feat: edit Vani of the day
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
Flag,
|
||||
Gift,
|
||||
Megaphone,
|
||||
Quote,
|
||||
FolderClosed,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
@@ -30,6 +31,7 @@ const NAV = [
|
||||
{ href: "/reports", label: "Reports", icon: Flag },
|
||||
{ href: "/broadcasts", label: "Announcements", icon: Megaphone },
|
||||
{ href: "/reward-gifts", label: "Gifts", icon: Gift },
|
||||
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({
|
||||
|
||||
@@ -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
@@ -480,3 +480,72 @@ export const rewardGifts = {
|
||||
body: JSON.stringify({ reward_key: rewardKey }),
|
||||
}).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),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user