feat: edit Vani of the day

This commit is contained in:
gosangam
2026-08-31 12:15:19 +05:30
parent df9af9dbac
commit 53e00863f7
3 changed files with 288 additions and 0 deletions
+2
View File
@@ -7,6 +7,7 @@ import {
Flag, Flag,
Gift, Gift,
Megaphone, Megaphone,
Quote,
FolderClosed, FolderClosed,
LayoutDashboard, LayoutDashboard,
LayoutGrid, LayoutGrid,
@@ -30,6 +31,7 @@ const NAV = [
{ href: "/reports", label: "Reports", icon: Flag }, { href: "/reports", label: "Reports", icon: Flag },
{ href: "/broadcasts", label: "Announcements", icon: Megaphone }, { href: "/broadcasts", label: "Announcements", icon: Megaphone },
{ href: "/reward-gifts", label: "Gifts", icon: Gift }, { href: "/reward-gifts", label: "Gifts", icon: Gift },
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote },
]; ];
export default function DashboardLayout({ export default function DashboardLayout({
+217
View File
@@ -0,0 +1,217 @@
"use client";
/**
* /vani — choose the Vāṇī of the Day.
*
* Days without a pinned pick are left alone: the BFF chooses one at random on
* the day's first request, avoiding anything served in the last 90 days. This
* page only overrides — so an empty schedule is the normal, healthy state, not
* something to fill in.
*
* Pinning sends only a reference. The BFF rebuilds the display blob with the
* same builders the random picker uses, so a pinned pick renders exactly like
* an automatic one.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
vani,
vaniKindFor,
type VaniCandidate,
type VaniPick,
} from "@/lib/api";
/** Today in UTC — picks are keyed on a UTC calendar day. */
function todayUTC(): string {
return new Date().toISOString().slice(0, 10);
}
export default function VaniPage() {
const [schedule, setSchedule] = useState<VaniPick[] | null>(null);
const [day, setDay] = useState(todayUTC());
const [query, setQuery] = useState("");
const [results, setResults] = useState<{ q: string; rows: VaniCandidate[] }>({
q: "",
rows: [],
});
const [busy, setBusy] = useState(false);
const refresh = () =>
vani
.schedule()
.then(setSchedule)
.catch(() => {
toast.error("Could not load the schedule");
setSchedule([]);
});
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const q = query.trim();
const searchable = q.length >= 2;
// Results carry the query they belong to, so a stale page simply stops
// matching rather than needing to be cleared.
const visible = results.q === q ? results.rows : [];
useEffect(() => {
if (!searchable) return;
const timer = setTimeout(() => {
vani
.search(q)
.then((rows) => setResults({ q, rows }))
.catch(() => setResults({ q, rows: [] }));
}, 300);
return () => clearTimeout(timer);
}, [q, searchable]);
const pin = async (candidate: VaniCandidate) => {
const kind = vaniKindFor(candidate.collection);
if (!kind) return;
setBusy(true);
try {
await vani.set({ day, kind, ref_id: candidate.doc_id });
toast.success(`Set for ${day}`);
setQuery("");
setResults({ q: "", rows: [] });
await refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not set it");
} finally {
setBusy(false);
}
};
const clear = async (pick: VaniPick) => {
if (
!window.confirm(
`Clear ${pick.day}? A random pick will be chosen instead.`,
)
) {
return;
}
setBusy(true);
try {
await vani.clear(pick.day);
toast.success("Cleared");
await refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not clear");
} finally {
setBusy(false);
}
};
const field =
"rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40";
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold text-foreground">
Vāṇī of the Day
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Pin a verse, chapter or lecture to a date. Days you leave alone get a
random pick that avoids anything shown in the last 90 days.
</p>
</header>
<section className="space-y-3 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm text-muted-foreground">
Date
<input
type="date"
value={day}
min={todayUTC()}
onChange={(e) => setDay(e.target.value)}
className={`${field} ml-2`}
/>
</label>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search verses, chapters and lectures"
className={`${field} min-w-0 flex-1`}
/>
</div>
{!searchable ? (
<p className="text-xs text-muted-foreground">
Type at least two characters to search.
</p>
) : visible.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing found.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{visible.map((c) => (
<li
key={c.doc_id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{c.title}</p>
<p className="truncate text-xs text-muted-foreground">
{vaniKindFor(c.collection)} · {c.snippet}
</p>
</div>
<button
type="button"
disabled={busy}
onClick={() => pin(c)}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Set for {day}
</button>
</li>
))}
</ul>
)}
</section>
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">
Pinned from today onward
</h2>
{schedule === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : schedule.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing pinned — every day is chosen at random.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{schedule.map((p) => (
<li key={p.day} className="flex items-center gap-3 px-3 py-2.5">
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{p.day}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{p.payload.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{p.payload.label}
{p.payload.book_title ? ` · ${p.payload.book_title}` : ""}
</p>
</div>
<button
type="button"
disabled={busy}
onClick={() => clear(p)}
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Clear
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+69
View File
@@ -480,3 +480,72 @@ export const rewardGifts = {
body: JSON.stringify({ reward_key: rewardKey }), body: JSON.stringify({ reward_key: rewardKey }),
}).then((r) => r.data), }).then((r) => r.data),
}; };
// ─── Vāṇī of the Day ────────────────────────────────────────────────────────
/** One decided day. `payload` is the blob the home-page CTA renders. */
export type VaniPick = {
day: string;
kind: "verse" | "prose" | "transcript";
ref_id: string;
payload: {
kind: string;
label: string;
title: string;
subtitle: string;
href: string;
book_title?: string;
date?: string;
};
};
/** A search hit, narrowed to what pinning needs. */
export type VaniCandidate = {
collection: string;
doc_id: string;
title: string;
snippet: string;
url: string;
};
/** Typesense collection → the kind the vani picker uses. */
const VANI_KINDS: Record<string, VaniPick["kind"]> = {
verses: "verse",
prose: "prose",
transcriptions: "transcript",
};
export function vaniKindFor(collection: string): VaniPick["kind"] | null {
return VANI_KINDS[collection] ?? null;
}
export const vani = {
/** Decided days from today onward. */
schedule: () => call<VaniPick[]>("/admin/vani?limit=30").then((r) => r.data),
/** Pin one item to a day. Only the reference travels — the BFF rebuilds the
* display blob, so a pinned pick renders exactly like an automatic one. */
set: (input: { day: string; kind: VaniPick["kind"]; ref_id: string }) =>
call<VaniPick>("/admin/vani", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.data),
/** Hand the day back to the random picker. */
clear: (day: string) =>
call<void>(`/admin/vani/${encodeURIComponent(day)}`, { method: "DELETE" }),
/**
* Search the three vāṇī-eligible collections. Uses the public search
* endpoint — the same index the app searches — and keeps only hits that map
* to a pinnable kind.
*/
search: async (q: string): Promise<VaniCandidate[]> => {
const { data } = await call<{ results: VaniCandidate[] }>(
`/search?type=all&limit=20&q=${encodeURIComponent(q)}`,
);
return (data?.results ?? []).filter(
(r) => r.doc_id && vaniKindFor(r.collection),
);
},
};