Files
spsays-admin/app/(dashboard)/vani/page.tsx
T
2026-10-16 15:45:29 +05:30

267 lines
8.9 KiB
TypeScript

"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 { Can } from "@/components/staff-context";
import { toast } from "sonner";
import {
vani,
vaniKindFor,
type VaniCandidate,
type VaniPick,
} from "@/lib/api";
import { Clamp } from "@/components/ui/clamp";
/** 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 [link, setLink] = useState("");
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);
}
};
// Pasting the link is the primary path: it names one exact item, where
// search can only match words. A slug searched as text finds its
// neighbours, not itself.
const pinByLink = async () => {
const url = link.trim();
if (!url) return;
setBusy(true);
try {
const pick = await vani.setByURL({ day, url });
toast.success(`Set ${pick.payload.title} for ${day}`);
setLink("");
await refresh();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Could not use that link",
);
} 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>
<Can perm="vani:write">
<section className="space-y-3 rounded-xl border border-border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void pinByLink()}
placeholder="Paste a link — /transcriptions/720720sb-london"
className={`${field} w-full min-w-0 sm:flex-1`}
/>
<button
type="button"
disabled={busy || !link.trim()}
onClick={pinByLink}
className="shrink-0 whitespace-nowrap rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Use this link
</button>
</div>
<p className="text-xs text-muted-foreground">
Or search by words. A slug only matches its own words, so paste the
link when you know exactly which item you want.
</p>
<div className="flex flex-col gap-3 border-t border-border pt-3 sm:flex-row sm:items-center">
<label className="flex items-center text-sm text-muted-foreground">
Date
<input
type="date"
value={day}
min={todayUTC()}
onChange={(e) => setDay(e.target.value)}
className={`${field} ml-2 min-w-0 flex-1 sm:flex-none`}
/>
</label>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search verses, chapters and lectures"
className={`${field} w-full min-w-0 sm: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">
<Clamp className="text-sm text-foreground">{c.title}</Clamp>
<Clamp className="text-xs text-muted-foreground">
{`${vaniKindFor(c.collection)} · ${c.snippet}`}
</Clamp>
</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>
</Can>
<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">
<Clamp className="text-sm text-foreground">
{p.payload.title}
</Clamp>
<Clamp className="text-xs text-muted-foreground">
{`${p.payload.label}${p.payload.book_title ? ` · ${p.payload.book_title}` : ""}`}
</Clamp>
</div>
<Can perm="vani:write">
<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>
</Can>
</li>
))}
</ul>
)}
</section>
</div>
);
}