267 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|