Compare commits

...
10 Commits
Author SHA1 Message Date
gosangam 111485a43e feat: quiz 2026-10-15 19:40:28 +05:30
gosangam 3f3fa9a720 feat: rtf 2026-10-09 06:22:31 +05:30
gosangam 082815fc53 feat: vani of the day 2026-08-31 13:40:08 +05:30
gosangam 53e00863f7 feat: edit Vani of the day 2026-08-31 12:15:19 +05:30
gosangam df9af9dbac feat: add stickers delete api 2026-08-30 10:32:13 +05:30
gosangam f295f59d0b feat: add custom stickers 2026-08-30 03:03:00 +05:30
gosangam e0294d80e3 feat: add stickers collection 2026-08-30 02:19:16 +05:30
gosangam c9ac2bd3b4 feat: broadcast 2026-08-29 18:34:33 +05:30
gosangam 7ff5610477 feat: disciple link 2026-08-07 22:46:41 +05:30
gosangam 5c9a689306 feat: url dashboard 2026-07-11 01:10:55 +05:30
25 changed files with 4187 additions and 96 deletions
+310
View File
@@ -0,0 +1,310 @@
"use client";
/**
* /broadcasts — compose and publish an announcement.
*
* Publishing is one-way and immediate: it pushes to every subscribed device,
* nudges everyone currently connected, and each user is shown the modal once.
* There is no recall, which is why publishing is behind a confirmation and
* why drafts exist.
*
* The only delivery signal is the dismissal count, since nothing is stored on
* the user's side.
*/
import { useEffect, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import { broadcasts, type Broadcast } from "@/lib/api";
import { compressImage } from "@/lib/compress-image";
export default function BroadcastsPage() {
const [items, setItems] = useState<Broadcast[]>([]);
const [loading, setLoading] = useState(true);
const refresh = () =>
broadcasts
.list()
.then((r) => setItems(r.items))
.catch(() => toast.error("Could not load announcements"))
.finally(() => setLoading(false));
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold text-foreground">Announcements</h1>
<p className="mt-1 text-sm text-muted-foreground">
Pushed to every user and shown once, in a modal. Nothing is saved to
their notification history.
</p>
</header>
<Can perm="broadcasts:write">
<Composer onDone={refresh} />
</Can>
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">Recent</h2>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing sent yet.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((b) => (
<BroadcastRow key={b.id} item={b} onChanged={refresh} />
))}
</ul>
)}
</section>
</div>
);
}
function Composer({ onDone }: { onDone: () => void }) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [ctaLabel, setCtaLabel] = useState("");
const [ctaUrl, setCtaUrl] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [bannerKey, setBannerKey] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false);
const uploadBanner = async (original: File) => {
setUploading(true);
try {
// Compress before the presigned PUT. A banner renders at roughly 2:1 in
// a ~448px modal, so a phone-camera original is orders of magnitude
// larger than anything that reaches a screen — and every devotee
// downloads it. Falls back to the original on any decode failure, and
// leaves GIFs alone so an animation isn't flattened.
const file = await compressImage(original, { maxDimension: 1280 }).catch(
() => original,
);
const { upload_url, key } = await broadcasts.presignBanner(file.type);
const res = await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) throw new Error(String(res.status));
setBannerKey(key);
toast.success("Banner uploaded");
} catch {
toast.error("Banner upload failed");
} finally {
setUploading(false);
}
};
const submit = async (publish: boolean) => {
if (!title.trim() || !body.trim()) return;
if (
publish &&
!window.confirm(
"Publish now? This pushes to every user immediately and cannot be recalled.",
)
) {
return;
}
setBusy(true);
try {
await broadcasts.create({
title: title.trim(),
body: body.trim(),
banner_key: bannerKey,
cta_label: ctaLabel.trim() || null,
cta_url: ctaUrl.trim() || null,
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null,
publish,
});
toast.success(publish ? "Published" : "Draft saved");
setTitle("");
setBody("");
setCtaLabel("");
setCtaUrl("");
setExpiresAt("");
setBannerKey(null);
onDone();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not save");
} finally {
setBusy(false);
}
};
const field =
"w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40";
return (
<section className="space-y-3 rounded-lg border border-border p-4">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
placeholder="Title"
className={field}
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
maxLength={2000}
placeholder="Description — *bold*, ~strike~ and `code` work, same as chat."
className={`${field} resize-y`}
/>
<div className="grid gap-3 sm:grid-cols-2">
<input
value={ctaLabel}
onChange={(e) => setCtaLabel(e.target.value)}
maxLength={40}
placeholder="Button label (optional)"
className={field}
/>
<input
value={ctaUrl}
onChange={(e) => setCtaUrl(e.target.value)}
placeholder="Button URL — /chats or https://…"
className={field}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-muted-foreground">
Stop showing after
<input
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
className={`${field} mt-1`}
/>
</label>
<label className="text-sm text-muted-foreground">
Banner image
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={uploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadBanner(f);
}}
className={`${field} mt-1`}
/>
{bannerKey && (
<span className="mt-1 block text-xs text-primary">
Uploaded — it will appear above the text.
</span>
)}
</label>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
disabled={busy || !title.trim() || !body.trim()}
onClick={() => submit(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-muted disabled:opacity-50"
>
Save draft
</button>
<Can perm="broadcasts:publish">
<button
type="button"
disabled={busy || !title.trim() || !body.trim()}
onClick={() => submit(true)}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Publish now
</button>
</Can>
</div>
</section>
);
}
function BroadcastRow({
item,
onChanged,
}: {
item: Broadcast;
onChanged: () => void;
}) {
const [busy, setBusy] = useState(false);
const live = !!item.published_at;
const act = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
try {
await fn();
toast.success(ok);
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setBusy(false);
}
};
return (
<li className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.title}
</p>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{item.body}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{live
? `Published · dismissed by ${item.seen_count}`
: "Draft — not sent"}
{item.expires_at
? ` · stops ${new Date(item.expires_at).toLocaleDateString()}`
: ""}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Can perm="broadcasts:publish">
{!live && (
<button
type="button"
disabled={busy}
onClick={() =>
window.confirm("Publish now? This cannot be recalled.") &&
act(() => broadcasts.publish(item.id), "Published")
}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Publish
</button>
)}
</Can>
<Can perm="broadcasts:write">
<button
type="button"
disabled={busy}
onClick={() =>
window.confirm(
live
? "Delete this? Anyone who hasn't seen it never will."
: "Delete this draft?",
) && act(() => broadcasts.remove(item.id), "Deleted")
}
className="rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Delete
</button>
</Can>
</div>
</li>
);
}
+38 -23
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import { api, AdminError, type AdminFeedback } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
@@ -9,7 +10,8 @@ import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { useConfirm } from "@/components/confirm";
import { Textarea } from "@/components/ui/input";
import { RichTextarea } from "@/components/ui/rich-textarea";
import { RichText } from "@/lib/rich-text";
import { cn, fmtDate } from "@/lib/utils";
const STATUSES = ["all", "open", "resolved"] as const;
@@ -39,7 +41,8 @@ export default function FeedbackPage() {
if (
await confirm({
title: "Acknowledge this feedback?",
message: "The author will be emailed that we've seen it and started work.",
message:
"The author will be emailed that we've seen it and started work.",
confirmLabel: "Acknowledge",
})
) {
@@ -51,7 +54,8 @@ export default function FeedbackPage() {
if (
await confirm({
title: "Mark resolved?",
message: "The author will be emailed a thank-you. This can't be undone.",
message:
"The author will be emailed a thank-you. This can't be undone.",
confirmLabel: "Mark resolved",
})
) {
@@ -121,7 +125,7 @@ export default function FeedbackPage() {
{f.reply_message ? (
<div className="mt-2 rounded-md bg-muted px-3 py-2 text-xs">
<span className="font-medium">Your reply: </span>
{f.reply_message}
<RichText text={f.reply_message} />
</div>
) : null}
</TD>
@@ -131,27 +135,35 @@ export default function FeedbackPage() {
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
<TD className="text-right">
<div className="flex flex-wrap justify-end gap-2">
{!f.resolved_at && !f.acknowledged_at ? (
<Button
size="sm"
variant="ghost"
onClick={() => acknowledge(f)}
>
Acknowledge
</Button>
) : null}
{!f.resolved_at ? (
<Can perm="feedback:resolve">
{!f.resolved_at && !f.acknowledged_at ? (
<Button
size="sm"
variant="ghost"
onClick={() => acknowledge(f)}
>
Acknowledge
</Button>
) : null}
{!f.resolved_at ? (
<Button
size="sm"
variant="outline"
onClick={() => resolve(f)}
>
Resolve
</Button>
) : null}
</Can>
<Can perm="feedback:reply">
<Button
size="sm"
variant="outline"
onClick={() => resolve(f)}
onClick={() => setReply(f)}
>
Resolve
{f.replied_at ? "Reply again" : "Reply"}
</Button>
) : null}
<Button size="sm" variant="outline" onClick={() => setReply(f)}>
{f.replied_at ? "Reply again" : "Reply"}
</Button>
</Can>
</div>
</TD>
</TR>
@@ -205,7 +217,10 @@ function ReplyModal({
if (!feedback || message.trim() === "") return;
setBusy(true);
try {
const { data } = (await api.replyFeedback(feedback.id, message.trim())) as {
const { data } = (await api.replyFeedback(
feedback.id,
message.trim(),
)) as {
data: { replied: boolean; emailed: boolean };
};
toast.success(
@@ -235,9 +250,9 @@ function ReplyModal({
) : null}
<label className="block space-y-1.5">
<span className="text-sm font-medium">Your reply</span>
<Textarea
<RichTextarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onValueChange={setMessage}
rows={5}
maxLength={4000}
placeholder="Write your reply… (emailed to the submitter; use Resolve separately to close it)"
+121 -34
View File
@@ -4,26 +4,71 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
ClipboardCheck,
Flag,
ShieldCheck,
Gift,
Megaphone,
Quote,
FolderClosed,
LayoutDashboard,
LayoutGrid,
Link2,
LogOut,
MessageSquare,
Users,
} from "lucide-react";
import { api, type Me } from "@/lib/api";
import { api, staff, MANAGE_STAFF, type Me, type StaffMe } from "@/lib/api";
import { RequirePermission, StaffProvider } from "@/components/staff-context";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ConfirmProvider } from "@/components/confirm";
// Each entry names the permission that unlocks it, so the sidebar only shows
// what the signed-in role can actually use.
const NAV = [
{ href: "/", label: "Overview", icon: LayoutDashboard },
{ href: "/users", label: "Users", icon: Users },
{ href: "/posts", label: "Posts", icon: LayoutGrid },
{ href: "/collections", label: "Collections", icon: FolderClosed },
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
{ href: "/reports", label: "Reports", icon: Flag },
{ href: "/", label: "Overview", icon: LayoutDashboard, perm: "stats:read" },
{ href: "/users", label: "Users", icon: Users, perm: "users:read" },
{ href: "/posts", label: "Posts", icon: LayoutGrid, perm: "posts:moderate" },
{
href: "/collections",
label: "Collections",
icon: FolderClosed,
perm: "collections:read",
},
{
href: "/short-urls",
label: "Short URLs",
icon: Link2,
perm: "short_urls:read",
},
{
href: "/feedback",
label: "Feedback",
icon: MessageSquare,
perm: "feedback:read",
},
{ href: "/reports", label: "Reports", icon: Flag, perm: "reports:read" },
{
href: "/broadcasts",
label: "Announcements",
icon: Megaphone,
perm: "broadcasts:read",
},
{ href: "/reward-gifts", label: "Gifts", icon: Gift, perm: "rewards:read" },
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote, perm: "vani:read" },
{
href: "/quizzes",
label: "Quizzes",
icon: ClipboardCheck,
perm: "quizzes:read",
},
{
href: "/staff",
label: "Staff & roles",
icon: ShieldCheck,
perm: MANAGE_STAFF,
},
];
export default function DashboardLayout({
@@ -34,22 +79,24 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const [me, setMe] = useState<Me | null>(null);
const [staffMe, setStaffMe] = useState<StaffMe | null>(null);
const [checked, setChecked] = useState(false);
// Admin gate: only an authenticated admin may see the dashboard. Anyone
// else is bounced to /login. The BFF independently enforces this on every
// /admin/* call, so this is UX, not the security boundary.
// Staff gate: only an account holding an admin role, and not blocked from
// the panel, may see the dashboard. /admin/me answers both at once — it 403s
// otherwise. The BFF enforces this on every /admin/* call regardless, so
// this is UX, not the security boundary.
useEffect(() => {
let cancelled = false;
api
.me()
.then((u) => {
Promise.all([api.me(), staff.me()])
.then(([u, s]) => {
if (cancelled) return;
if (!u || u.role !== "admin") {
if (!u || !s) {
router.replace("/login");
return;
}
setMe(u);
setStaffMe(s);
setChecked(true);
})
.catch(() => {
@@ -60,12 +107,25 @@ export default function DashboardLayout({
};
}, [router]);
// A role without stats:read has no Overview — send them to the first
// section they can use instead of a page of permission errors.
useEffect(() => {
if (
!staffMe ||
pathname !== "/" ||
staffMe.permissions.includes("stats:read")
)
return;
const first = NAV.find((n) => staffMe.permissions.includes(n.perm));
if (first) router.replace(first.href);
}, [staffMe, pathname, router]);
async function signOut() {
await api.logout().catch(() => {});
router.replace("/login");
}
if (!checked || !me) {
if (!checked || !me || !staffMe) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Loading…
@@ -83,29 +143,35 @@ export default function DashboardLayout({
<p className="mt-0.5 text-sm font-semibold text-foreground">Admin</p>
</div>
<nav className="flex-1 space-y-1 px-3">
{NAV.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{label}
</Link>
);
})}
{NAV.filter((n) => staffMe.permissions.includes(n.perm)).map(
({ href, label, icon: Icon }) => {
const active =
pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{label}
</Link>
);
},
)}
</nav>
<div className="border-t border-border px-3 py-3">
<div className="px-3 pb-2 text-xs text-muted-foreground">
<p className="truncate font-medium text-foreground">{me.name}</p>
<p className="truncate">@{me.handle}</p>
<p className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{staffMe.role_name}
</p>
</div>
<Button
variant="ghost"
@@ -121,7 +187,28 @@ export default function DashboardLayout({
<main className="flex-1 overflow-x-hidden px-6 py-6 lg:px-10">
<div className="mx-auto max-w-6xl">
<ConfirmProvider>{children}</ConfirmProvider>
<StaffProvider value={staffMe}>
<ConfirmProvider>
{/* The section's own permission, so a URL typed straight in
explains itself instead of failing call by call. */}
{(() => {
const section =
NAV.find(
(n) =>
n.href !== "/" &&
(pathname === n.href ||
pathname.startsWith(n.href + "/")),
) ?? (pathname === "/" ? NAV[0] : undefined);
return section ? (
<RequirePermission perm={section.perm}>
{children}
</RequirePermission>
) : (
children
);
})()}
</ConfirmProvider>
</StaffProvider>
</div>
</main>
</div>
+1 -1
View File
@@ -54,7 +54,7 @@ export default function OverviewPage() {
<h2 className="text-lg font-semibold text-foreground">
Feedback by category
</h2>
{stats.feedback_by_category.length === 0 ? (
{!stats.feedback_by_category?.length ? (
<p className="text-sm text-muted-foreground">No feedback yet.</p>
) : (
<div className="max-w-md divide-y divide-border rounded-xl border border-border bg-card">
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { quizzes, AdminError, type Quiz } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/components/confirm";
import { Can, useStaff } from "@/components/staff-context";
import { cn } from "@/lib/utils";
import { QuizEditor } from "../quiz-editor";
import { KIND_LABEL } from "../labels";
import { Participants, Responses } from "./results";
type Tab = "edit" | "participants" | "responses";
export default function QuizDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const confirm = useConfirm();
const { can } = useStaff();
const [quiz, setQuiz] = useState<Quiz | null>(null);
const [tab, setTab] = useState<Tab>("edit");
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
quizzes
.get(id)
.then(setQuiz)
.catch((err) =>
toast.error(
err instanceof AdminError ? err.message : "Could not load the quiz",
),
);
}, [id]);
useEffect(() => {
load();
}, [load]);
if (!quiz) return <p className="text-sm text-muted-foreground">Loading…</p>;
const togglePublish = async () => {
const on = !quiz.published_at;
if (
!on &&
!(await confirm({
title: "Unpublish this quiz?",
message:
"It disappears from the app. Results so far are kept, and you can publish it again.",
confirmLabel: "Unpublish",
}))
) {
return;
}
setBusy(true);
try {
setQuiz(await quizzes.setPublished(quiz.id, on));
toast.success(on ? "Published" : "Unpublished");
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "That didn't work");
} finally {
setBusy(false);
}
};
const remove = async () => {
if (
!(await confirm({
title: `Delete “${quiz.title}”?`,
message:
"It disappears from the app. Participants' results are kept on record.",
confirmLabel: "Delete",
destructive: true,
}))
) {
return;
}
try {
await quizzes.remove(quiz.id);
toast.success("Quiz deleted");
router.replace("/quizzes");
} catch (err) {
toast.error(
err instanceof AdminError ? err.message : "Could not delete the quiz",
);
}
};
const tabs: { key: Tab; label: string; show: boolean }[] = [
{
key: "edit",
label: can("quizzes:write") ? "Edit" : "Questions",
show: true,
},
{
key: "participants",
label: `Participants (${quiz.attempt_count ?? 0})`,
show: can("quizzes:results"),
},
{ key: "responses", label: "Responses", show: can("quizzes:results") },
];
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<Link
href="/quizzes"
className="text-xs text-muted-foreground hover:text-foreground"
>
← Quizzes
</Link>
<h1 className="mt-1 text-xl font-semibold text-foreground">
{quiz.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{KIND_LABEL[quiz.target_kind]}
{quiz.target_title && ` · ${quiz.target_title}`}
{quiz.target_path && (
<span className="ml-1 font-mono text-xs">
({quiz.target_path})
</span>
)}
</p>
</div>
<div className="flex gap-2">
<Can perm="quizzes:write">
<Button
variant={quiz.published_at ? "outline" : "primary"}
disabled={busy}
onClick={() => void togglePublish()}
>
{quiz.published_at ? "Unpublish" : "Publish"}
</Button>
</Can>
<Can perm="quizzes:delete">
<Button variant="ghost" onClick={() => void remove()}>
Delete
</Button>
</Can>
</div>
</header>
<div className="flex gap-1 border-b border-border">
{tabs
.filter((t) => t.show)
.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-sm transition-colors",
tab === t.key
? "border-primary font-medium text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
{tab === "edit" && (
<QuizEditor
key={quiz.updated_at}
initial={quiz}
onSaved={setQuiz}
readOnly={!can("quizzes:write")}
/>
)}
{tab === "participants" && <Participants quizId={quiz.id} />}
{tab === "responses" && <Responses quizId={quiz.id} />}
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
"use client";
/**
* Who took a quiz, and how each question went.
*
* Response times are measured by the BFF — from the moment a question was
* served to the moment its answer arrived — so they can't be massaged by the
* browser. They include any time the person spent away from the screen.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
quizzes,
AdminError,
type QuizParticipant,
type QuizQuestionStat,
type QuizResult,
} from "@/lib/api";
import { Avatar } from "@/components/ui/avatar";
import { Modal } from "@/components/ui/modal";
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { RichText } from "@/lib/rich-text";
import { cn, fmtDate } from "@/lib/utils";
import { fmtMs } from "../labels";
const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback;
export function Participants({ quizId }: { quizId: string }) {
const [rows, setRows] = useState<QuizParticipant[] | null>(null);
const [open, setOpen] = useState<QuizParticipant | null>(null);
const [detail, setDetail] = useState<QuizResult | null>(null);
useEffect(() => {
quizzes
.participants(quizId)
.then(setRows)
.catch((err) => {
toast.error(errMsg(err, "Could not load participants"));
setRows([]);
});
}, [quizId]);
const view = (p: QuizParticipant) => {
setOpen(p);
setDetail(null);
quizzes
.participant(quizId, p.user_id)
.then(setDetail)
.catch((err) => toast.error(errMsg(err, "Could not load their answers")));
};
if (rows === null)
return <p className="text-sm text-muted-foreground">Loading…</p>;
if (rows.length === 0)
return (
<p className="text-sm text-muted-foreground">
Nobody has taken this quiz yet.
</p>
);
return (
<>
<Table>
<THead>
<TR>
<TH>Person</TH>
<TH>Score</TH>
<TH>Progress</TH>
<TH>Time</TH>
<TH>Started</TH>
</TR>
</THead>
<tbody>
{rows.map((p) => (
<TR key={p.user_id}>
<TD>
<button
type="button"
onClick={() => view(p)}
className="flex items-center gap-3 text-left hover:underline"
>
<Avatar
src={p.avatar_url ?? null}
name={p.name}
className="size-8"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{p.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
@{p.handle}
</span>
</span>
</button>
</TD>
<TD>
{p.completed_at ? (
<span className="font-medium">
{p.score} / {p.total}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TD>
<TD>
{p.completed_at ? (
<span className="text-xs text-primary">Finished</span>
) : (
<span className="text-xs text-muted-foreground">
{p.answered} of {p.total} answered
</span>
)}
</TD>
<TD className="tabular-nums">{fmtMs(p.total_ms)}</TD>
<TD className="text-xs text-muted-foreground">
{fmtDate(p.started_at)}
</TD>
</TR>
))}
</tbody>
</Table>
<Modal
open={open !== null}
onClose={() => setOpen(null)}
title={open ? `${open.name}'s answers` : ""}
>
{!detail ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
<p className="text-sm text-muted-foreground">
{detail.completed_at
? `Scored ${detail.score} of ${detail.total}`
: "Still in progress"}{" "}
· {fmtMs(detail.total_ms)} in total
</p>
{detail.items.map((it, i) => (
<div key={it.id ?? i} className="space-y-1.5">
<div className="flex items-start justify-between gap-3 text-sm">
<p className="font-medium">
{i + 1}. <RichText text={it.prompt} />
</p>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{fmtMs(it.response_ms)}
</span>
</div>
<ul className="space-y-1">
{it.options.map((o) => {
const chosen = !!o.id && it.chosen_option_ids.includes(o.id);
return (
<li
key={o.id}
className={cn(
"rounded-md px-3 py-1.5 text-sm",
o.is_correct
? "bg-primary font-medium text-primary-foreground"
: chosen
? "bg-destructive/10 text-destructive"
: "bg-muted/40",
)}
>
<RichText text={o.body} />
{chosen && (
<span className="ml-2 text-xs">← their answer</span>
)}
</li>
);
})}
</ul>
{it.chosen_option_ids.length === 0 && (
<p className="text-xs text-muted-foreground">
Not answered yet.
</p>
)}
</div>
))}
</div>
)}
</Modal>
</>
);
}
export function Responses({ quizId }: { quizId: string }) {
const [stats, setStats] = useState<QuizQuestionStat[] | null>(null);
useEffect(() => {
quizzes
.responses(quizId)
.then(setStats)
.catch((err) => {
toast.error(errMsg(err, "Could not load responses"));
setStats([]);
});
}, [quizId]);
if (stats === null)
return <p className="text-sm text-muted-foreground">Loading…</p>;
return (
<ol className="space-y-4">
{stats.map((q, i) => {
const pct = q.answered ? Math.round((q.correct / q.answered) * 100) : 0;
return (
<li key={q.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-4">
<p className="text-sm font-medium">
{i + 1}. <RichText text={q.prompt} />
{q.multi && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Multiple answers
</span>
)}
</p>
<div className="shrink-0 text-right text-xs text-muted-foreground">
<p>
<span className="font-medium text-foreground">{pct}%</span>{" "}
correct · {q.answered} answered
</p>
<p className="tabular-nums">
avg {fmtMs(q.avg_ms)} · median {fmtMs(q.median_ms)}
</p>
</div>
</div>
<ul className="mt-3 space-y-1.5">
{q.options.map((o) => {
const share = q.answered ? (o.picks / q.answered) * 100 : 0;
return (
<li
key={o.id}
className="relative overflow-hidden rounded-md border border-border"
>
<div
className={cn(
"absolute inset-y-0 left-0",
o.is_correct ? "bg-primary/15" : "bg-muted",
)}
style={{ width: `${share}%` }}
aria-hidden
/>
<div className="relative flex items-center justify-between gap-3 px-3 py-1.5 text-sm">
<span
className={cn(
o.is_correct && "font-medium text-primary",
)}
>
<RichText text={o.body} />
{o.is_correct && (
<span className="ml-2 text-xs">✓ correct</span>
)}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{o.picks} · {Math.round(share)}%
</span>
</div>
</li>
);
})}
</ul>
</li>
);
})}
</ol>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Quiz } from "@/lib/api";
export const KIND_LABEL: Record<Quiz["target_kind"], string> = {
chapter: "Book chapter",
lecture: "Lecture",
conversation: "Conversation",
standalone: "Stand-alone",
};
/** "1m 12s" / "8.4s" — response times read better than raw milliseconds. */
export function fmtMs(ms: number | null | undefined): string {
if (ms == null) return "—";
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { QuizEditor } from "../quiz-editor";
import { RequirePermission } from "@/components/staff-context";
export default function NewQuizPage() {
const router = useRouter();
return (
<RequirePermission perm="quizzes:write">
<div className="space-y-6">
<h1 className="text-xl font-semibold text-foreground">New quiz</h1>
<QuizEditor
initial={null}
onSaved={(q) => router.replace(`/quizzes/${q.id}`)}
/>
</div>
</RequirePermission>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
/**
* /quizzes — every quiz, with how many people have started and finished it.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { quizzes, AdminError, type Quiz } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { Can } from "@/components/staff-context";
import { fmtDate } from "@/lib/utils";
import { KIND_LABEL } from "./labels";
export default function QuizzesPage() {
const [list, setList] = useState<Quiz[] | null>(null);
useEffect(() => {
quizzes
.list()
.then(setList)
.catch((err) => {
toast.error(
err instanceof AdminError ? err.message : "Could not load quizzes",
);
setList([]);
});
}, []);
return (
<div className="space-y-6">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Quizzes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Quizzes on book chapters, lectures and conversations — or on their
own. A published quiz shows a button on its page and appears in the
app&apos;s quiz list.
</p>
</div>
<Can perm="quizzes:write">
<Link href="/quizzes/new">
<Button>New quiz</Button>
</Link>
</Can>
</header>
{list === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">No quizzes yet.</p>
) : (
<Table>
<THead>
<TR>
<TH>Quiz</TH>
<TH>Attached to</TH>
<TH>Questions</TH>
<TH>Taken</TH>
<TH>Status</TH>
</TR>
</THead>
<tbody>
{list.map((q) => (
<TR key={q.id}>
<TD>
<Link
href={`/quizzes/${q.id}`}
className="font-medium text-foreground hover:underline"
>
{q.title}
</Link>
<p className="text-xs text-muted-foreground">
Updated {fmtDate(q.updated_at)}
</p>
</TD>
<TD>
<p className="text-sm">{KIND_LABEL[q.target_kind]}</p>
{q.target_title && (
<p className="truncate text-xs text-muted-foreground">
{q.target_title}
</p>
)}
</TD>
<TD>{q.question_count}</TD>
<TD>
{q.completed_count ?? 0} finished
<span className="text-muted-foreground">
{" "}
/ {q.attempt_count ?? 0} started
</span>
</TD>
<TD>
{q.published_at ? (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
Published
</span>
) : (
<span className="text-xs text-muted-foreground">Draft</span>
)}
</TD>
</TR>
))}
</tbody>
</Table>
)}
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
/**
* Create or edit a quiz.
*
* Questions are multiple choice: 2–5 options, with exactly one correct — or,
* for a "multiple answers" question, one or more correct. Prompts,
* options, explanations and the description all take the same formatting
* markers as the rest of the panel (bold, italic, underline, strike, links).
*
* Once anyone has taken a quiz its questions are frozen — their answers point
* at those exact questions and options — so the editor shows them read-only
* and only the title, description and target stay editable.
*/
import { useState } from "react";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
quizzes,
AdminError,
type Quiz,
type QuizDraft,
type QuizQuestion,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextarea } from "@/components/ui/rich-textarea";
import { RichText } from "@/lib/rich-text";
import { cn } from "@/lib/utils";
const MAX_OPTIONS = 5;
const MIN_OPTIONS = 2;
const blankQuestion = (): QuizQuestion => ({
prompt: "",
explanation: "",
multi: false,
options: [
{ body: "", is_correct: true },
{ body: "", is_correct: false },
],
});
/** Mirrors the BFF's rules, so problems show before a round trip. */
function problemWith(q: QuizQuestion): string | null {
if (!q.prompt.trim()) return "needs a prompt";
if (q.options.length < MIN_OPTIONS || q.options.length > MAX_OPTIONS)
return `needs ${MIN_OPTIONS}–${MAX_OPTIONS} options`;
if (q.options.some((o) => !o.body.trim())) return "has an empty option";
const correct = q.options.filter((o) => o.is_correct).length;
if (q.multi && correct === 0) return "needs at least one correct option";
if (!q.multi && correct !== 1) return "needs exactly one correct option";
return null;
}
export function QuizEditor({
initial,
onSaved,
readOnly = false,
}: {
initial: Quiz | null;
onSaved: (q: Quiz) => void;
/** For staff who may view quizzes but not change them. */
readOnly?: boolean;
}) {
const taken = (initial?.attempt_count ?? 0) > 0;
// Questions render frozen either way; only `taken` explains why.
const locked = taken || readOnly;
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [targetUrl, setTargetUrl] = useState(initial?.target_path ?? "");
const [targetTitle, setTargetTitle] = useState(initial?.target_title ?? "");
const [questions, setQuestions] = useState<QuizQuestion[]>(
initial?.questions?.length ? initial.questions : [blankQuestion()],
);
const [saving, setSaving] = useState(false);
const editQ = (i: number, patch: Partial<QuizQuestion>) =>
setQuestions((qs) => qs.map((q, j) => (j === i ? { ...q, ...patch } : q)));
const editOption = (qi: number, oi: number, body: string) =>
editQ(qi, {
options: questions[qi].options.map((o, j) =>
j === oi ? { ...o, body } : o,
),
});
// Single answer: picking one clears the rest. Multiple answers: toggles.
const markCorrect = (qi: number, oi: number) =>
editQ(qi, {
options: questions[qi].options.map((o, j) =>
questions[qi].multi
? j === oi
? { ...o, is_correct: !o.is_correct }
: o
: { ...o, is_correct: j === oi },
),
});
// Back to a single answer keeps only the first correct option.
const setMulti = (qi: number, multi: boolean) => {
const opts = questions[qi].options;
const first = opts.findIndex((o) => o.is_correct);
editQ(qi, {
multi,
options: multi
? opts
: opts.map((o, j) => ({ ...o, is_correct: j === Math.max(first, 0) })),
});
};
const removeOption = (qi: number, oi: number) => {
const opts = questions[qi].options.filter((_, j) => j !== oi);
// Removing the last correct option would leave none marked; promote the
// first.
if (!opts.some((o) => o.is_correct) && opts[0])
opts[0] = { ...opts[0], is_correct: true };
editQ(qi, { options: opts });
};
const move = (i: number, by: number) =>
setQuestions((qs) => {
const j = i + by;
if (j < 0 || j >= qs.length) return qs;
const next = [...qs];
[next[i], next[j]] = [next[j], next[i]];
return next;
});
const save = async () => {
if (!title.trim()) return toast.error("Give the quiz a title");
if (!locked) {
const bad = questions.map(problemWith).findIndex(Boolean);
if (bad >= 0)
return toast.error(
`Question ${bad + 1} ${problemWith(questions[bad])}`,
);
}
const draft: QuizDraft = {
title: title.trim(),
description,
target_url: targetUrl.trim(),
target_title: targetUrl.trim() ? targetTitle.trim() : "",
...(locked ? {} : { questions }),
};
setSaving(true);
try {
const saved = initial
? await quizzes.update(initial.id, draft)
: await quizzes.create(draft);
toast.success(
initial ? "Quiz saved" : "Quiz created — publish it when it's ready",
);
onSaved(saved);
} catch (err) {
toast.error(
err instanceof AdminError ? err.message : "Could not save the quiz",
);
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<fieldset
disabled={readOnly}
className="space-y-3 rounded-xl border border-border p-4 disabled:opacity-80"
>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Quiz title"
maxLength={200}
/>
<RichTextarea
value={description}
onValueChange={setDescription}
rows={3}
maxLength={2000}
placeholder="Introduction shown before the first question (optional)"
/>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Input
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
placeholder="Link to a chapter, lecture or conversation"
/>
<p className="mt-1 text-xs text-muted-foreground">
Paste the page&apos;s link, e.g. /books/bg/2 or
/transcriptions/720720sb-london. Leave empty for a stand-alone
quiz.
</p>
</div>
{targetUrl.trim() && (
<div>
<Input
value={targetTitle}
onChange={(e) => setTargetTitle(e.target.value)}
placeholder="Content title (filled in automatically)"
/>
<p className="mt-1 text-xs text-muted-foreground">
Override only if the automatic title reads badly.
</p>
</div>
)}
</div>
</fieldset>
{taken && !readOnly && (
<p className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
{initial?.attempt_count}{" "}
{initial?.attempt_count === 1 ? "person has" : "people have"} taken
this quiz, so its questions can no longer change. You can still edit
the title, introduction and link.
</p>
)}
<ol className="space-y-4">
{questions.map((q, qi) => (
<li
key={q.id ?? `new-${qi}`}
className="rounded-xl border border-border p-4"
>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium text-foreground">
Question {qi + 1}
</p>
{!locked && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
disabled={qi === 0}
onClick={() => move(qi, -1)}
aria-label="Move up"
>
<ArrowUp className="size-4" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={qi === questions.length - 1}
onClick={() => move(qi, 1)}
aria-label="Move down"
>
<ArrowDown className="size-4" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={questions.length === 1}
onClick={() =>
setQuestions((qs) => qs.filter((_, j) => j !== qi))
}
aria-label="Delete question"
>
<Trash2 className="size-4" />
</Button>
</div>
)}
</div>
{locked ? (
<div className="space-y-2 text-sm">
<p className="text-foreground">
<RichText text={q.prompt} />
</p>
{q.multi && (
<p className="text-xs text-muted-foreground">
Multiple answers
</p>
)}
<ul className="space-y-1">
{q.options.map((o, oi) => (
<li
key={oi}
className={cn(
"rounded-md px-3 py-1.5",
o.is_correct
? "bg-primary font-medium text-primary-foreground"
: "bg-muted/40",
)}
>
<RichText text={o.body} />
</li>
))}
</ul>
</div>
) : (
<div className="space-y-3">
<RichTextarea
value={q.prompt}
onValueChange={(v) => editQ(qi, { prompt: v })}
rows={2}
maxLength={2000}
placeholder="Question"
/>
<label className="flex w-fit items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={q.multi}
onChange={(e) => setMulti(qi, e.target.checked)}
className="accent-primary"
/>
Multiple correct answers
<span className="text-xs text-muted-foreground">
— users select all that apply
</span>
</label>
<fieldset className="space-y-2">
<legend className="mb-1 text-xs text-muted-foreground">
{q.multi
? "Options — tick every correct one"
: "Options — select the correct one"}
</legend>
{q.options.map((o, oi) => (
<div key={oi} className="flex items-start gap-2">
<input
type={q.multi ? "checkbox" : "radio"}
name={`correct-${qi}`}
checked={o.is_correct}
onChange={() => markCorrect(qi, oi)}
className="mt-3 accent-primary"
aria-label={`Option ${oi + 1} is correct`}
/>
<RichTextarea
value={o.body}
onValueChange={(v) => editOption(qi, oi, v)}
rows={1}
maxLength={500}
placeholder={`Option ${oi + 1}`}
className="flex-1"
/>
<Button
size="sm"
variant="ghost"
className="mt-1"
disabled={q.options.length <= MIN_OPTIONS}
onClick={() => removeOption(qi, oi)}
aria-label={`Remove option ${oi + 1}`}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{q.options.length < MAX_OPTIONS && (
<Button
size="sm"
variant="ghost"
onClick={() =>
editQ(qi, {
options: [
...q.options,
{ body: "", is_correct: false },
],
})
}
>
<Plus className="size-4" /> Add option
</Button>
)}
</fieldset>
<RichTextarea
value={q.explanation}
onValueChange={(v) => editQ(qi, { explanation: v })}
rows={2}
maxLength={2000}
placeholder="Explanation shown with the answer (optional)"
/>
</div>
)}
</li>
))}
</ol>
<div className="flex items-center justify-between">
{!locked ? (
<Button
variant="outline"
onClick={() => setQuestions((qs) => [...qs, blankQuestion()])}
>
<Plus className="size-4" /> Add question
</Button>
) : (
<span />
)}
{!readOnly && (
<Button onClick={() => void save()} disabled={saving}>
{saving ? "Saving…" : initial ? "Save changes" : "Create quiz"}
</Button>
)}
</div>
</div>
);
}
+40 -20
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Can } from "@/components/staff-context";
import { toast } from "sonner";
import { api, AdminError, type AdminReport, type ReportType } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
@@ -8,7 +9,8 @@ import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/input";
import { RichTextarea } from "@/components/ui/rich-textarea";
import { RichText } from "@/lib/rich-text";
import { useConfirm } from "@/components/confirm";
import { cn, fmtDate } from "@/lib/utils";
@@ -45,7 +47,11 @@ export default function ReportsPage() {
if (r.type === "post") {
return (
<>
<Button size="sm" variant="outline" onClick={() => run(api.hidePost(r.target_id), "Post hidden.")}>
<Button
size="sm"
variant="outline"
onClick={() => run(api.hidePost(r.target_id), "Post hidden.")}
>
Hide
</Button>
<Button
@@ -55,7 +61,8 @@ export default function ReportsPage() {
if (
await confirm({
title: "Delete this post?",
message: "It will be removed from the app. You can undo this.",
message:
"It will be removed from the app. You can undo this.",
confirmLabel: "Delete post",
destructive: true,
})
@@ -78,7 +85,8 @@ export default function ReportsPage() {
if (
await confirm({
title: "Delete this comment?",
message: "It will be removed from the thread. You can undo this.",
message:
"It will be removed from the thread. You can undo this.",
confirmLabel: "Delete comment",
destructive: true,
})
@@ -99,7 +107,8 @@ export default function ReportsPage() {
if (
await confirm({
title: "Suspend this account?",
message: "They'll be signed out and unable to log in. You can undo this.",
message:
"They'll be signed out and unable to log in. You can undo this.",
confirmLabel: "Suspend account",
destructive: true,
})
@@ -121,7 +130,8 @@ export default function ReportsPage() {
size="sm"
variant="outline"
onClick={() => {
if (r.type === "post") run(api.restorePost(r.target_id), "Post restored.");
if (r.type === "post")
run(api.restorePost(r.target_id), "Post restored.");
else if (r.type === "comment")
run(api.restoreComment(r.target_id), "Comment restored.");
else run(api.unbanUser(r.target_id), "Account restored.");
@@ -133,7 +143,11 @@ export default function ReportsPage() {
}
const targetLabel =
type === "user" ? "Reported account" : type === "comment" ? "Comment" : "Post";
type === "user"
? "Reported account"
: type === "comment"
? "Comment"
: "Post";
return (
<ListShell
@@ -186,7 +200,9 @@ export default function ReportsPage() {
</span>
) : (
<>
<div className="line-clamp-2">{r.target_text || "—"}</div>
<div className="line-clamp-2">
{r.target_text || "—"}
</div>
{r.target_author_handle ? (
<div className="mt-0.5 text-xs text-muted-foreground">
by @{r.target_author_handle}
@@ -221,8 +237,8 @@ export default function ReportsPage() {
✓ Resolved
</span>
{r.resolution_note ? (
<p className="max-w-[220px] whitespace-pre-line text-right text-xs text-muted-foreground">
{r.resolution_note}
<p className="max-w-[220px] text-right text-xs text-muted-foreground">
<RichText text={r.resolution_note} />
</p>
) : null}
{r.target_removed ? undoButton(r) : null}
@@ -230,13 +246,15 @@ export default function ReportsPage() {
) : (
<div className="flex flex-wrap justify-end gap-2">
{r.target_removed ? undoButton(r) : modButton(r)}
<Button
size="sm"
variant="ghost"
onClick={() => setResolveTarget(r)}
>
Resolve
</Button>
<Can perm="reports:resolve">
<Button
size="sm"
variant="ghost"
onClick={() => setResolveTarget(r)}
>
Resolve
</Button>
</Can>
</div>
)}
</TD>
@@ -280,7 +298,9 @@ function ResolveModal({
setNote("");
onDone();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Could not resolve.");
toast.error(
err instanceof AdminError ? err.message : "Could not resolve.",
);
} finally {
setBusy(false);
}
@@ -291,9 +311,9 @@ function ResolveModal({
<div className="space-y-4">
<label className="block space-y-1.5">
<span className="text-sm font-medium">Resolution note</span>
<Textarea
<RichTextarea
value={note}
onChange={(e) => setNote(e.target.value)}
onValueChange={setNote}
rows={4}
maxLength={2000}
placeholder="What did you do / decide? (shown in the reports table)"
+309
View File
@@ -0,0 +1,309 @@
"use client";
/**
* /reward-gifts — hand a sticker to everyone, outside the streak.
*
* These arrive wrapped: each devotee sees a blurred tile in their rewards
* gallery, and opening it plays the same celebration a streak reward does.
* Nothing is required of them to earn it.
*
* Sending is idempotent — a repeat reaches only accounts that don't already
* hold the sticker — so a double click tops up rather than duplicating.
*/
import { useEffect, useState } from "react";
import { Can } from "@/components/staff-context";
import Image from "next/image";
import { toast } from "sonner";
import { rewardGifts, type GiftableReward } from "@/lib/api";
import { compressImage } from "@/lib/compress-image";
export default function RewardGiftsPage() {
const [catalog, setCatalog] = useState<GiftableReward[] | null>(null);
const [sending, setSending] = useState<string | null>(null);
useEffect(() => {
rewardGifts
.catalog()
.then(setCatalog)
.catch(() => {
toast.error("Could not load the reward catalog");
setCatalog([]);
});
}, []);
const send = async (reward: GiftableReward) => {
if (
!window.confirm(
`Send “${reward.name}” to every devotee? They'll see it wrapped in their gallery.`,
)
) {
return;
}
setSending(reward.key);
try {
const { granted } = await rewardGifts.send(reward.key);
toast.success(
granted === 0
? "Everyone already has this one"
: `Sent to ${granted} ${granted === 1 ? "devotee" : "devotees"}`,
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Could not send the gift",
);
} finally {
setSending(null);
}
};
const remove = async (reward: GiftableReward) => {
if (
!window.confirm(
`Delete “${reward.name}”? It disappears from the gallery of everyone who has it. Stickers already sent in chats stay put.`,
)
) {
return;
}
setSending(reward.key);
try {
await rewardGifts.remove(reward.key);
setCatalog((prev) => prev?.filter((c) => c.key !== reward.key) ?? null);
toast.success("Deleted");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not delete");
} finally {
setSending(null);
}
};
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold text-foreground">
Occasional gifts
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Send a sticker to everyone, no streak required. It arrives wrapped and
opens with a celebration.
</p>
</header>
<Can perm="rewards:write">
<CustomGift
onCreated={(reward) =>
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
}
/>
</Can>
{catalog === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : catalog.length === 0 ? (
<p className="text-sm text-muted-foreground">
The reward catalog is empty.
</p>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{catalog.map((r) => (
<li
key={r.key}
// h-full + mt-auto on the button: grid rows stretch to their
// tallest tile, but without this the contents stay top-packed
// and a two-line name pushes its button below the others.
className="flex h-full flex-col items-center gap-2 rounded-xl border border-border p-3 text-center"
>
<div className="relative aspect-square w-full">
<Image
src={r.image_url}
alt={r.name}
fill
sizes="160px"
className="object-contain p-1"
unoptimized
/>
</div>
<p className="line-clamp-2 text-sm font-medium text-foreground">
{r.name}
</p>
<div className="mt-auto w-full space-y-1.5">
<Can perm="rewards:send">
<button
type="button"
disabled={sending !== null}
onClick={() => send(r)}
className="w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{sending === r.key ? "Sending…" : "Send to everyone"}
</button>
</Can>
{/* Seeded rewards have no delete: they would return on the
next seed run, having been taken from the galleries of
everyone who earned them. */}
<Can perm="rewards:delete">
{r.key.startsWith("custom_") && (
<button
type="button"
disabled={sending !== null}
onClick={() => remove(r)}
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Delete
</button>
)}
</Can>
</div>
</li>
))}
</ul>
)}
</div>
);
}
/**
* Upload artwork and add it to the catalog.
*
* Creating and sending are deliberately separate steps: a new sticker lands
* in the grid below, where it is sent with the same confirmation as any
* other — so a mistyped name is caught before it reaches everyone.
*/
function CustomGift({
onCreated,
}: {
onCreated: (reward: GiftableReward) => void;
}) {
const [name, setName] = useState("");
const [meaning, setMeaning] = useState("");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const upload = async (original: File) => {
setUploading(true);
try {
// WebP, NOT the helper's default JPEG: JPEG has no alpha channel, and
// compress-image paints a white backdrop behind it so transparency
// doesn't turn black. A sticker has to keep its cut-out, so it encodes
// to a format that has alpha — the white fill is skipped for anything
// that isn't JPEG.
//
// 1024px, not the gallery tile's 120px: the reward detail renders the
// artwork at 224 CSS px (rewards-gallery.tsx:184), which is 672 real
// pixels on a 3× screen — so anything smaller is upscaled and visibly
// soft exactly where the artwork is largest. Quality is raised too:
// stickers are flat illustration with hard edges, where WebP's default
// ringing shows far more than it does on a photograph.
const file = await compressImage(original, {
maxDimension: 1024,
quality: 0.95,
mimeType: "image/webp",
}).catch(() => original);
const { upload_url, image_url } = await rewardGifts.presignImage(
file.type,
);
const res = await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) throw new Error(String(res.status));
setImageUrl(image_url);
toast.success("Artwork uploaded");
} catch {
toast.error("Could not upload that image");
} finally {
setUploading(false);
}
};
const save = async () => {
if (!name.trim() || !imageUrl) return;
setSaving(true);
try {
const reward = await rewardGifts.create({
name: name.trim(),
meaning: meaning.trim(),
image_url: imageUrl,
});
onCreated(reward);
toast.success("Added to the catalog — send it when you're ready");
setName("");
setMeaning("");
setImageUrl(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Could not save");
} finally {
setSaving(false);
}
};
const field =
"w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40";
return (
<section className="space-y-3 rounded-xl border border-border p-4">
<div>
<h2 className="text-sm font-medium text-foreground">
Add a custom gift
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
It joins the catalog below. Nobody receives it until you send it.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={80}
placeholder="Name — e.g. Tulasī Leaf"
className={field}
/>
<input
value={meaning}
onChange={(e) => setMeaning(e.target.value)}
maxLength={200}
placeholder="Meaning (optional)"
className={field}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
// No JPEG: it cannot carry transparency, so it can only produce a
// sticker with a hard rectangular edge.
accept="image/png,image/webp"
disabled={uploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f);
e.target.value = "";
}}
className={`${field} sm:w-auto`}
/>
{imageUrl && (
<span className="relative size-12 shrink-0">
<Image
src={imageUrl}
alt=""
fill
sizes="48px"
className="object-contain"
unoptimized
/>
</span>
)}
<button
type="button"
disabled={saving || uploading || !name.trim() || !imageUrl}
onClick={save}
className="ml-auto rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Adding…" : "Add to catalog"}
</button>
</div>
</section>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api, type AdminShortUrl, type ShortUrlStats } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { fmtDate } from "@/lib/utils";
export default function ShortUrlsPage() {
// ── Stats ────────────────────────────────────────────────────────────────
const [stats, setStats] = useState<ShortUrlStats | null>(null);
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
useEffect(() => {
// Fetch once both custom dates are set, or once both are cleared (fixed
// windows only). A half-filled range waits for the second date.
const ready = (!!from && !!to) || (!from && !to);
if (!ready) return;
let cancelled = false;
api
.shortUrlStats(from || undefined, to || undefined)
.then((s) => {
if (!cancelled) setStats(s);
})
.catch(() => {
/* leave the previous stats in place on error */
});
return () => {
cancelled = true;
};
}, [from, to]);
// ── Paginated list ─────────────────────────────────────────────────────────
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.shortUrls(q, limit, offset),
[q],
);
const list = usePagedList<AdminShortUrl>(fetcher, [q]);
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<h1 className="text-lg font-semibold text-foreground">
Short URL stats
</h1>
{/* Custom date range (inclusive). Clearing both returns to the fixed
windows below. */}
<div className="flex items-end gap-2">
<label className="flex flex-col text-xs text-muted-foreground">
From
<input
type="date"
value={from}
max={to || undefined}
onChange={(e) => setFrom(e.target.value)}
className="mt-1 rounded-md border border-border bg-card px-2.5 py-1.5 text-sm text-foreground"
/>
</label>
<label className="flex flex-col text-xs text-muted-foreground">
To
<input
type="date"
value={to}
min={from || undefined}
onChange={(e) => setTo(e.target.value)}
className="mt-1 rounded-md border border-border bg-card px-2.5 py-1.5 text-sm text-foreground"
/>
</label>
{(from || to) && (
<button
type="button"
onClick={() => {
setFrom("");
setTo("");
}}
className="rounded-md px-2.5 py-1.5 text-sm text-muted-foreground hover:text-foreground"
>
Clear
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Today" created={stats?.today.created} clicks={stats?.today.clicks} />
<StatCard label="This week" created={stats?.week.created} clicks={stats?.week.clicks} />
<StatCard label="This month" created={stats?.month.created} clicks={stats?.month.clicks} />
<StatCard
label="All time"
created={stats?.total_urls}
clicks={stats?.total_clicks}
/>
{stats?.range && (
<StatCard
label={`Custom · ${from} → ${to}`}
created={stats.range.created}
clicks={stats.range.clicks}
highlight
/>
)}
</div>
</section>
<ListShell
title="Shortened URLs"
total={list.total}
searchPlaceholder="Search by code or target URL…"
onQueryChange={setQ}
list={list}
>
<Table>
<THead>
<TR>
<TH>Code</TH>
<TH>Target</TH>
<TH>Type</TH>
<TH>Clicks</TH>
<TH>Status</TH>
<TH>Created</TH>
</TR>
</THead>
<tbody>
{list.items.length === 0 && !list.loading ? (
<EmptyRow cols={6} label="No shortened URLs found." />
) : (
list.items.map((s) => (
<TR key={s.code}>
<TD className="whitespace-nowrap font-mono text-xs">
{s.code}
</TD>
<TD className="max-w-md">
<a
href={s.long_url}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-1 break-all text-primary hover:underline"
title={s.long_url}
>
{s.long_url}
</a>
</TD>
<TD className="whitespace-nowrap text-muted-foreground">
{s.content_type || "—"}
</TD>
<TD className="whitespace-nowrap">
<span className="font-medium">
{s.click_count.toLocaleString()}
</span>
<span className="ml-1 text-xs text-muted-foreground">
({s.human_click_count.toLocaleString()} human)
</span>
</TD>
<TD className="whitespace-nowrap">
<span
className={
s.disabled ? "text-destructive" : "text-foreground"
}
>
{s.disabled ? "Disabled" : "Active"}
</span>
</TD>
<TD className="whitespace-nowrap">{fmtDate(s.created_at)}</TD>
</TR>
))
)}
</tbody>
</Table>
</ListShell>
</div>
);
}
function StatCard({
label,
created,
clicks,
highlight,
}: {
label: string;
created?: number;
clicks?: number;
highlight?: boolean;
}) {
return (
<div
className={
"rounded-xl border bg-card p-4 " +
(highlight ? "border-primary/40" : "border-border")
}
>
<div className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<div className="text-2xl font-semibold text-foreground">
{(created ?? 0).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">created</div>
</div>
<div>
<div className="text-2xl font-semibold text-foreground">
{(clicks ?? 0).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">clicks</div>
</div>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
/**
* /staff — who can use the admin panel, and with what powers. Superadmins only.
*
* Two tabs: the people (grant, change role, block, revoke) and the roles
* (the three system roles, plus custom ones built from the permission
* catalog). Every rule that matters — you can't act on yourself, the last
* active superadmin can't be removed, custom roles can't hold staff
* management — is enforced by the BFF; this page just explains the refusal.
*/
import { useState } from "react";
import { cn } from "@/lib/utils";
import { StaffTab } from "./staff-tab";
import { RolesTab } from "./roles-tab";
export default function StaffPage() {
const [tab, setTab] = useState<"staff" | "roles">("staff");
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold text-foreground">
Staff &amp; roles
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Decide who can use this panel and what each role may do.
</p>
</header>
<div className="flex gap-1 border-b border-border">
{(["staff", "roles"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-sm capitalize transition-colors",
tab === t
? "border-primary font-medium text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t}
</button>
))}
</div>
{tab === "staff" ? <StaffTab /> : <RolesTab />}
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
/**
* Roles: the three system roles (read-only) and custom ones.
*
* A custom role is any subset of the permission catalog. Staff management
* itself isn't in the catalog, so it can't be granted here — that stays with
* superadmins.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { staff, AdminError, type AdminRole, type Permission } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { useConfirm } from "@/components/confirm";
const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback;
type Draft = {
id?: string;
name: string;
description: string;
permissions: Set<string>;
};
export function RolesTab() {
const confirm = useConfirm();
const [roles, setRoles] = useState<AdminRole[] | null>(null);
const [catalog, setCatalog] = useState<Permission[]>([]);
const [draft, setDraft] = useState<Draft | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const [r, c] = await Promise.all([staff.roles(), staff.catalog()]);
setRoles(r);
setCatalog(c);
} catch (err) {
toast.error(errMsg(err, "Could not load roles"));
setRoles([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// Catalog grouped for the editor and for describing system roles.
const groups = useMemo(() => {
const m = new Map<string, Permission[]>();
for (const p of catalog) m.set(p.group, [...(m.get(p.group) ?? []), p]);
return [...m.entries()];
}, [catalog]);
const labelOf = useMemo(
() => new Map(catalog.map((p) => [p.key, p.label])),
[catalog],
);
const save = async () => {
if (!draft) return;
setSaving(true);
const input = {
name: draft.name.trim(),
description: draft.description.trim(),
permissions: [...draft.permissions],
};
try {
if (draft.id) await staff.updateRole(draft.id, input);
else await staff.createRole(input);
toast.success(draft.id ? "Role updated" : "Role created");
setDraft(null);
await load();
} catch (err) {
toast.error(errMsg(err, "Could not save the role"));
} finally {
setSaving(false);
}
};
const remove = async (r: AdminRole) => {
if (
!(await confirm({
title: `Delete the ${r.name} role?`,
message: "Only roles nobody holds can be deleted.",
confirmLabel: "Delete",
destructive: true,
}))
) {
return;
}
try {
await staff.deleteRole(r.id);
toast.success("Role deleted");
await load();
} catch (err) {
toast.error(errMsg(err, "Could not delete the role"));
}
};
const toggle = (key: string) =>
setDraft((d) => {
if (!d) return d;
const next = new Set(d.permissions);
if (next.has(key)) next.delete(key);
else next.add(key);
return { ...d, permissions: next };
});
if (roles === null)
return <p className="text-sm text-muted-foreground">Loading…</p>;
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
onClick={() =>
setDraft({ name: "", description: "", permissions: new Set() })
}
>
New role
</Button>
</div>
<ul className="space-y-3">
{roles.map((r) => (
<li key={r.id} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{r.name}{" "}
{r.is_system && (
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
System
</span>
)}
</p>
{r.description && (
<p className="mt-0.5 text-xs text-muted-foreground">
{r.description}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{r.member_count} {r.member_count === 1 ? "member" : "members"}
</p>
</div>
{!r.is_system && (
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
setDraft({
id: r.id,
name: r.name,
description: r.description,
permissions: new Set(r.permissions),
})
}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void remove(r)}
>
Delete
</Button>
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{r.permissions.includes("*") ? (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Everything, including managing staff
</span>
) : (
r.permissions.map((p) => (
<span
key={p}
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{labelOf.get(p) ?? p}
</span>
))
)}
</div>
</li>
))}
</ul>
<Modal
open={draft !== null}
onClose={() => setDraft(null)}
title={draft?.id ? "Edit role" : "New role"}
>
{draft && (
<div className="space-y-4">
<Input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Role name — e.g. Quiz editor"
maxLength={80}
/>
<Input
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.target.value })
}
placeholder="What is this role for? (optional)"
/>
<div className="max-h-[50vh] space-y-4 overflow-y-auto pr-1">
{groups.map(([group, perms]) => (
<fieldset key={group}>
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group}
</legend>
<div className="space-y-1">
{perms.map((p) => (
<label
key={p.key}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={draft.permissions.has(p.key)}
onChange={() => toggle(p.key)}
className="accent-primary"
/>
{p.label}
</label>
))}
</div>
</fieldset>
))}
</div>
<p className="text-xs text-muted-foreground">
Managing staff and roles is reserved for superadmins and
can&apos;t be added to a custom role.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDraft(null)}>
Cancel
</Button>
<Button
onClick={() => void save()}
disabled={
saving || !draft.name.trim() || draft.permissions.size === 0
}
>
{saving ? "Saving…" : "Save role"}
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
api,
staff,
AdminError,
type AdminRole,
type StaffMember,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
import { useConfirm } from "@/components/confirm";
const errMsg = (err: unknown, fallback: string) =>
err instanceof AdminError ? err.message : fallback;
export function StaffTab() {
const confirm = useConfirm();
const [members, setMembers] = useState<StaffMember[] | null>(null);
const [roles, setRoles] = useState<AdminRole[]>([]);
const [selfId, setSelfId] = useState<string | null>(null);
const [handle, setHandle] = useState("");
const [grantRole, setGrantRole] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [m, r, me] = await Promise.all([
staff.list(),
staff.roles(),
api.me(),
]);
setMembers(m);
setRoles(r);
setSelfId(me?.id ?? null);
setGrantRole(
(prev) =>
prev || r.find((x) => x.key === "moderator")?.id || r[0]?.id || "",
);
} catch (err) {
toast.error(errMsg(err, "Could not load staff"));
setMembers([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const run = async (key: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(key);
try {
await fn();
toast.success(ok);
await load();
} catch (err) {
toast.error(errMsg(err, "That didn't work"));
} finally {
setBusy(null);
}
};
const grant = () => {
const h = handle.trim().replace(/^@/, "");
if (!h || !grantRole) return;
// Only someone new to the panel gets the welcome email; granting to an
// existing staff member just changes their role.
const existing = members?.some((m) => m.handle === h);
void run(
"grant",
() => staff.grant(h, grantRole),
existing
? `@${h}'s role was changed`
: `@${h} is now staff — we've emailed them how to log in`,
).then(() => setHandle(""));
};
const block = async (m: StaffMember) => {
const blocking = !m.blocked_at;
if (
blocking &&
!(await confirm({
title: `Block ${m.name}?`,
message:
"They keep their role but can't use the admin panel until you unblock them.",
confirmLabel: "Block",
destructive: true,
}))
) {
return;
}
void run(
m.user_id,
() => staff.setBlocked(m.user_id, blocking),
blocking ? "Blocked" : "Unblocked",
);
};
const revoke = async (m: StaffMember) => {
if (
!(await confirm({
title: `Remove ${m.name} from staff?`,
message:
"They lose admin access entirely. Their user account is unaffected.",
confirmLabel: "Remove",
destructive: true,
}))
) {
return;
}
void run(m.user_id, () => staff.revoke(m.user_id), "Removed from staff");
};
return (
<div className="space-y-6">
<section className="space-y-3 rounded-xl border border-border p-4">
<h2 className="text-sm font-medium text-foreground">
Add a staff member
</h2>
<div className="flex flex-wrap items-center gap-2">
<Input
value={handle}
onChange={(e) => setHandle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && grant()}
placeholder="@handle"
className="max-w-xs"
/>
<select
value={grantRole}
onChange={(e) => setGrantRole(e.target.value)}
className="h-10 rounded-lg border border-input bg-card px-3 text-sm"
>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<Button onClick={grant} disabled={busy === "grant" || !handle.trim()}>
Add
</Button>
</div>
</section>
{members === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<Table>
<THead>
<TR>
<TH>Person</TH>
<TH>Role</TH>
<TH>Status</TH>
<TH className="text-right">Actions</TH>
</TR>
</THead>
<tbody>
{members.map((m) => {
const self = m.user_id === selfId;
return (
<TR key={m.user_id}>
<TD>
<div className="flex items-center gap-3">
<Avatar
src={m.avatar_url ?? null}
name={m.name}
className="size-8"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{m.name}{" "}
{self && (
<span className="text-muted-foreground">(you)</span>
)}
</p>
<p className="truncate text-xs text-muted-foreground">
@{m.handle}
</p>
</div>
</div>
</TD>
<TD>
{/* Your own row is read-only: nobody changes their own
access, so a superadmin can't demote themselves into
a locked-out panel by accident. */}
{self ? (
<span className="text-sm">{m.role_name}</span>
) : (
<select
value={m.role_id}
disabled={busy === m.user_id}
onChange={(e) =>
void run(
m.user_id,
() => staff.changeRole(m.user_id, e.target.value),
"Role changed",
)
}
className="h-8 rounded-md border border-input bg-card px-2 text-xs"
>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)}
</TD>
<TD>
{m.blocked_at ? (
<span className="rounded bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
Blocked
</span>
) : (
<span className="text-xs text-muted-foreground">
Active
</span>
)}
</TD>
<TD className="text-right">
{!self && (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={busy === m.user_id}
onClick={() => void block(m)}
>
{m.blocked_at ? "Unblock" : "Block"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy === m.user_id}
onClick={() => void revoke(m)}
>
Remove
</Button>
</div>
)}
</TD>
</TR>
);
})}
</tbody>
</Table>
)}
</div>
);
}
@@ -0,0 +1,246 @@
"use client";
/**
* DiscipleLinkModal — links a platform account to one of Śrīla Prabhupāda's
* initiated disciples (or clears an existing link).
*
* The disciple directory lives in Mongo and the link column on the Postgres
* users row, so the picker searches server-side via /admin/disciples. Each
* option reports whether another account already claims it, letting us disable
* those rows up front instead of surfacing a 409 after the click.
*
* Clicking a free row links immediately — the action is one field, instantly
* reversible from the same modal, so a confirm step would only add friction.
*/
import { useCallback, useEffect, useState } from "react";
import { Check, Link2Off, Search } from "lucide-react";
import { toast } from "sonner";
import { api, AdminError, type AdminDisciple, type AdminUser } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { cn } from "@/lib/utils";
/** "Vṛndāvana · 1972" — whichever halves we know. */
function placeYear(d: AdminDisciple): string | null {
const year = d.initiation_date
? new Date(d.initiation_date).getFullYear()
: null;
const parts = [d.initiation_place, Number.isFinite(year) ? year : null].filter(
Boolean,
);
return parts.length ? parts.join(" · ") : null;
}
export function DiscipleLinkModal({
user,
onClose,
onSaved,
}: {
user: AdminUser | null;
onClose: () => void;
onSaved: () => void;
}) {
const [q, setQ] = useState("");
const [options, setOptions] = useState<AdminDisciple[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
// Reset the query each time the modal opens for a different user, so a
// stale search doesn't carry over between rows.
useEffect(() => {
if (user) setQ("");
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search. Runs once on open (empty q → first page alphabetically)
// and then 250ms after the operator stops typing.
useEffect(() => {
if (!user) return;
let cancelled = false;
setLoading(true);
const t = setTimeout(async () => {
try {
const items = await api.disciples(q);
if (!cancelled) setOptions(items);
} catch (err) {
if (!cancelled) {
toast.error(
err instanceof AdminError ? err.message : "Could not load disciples.",
);
setOptions([]);
}
} finally {
if (!cancelled) setLoading(false);
}
}, 250);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [q, user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const link = useCallback(
async (d: AdminDisciple) => {
if (!user) return;
setBusyId(d.id);
try {
await api.linkDisciple(user.id, d.id);
toast.success(
`@${user.handle} linked to ${d.initiated_name} ${d.suffix}`.trim(),
);
onSaved();
onClose();
} catch (err) {
toast.error(
err instanceof AdminError ? err.message : "Could not link disciple.",
);
} finally {
setBusyId(null);
}
},
[user, onSaved, onClose],
);
async function unlink() {
if (!user) return;
setBusyId("__unlink__");
try {
await api.unlinkDisciple(user.id);
toast.success(`Removed the disciple link from @${user.handle}`);
onSaved();
onClose();
} catch (err) {
toast.error(
err instanceof AdminError ? err.message : "Could not remove the link.",
);
} finally {
setBusyId(null);
}
}
return (
<Modal
open={!!user}
onClose={onClose}
title={user ? `Direct disciple — @${user.handle}` : "Direct disciple"}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Link this account to one of Śrīla Prabhupāda&rsquo;s initiated
disciples. Linked accounts show a badge on their profile and are
highlighted in the disciples directory.
</p>
{user?.disciple_id ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2.5">
<div className="min-w-0">
<div className="text-xs text-muted-foreground">
Currently linked to
</div>
<div className="truncate text-sm font-medium">
{user.disciple_name ?? (
<span className="text-muted-foreground">
unknown disciple ({user.disciple_id.slice(0, 8)}…)
</span>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={unlink}
disabled={busyId !== null}
>
<Link2Off className="mr-1.5 size-3.5" />
{busyId === "__unlink__" ? "Removing…" : "Remove"}
</Button>
</div>
) : null}
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search initiated names…"
className="pl-9"
autoFocus
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
{loading && options.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Searching…
</p>
) : options.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No disciples match that search.
</p>
) : (
<ul className="divide-y divide-border">
{options.map((d) => {
const isCurrent = d.id === user?.disciple_id;
// Claimed by somebody else — not linkable until that link goes.
const takenByOther = !!d.linked_handle && !isCurrent;
const sub = placeYear(d);
return (
<li key={d.id}>
<button
type="button"
disabled={takenByOther || busyId !== null || isCurrent}
onClick={() => link(d)}
className={cn(
"flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors",
takenByOther || isCurrent
? "cursor-default"
: "hover:bg-muted",
takenByOther && "opacity-60",
)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{d.initiated_name}{" "}
<span className="font-normal text-muted-foreground">
{d.suffix}
</span>
</span>
{sub ? (
<span className="block truncate text-xs text-muted-foreground">
{sub}
</span>
) : null}
</span>
<span className="shrink-0 text-xs">
{isCurrent ? (
<span className="inline-flex items-center gap-1 font-medium text-primary">
<Check className="size-3.5" /> Linked
</span>
) : takenByOther ? (
<span className="text-muted-foreground">
@{d.linked_handle}
</span>
) : busyId === d.id ? (
<span className="text-muted-foreground">Linking…</span>
) : (
<span className="text-primary">Link</span>
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>
Done
</Button>
</div>
</div>
</Modal>
);
}
+71 -9
View File
@@ -18,10 +18,14 @@ import { Modal } from "@/components/ui/modal";
import { RowMenu, type MenuItem } from "@/components/ui/menu";
import { Avatar } from "@/components/ui/avatar";
import { useConfirm } from "@/components/confirm";
import { Input, Textarea } from "@/components/ui/input";
import { Input } from "@/components/ui/input";
import { RichTextarea } from "@/components/ui/rich-textarea";
import { fmtDate } from "@/lib/utils";
import { DiscipleLinkModal } from "./disciple-link-modal";
import { useStaff } from "@/components/staff-context";
export default function UsersPage() {
const { can } = useStaff();
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.users(q, limit, offset),
@@ -31,6 +35,7 @@ export default function UsersPage() {
const confirm = useConfirm();
const [notify, setNotify] = useState<AdminUser | null>(null);
const [limitsUser, setLimitsUser] = useState<AdminUser | null>(null);
const [discipleUser, setDiscipleUser] = useState<AdminUser | null>(null);
async function toggleBan(u: AdminUser) {
const banning = !u.banned_at;
@@ -54,13 +59,51 @@ export default function UsersPage() {
}
}
async function removeDiscipleLink(u: AdminUser) {
if (
!(await confirm({
title: `Remove disciple link from @${u.handle}?`,
message:
"Their profile badge and the highlight in the disciples directory will be removed. You can re-link them at any time.",
confirmLabel: "Remove link",
destructive: true,
}))
) {
return;
}
try {
await api.unlinkDisciple(u.id);
toast.success("Disciple link removed.");
list.refresh();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Action failed.");
}
}
function menuItems(u: AdminUser): MenuItem[] {
const items: MenuItem[] = [
{ label: "Send notification", onClick: () => setNotify(u) },
{ label: "Edit limits", onClick: () => setLimitsUser(u) },
];
// No ban option for fellow admins (avoid locking each other out).
if (u.role !== "admin") {
// Only what this role may do. The BFF refuses the rest regardless.
const items: MenuItem[] = [];
if (can("users:notify")) {
items.push({ label: "Send notification", onClick: () => setNotify(u) });
}
if (can("users:limits")) {
items.push({ label: "Edit limits", onClick: () => setLimitsUser(u) });
}
if (can("disciples:manage")) {
items.push({
label: u.disciple_id ? "Change disciple link" : "Link direct disciple",
onClick: () => setDiscipleUser(u),
});
if (u.disciple_id) {
items.push({
label: "Remove disciple link",
onClick: () => removeDiscipleLink(u),
});
}
}
// No ban option for staff: removing a staff member is a superadmin job,
// done from the Staff page — and the BFF refuses it from here anyway.
if (can("users:ban") && !u.admin_role) {
items.push({
label: u.banned_at ? "Unban account" : "Ban account",
onClick: () => toggleBan(u),
@@ -109,6 +152,20 @@ export default function UsersPage() {
<div className="text-xs text-muted-foreground">
@{u.handle}
</div>
{u.disciple_id ? (
<div
className="mt-1 inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
title={
u.disciple_name
? `Linked to ${u.disciple_name}`
: "Linked to an unknown disciple record"
}
>
<span className="truncate">
{u.disciple_name ?? "Direct disciple"}
</span>
</div>
) : null}
</div>
</div>
</TD>
@@ -139,6 +196,11 @@ export default function UsersPage() {
onClose={() => setLimitsUser(null)}
onSaved={list.refresh}
/>
<DiscipleLinkModal
user={discipleUser}
onClose={() => setDiscipleUser(null)}
onSaved={list.refresh}
/>
</ListShell>
);
}
@@ -352,9 +414,9 @@ function NotifyModal({
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Message</span>
<Textarea
<RichTextarea
value={body}
onChange={(e) => setBody(e.target.value)}
onValueChange={setBody}
rows={5}
maxLength={4000}
placeholder="Write your message…"
+268
View File
@@ -0,0 +1,268 @@
"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";
/** 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-wrap items-center gap-2">
<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} min-w-0 flex-1`}
/>
<button
type="button"
disabled={busy || !link.trim()}
onClick={pinByLink}
className="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-wrap items-center gap-3 border-t border-border pt-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>
</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">
<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>
<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>
);
}
+15 -9
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { api, AdminError, type Me } from "@/lib/api";
import { api, staff, AdminError, type Me } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,17 +17,23 @@ export default function LoginPage() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Once authenticated, only admins may proceed. Non-admins are signed back
// out so an ordinary account can't sit in an authenticated-but-forbidden
// state against the admin origin.
async function gateAdmin(user: Me) {
if (user.role === "admin") {
// Once authenticated, only staff may proceed. Non-staff — and staff a
// superadmin has blocked — are signed back out, so an ordinary account
// can't sit in an authenticated-but-forbidden state against this origin.
async function gateAdmin(_user: Me) {
try {
await staff.me();
router.replace("/");
return;
} catch (err) {
await api.logout().catch(() => {});
setError(
err instanceof AdminError && /suspended/i.test(err.message)
? "Your admin access has been suspended."
: "This account doesn't have admin access.",
);
setStage("credentials");
}
await api.logout().catch(() => {});
setError("This account doesn't have admin access.");
setStage("credentials");
}
async function onCredentials(e: React.FormEvent) {
+56
View File
@@ -0,0 +1,56 @@
"use client";
/**
* The signed-in staff member's role and permissions, for the whole panel.
*
* Used to HIDE what someone can't do — nav entries, buttons, whole pages.
* Hiding is a courtesy, not the boundary: every /admin/* route on the BFF
* checks its own permission, so a hidden button that somehow fired would
* still be refused.
*/
import { createContext, useContext } from "react";
import type { StaffMe } from "@/lib/api";
const StaffContext = createContext<StaffMe | null>(null);
export const StaffProvider = StaffContext.Provider;
export function useStaff() {
const me = useContext(StaffContext);
const perms = new Set(me?.permissions ?? []);
return {
me,
can: (perm: string) => perms.has(perm),
};
}
/**
* Renders children only for staff holding `perm`; otherwise a short notice.
* Wraps whole pages, so landing on one by URL explains itself rather than
* showing a screen of 403 errors.
*/
export function RequirePermission({
perm,
children,
}: {
perm: string;
children: React.ReactNode;
}) {
const { can } = useStaff();
if (!can(perm)) {
return (
<div className="rounded-xl border border-border p-6 text-sm text-muted-foreground">
Your role doesn&apos;t include access to this section. A superadmin
can change that from the Staff page.
</div>
);
}
return <>{children}</>;
}
/** Renders children only when the signed-in role holds `perm`. */
export function Can({ perm, children }: { perm: string; children: React.ReactNode }) {
const { can } = useStaff();
return can(perm) ? <>{children}</> : null;
}
+216
View File
@@ -0,0 +1,216 @@
"use client";
/**
* A textarea with a formatting toolbar: bold, italic, underline, strike, link.
*
* The buttons don't make the field "rich" — they wrap the selection in the
* same plain-text markers an admin could type by hand (*bold*, _italic_,
* __underline__, ~strike~, [label](url)). The stored text stays plain, so it
* travels unchanged to every place it ends up: rendered in email by the BFF,
* stripped for push, rendered again in the app.
*
* Preview shows exactly what those markers become, because a reply is sent
* the moment it's submitted and the recipient's inbox is not the place to
* discover a stray asterisk.
*/
import { useRef, useState } from "react";
import {
Bold,
Eye,
Italic,
Link2,
PencilLine,
Strikethrough,
Underline,
} from "lucide-react";
import { Textarea } from "@/components/ui/input";
import { RichText } from "@/lib/rich-text";
import { cn } from "@/lib/utils";
type Wrap = { open: string; close: string; label: string };
const WRAPS = {
bold: { open: "*", close: "*", label: "Bold" },
italic: { open: "_", close: "_", label: "Italic" },
underline: { open: "__", close: "__", label: "Underline" },
strike: { open: "~", close: "~", label: "Strikethrough" },
} satisfies Record<string, Wrap>;
/** Placeholder put in a new link, selected so it can be typed over. */
const URL_PLACEHOLDER = "https://";
export function RichTextarea({
value,
onValueChange,
className,
...props
}: {
value: string;
onValueChange: (next: string) => void;
} & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "onChange"
>) {
const ref = useRef<HTMLTextAreaElement>(null);
const [previewing, setPreviewing] = useState(false);
/** Replace the selection, then put the caret/selection where it's useful. */
const replaceSelection = (
build: (selected: string) => string,
select: (start: number, built: string) => [number, number],
) => {
const el = ref.current;
if (!el) return;
const { selectionStart: start, selectionEnd: end } = el;
const built = build(value.slice(start, end));
const next = value.slice(0, start) + built + value.slice(end);
// Respect maxLength: a wrap that would overflow it is refused rather than
// silently truncating the end of the message.
if (props.maxLength && next.length > props.maxLength) return;
onValueChange(next);
const [s, e] = select(start, built);
// After React commits the new value; setting it now would be overwritten.
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(s, e);
});
};
const wrap = (w: Wrap) =>
replaceSelection(
(sel) => `${w.open}${sel}${w.close}`,
// Keep the wrapped text selected, so a second click (say, bold then
// italic) applies to the same words.
(start, built) => [
start + w.open.length,
start + built.length - w.close.length,
],
);
const link = () =>
replaceSelection(
(sel) => `[${sel || "link text"}](${URL_PLACEHOLDER})`,
// Select the URL placeholder: the label is usually what was selected,
// and the address is the part still to be filled in.
(start, built) => {
const urlStart = start + built.lastIndexOf(URL_PLACEHOLDER);
return [urlStart, urlStart + URL_PLACEHOLDER.length];
},
);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!(e.metaKey || e.ctrlKey)) return;
const key = e.key.toLowerCase();
const action =
key === "b"
? () => wrap(WRAPS.bold)
: key === "i"
? () => wrap(WRAPS.italic)
: key === "u"
? () => wrap(WRAPS.underline)
: key === "k"
? link
: null;
if (!action) return;
e.preventDefault();
action();
};
const tool = (
label: string,
icon: React.ReactNode,
onClick: () => void,
shortcut?: string,
) => (
<button
type="button"
// mousedown, not click: a click would move focus to the button first
// and the textarea would lose the selection being formatted.
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
disabled={previewing || props.disabled}
aria-label={label}
title={shortcut ? `${label} (${shortcut})` : label}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{icon}
</button>
);
return (
<div
className={cn(
"overflow-hidden rounded-lg border border-input bg-card focus-within:ring-2 focus-within:ring-ring",
className,
)}
>
<div className="flex items-center gap-0.5 border-b border-border px-1.5 py-1">
{tool(
"Bold",
<Bold className="size-4" />,
() => wrap(WRAPS.bold),
"⌘B",
)}
{tool(
"Italic",
<Italic className="size-4" />,
() => wrap(WRAPS.italic),
"⌘I",
)}
{tool(
"Underline",
<Underline className="size-4" />,
() => wrap(WRAPS.underline),
"⌘U",
)}
{tool("Strikethrough", <Strikethrough className="size-4" />, () =>
wrap(WRAPS.strike),
)}
{tool("Link", <Link2 className="size-4" />, link, "⌘K")}
<button
type="button"
onClick={() => setPreviewing((p) => !p)}
aria-pressed={previewing}
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
{previewing ? (
<>
<PencilLine className="size-3.5" /> Edit
</>
) : (
<>
<Eye className="size-3.5" /> Preview
</>
)}
</button>
</div>
{previewing ? (
<div
className="min-h-[7.5rem] whitespace-normal break-words px-3 py-2 text-sm text-foreground"
aria-live="polite"
>
{value.trim() ? (
<RichText text={value} />
) : (
<span className="text-muted-foreground">Nothing to preview.</span>
)}
</div>
) : (
<Textarea
ref={ref}
value={value}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={onKeyDown}
// The frame lives on the wrapper so the toolbar and field read as
// one control; the field itself is bare.
className="rounded-none border-0 bg-transparent focus-visible:ring-0"
{...props}
/>
)}
</div>
);
}
+431
View File
@@ -92,6 +92,28 @@ export type Stats = {
feedback_by_category: { category: string; count: number }[];
};
export type AdminShortUrl = {
id: number;
code: string;
long_url: string;
content_type: string;
click_count: number;
human_click_count: number;
disabled: boolean;
created_at: string;
};
export type ShortUrlRangeStat = { created: number; clicks: number };
export type ShortUrlStats = {
today: ShortUrlRangeStat;
week: ShortUrlRangeStat;
month: ShortUrlRangeStat;
total_urls: number;
total_clicks: number;
range?: ShortUrlRangeStat; // present only when a custom from/to is requested
};
export type AdminUser = {
id: string;
email: string | null;
@@ -99,6 +121,8 @@ export type AdminUser = {
name: string;
avatar_url: string | null;
role: string;
/** Staff role key (superadmin/admin/moderator/custom), absent for users. */
admin_role?: string | null;
plan: string;
email_verified_at: string | null;
banned_at: string | null;
@@ -111,6 +135,22 @@ export type AdminUser = {
bookmark_limit: number;
public_collection_limit: number;
private_collection_limit: number;
/** Direct-disciple link: the Mongo disciple id this account is linked to,
* and that disciple's name (hydrated server-side). Both null when the
* account isn't one of Śrīla Prabhupāda's direct disciples. */
disciple_id: string | null;
disciple_name: string | null;
};
/** One option in the direct-disciple picker. `linked_handle` is the @handle of
* the account already linked to this disciple — non-null means "taken". */
export type AdminDisciple = {
id: string;
initiated_name: string;
suffix: string;
initiation_date: string | null;
initiation_place: string | null;
linked_handle: string | null;
};
/** Per-user save-limit override payload. null = reset that limit to the plan
@@ -265,6 +305,24 @@ export const api = {
body: JSON.stringify(limits),
});
},
/** Search Śrīla Prabhupāda's disciples for the link picker. An empty query
* returns the first page alphabetically. */
async disciples(q: string, limit = 25): Promise<AdminDisciple[]> {
const { data } = await call<AdminDisciple[]>(
`/admin/disciples${qs({ q, limit })}`,
);
return data ?? [];
},
/** Mark a user as a direct disciple. Re-linking moves an existing link. */
linkDisciple(userId: string, discipleId: string) {
return call(`/admin/users/${userId}/disciple`, {
method: "POST",
body: JSON.stringify({ disciple_id: discipleId }),
});
},
unlinkDisciple(userId: string) {
return call(`/admin/users/${userId}/disciple`, { method: "DELETE" });
},
posts(q: string, limit: number, offset: number) {
return listed<AdminPost>(`/admin/posts${qs({ q, limit, offset })}`);
},
@@ -289,6 +347,15 @@ export const api = {
collections(q: string, limit: number, offset: number) {
return listed<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
},
shortUrls(q: string, limit: number, offset: number) {
return listed<AdminShortUrl>(`/admin/short-urls${qs({ q, limit, offset })}`);
},
async shortUrlStats(from?: string, to?: string): Promise<ShortUrlStats> {
const { data } = await call<ShortUrlStats>(
`/admin/short-urls/stats${qs({ from, to })}`,
);
return data;
},
feedback(status: string, limit: number, offset: number) {
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
},
@@ -314,3 +381,367 @@ export const api = {
});
},
};
// ─── Broadcasts ─────────────────────────────────────────────────────────────
/**
* Announcements pushed to every user and shown once, in a modal.
*
* No history is kept on the user's side: there is no notification row and no
* tray entry, so a broadcast exists only until each person dismisses it.
*/
export type Broadcast = {
id: string;
title: string;
body: string;
banner_url?: string | null;
cta_label?: string | null;
cta_url?: string | null;
published_at?: string | null;
expires_at?: string | null;
created_at: string;
/** How many people have dismissed it — the only delivery signal there is. */
seen_count: number;
};
export type NewBroadcast = {
title: string;
body: string;
banner_key?: string | null;
cta_label?: string | null;
cta_url?: string | null;
expires_at?: string | null;
publish: boolean;
};
export const broadcasts = {
list: () => listed<Broadcast>("/admin/broadcasts?limit=50"),
create: (input: NewBroadcast) =>
call<Broadcast>("/admin/broadcasts", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.data),
publish: (id: string) =>
call<Broadcast>(`/admin/broadcasts/${id}/publish`, { method: "POST" }).then(
(r) => r.data,
),
remove: (id: string) =>
call<void>(`/admin/broadcasts/${id}`, { method: "DELETE" }),
/** Presign a banner upload; returns the PUT URL and the key to submit. */
presignBanner: (contentType: string) =>
call<{ upload_url: string; key: string }>(
"/admin/broadcasts/banner-presign",
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
).then((r) => r.data),
};
// ─── Reward gifts ───────────────────────────────────────────────────────────
/** A sticker from the sādhana catalog, sendable as an occasional gift. */
export type GiftableReward = {
id: string;
key: string;
name: string;
meaning: string;
image_url: string;
};
export const rewardGifts = {
catalog: () => call<GiftableReward[]>("/admin/reward-gifts/catalog").then((r) => r.data),
/** Presign artwork for a custom gift. Returns the final public URL too,
* since sadhna_rewards.image_url stores an absolute address. */
presignImage: (contentType: string) =>
call<{ upload_url: string; image_url: string }>(
"/admin/reward-gifts/image-presign",
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
).then((r) => r.data),
/** Delete a custom sticker. Cascades out of every gallery and vault. */
remove: (key: string) =>
call<void>(`/admin/reward-gifts/${encodeURIComponent(key)}`, {
method: "DELETE",
}),
/** Add a sticker to the catalog. It reaches nobody until it is sent. */
create: (input: { name: string; meaning: string; image_url: string }) =>
call<GiftableReward>("/admin/reward-gifts/catalog", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.data),
/** Hand a reward to every account, unopened. Idempotent — a repeat only
* reaches people who don't already have it. */
send: (rewardKey: string) =>
call<{ granted: number }>("/admin/reward-gifts", {
method: "POST",
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" }),
/** Pin whatever an app link points at. The only reliable way to choose a
* specific item: slugs are not indexed, so searching one matches on its
* words instead ("720720sb-london" → anything mentioning London). */
setByURL: (input: { day: string; url: string }) =>
call<VaniPick>("/admin/vani/by-url", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.data),
/**
* Search the vāṇī-eligible collections.
*
* SCOPED server-side via `type`, not filtered here: `type=all` blends every
* collection and ranks by relevance, so the top hits are dominated by
* letters and glossary entries and a client-side filter throws the whole
* page away — which is exactly what made this look broken.
*/
search: async (q: string): Promise<VaniCandidate[]> => {
const { data } = await call<{ results: VaniCandidate[] }>(
`/search?type=verses,prose,transcriptions&limit=20&q=${encodeURIComponent(q)}`,
);
return (data?.results ?? []).filter(
(r) => r.doc_id && vaniKindFor(r.collection),
);
},
};
// ─── Staff, roles and permissions ───────────────────────────────────────────
/** The signed-in staff member's role and effective permissions. */
export type StaffMe = {
role_key: string;
role_name: string;
permissions: string[];
};
/** Held only by superadmins: managing other staff and roles. */
export const MANAGE_STAFF = "admins:manage";
export type Permission = { key: string; group: string; label: string };
export type AdminRole = {
id: string;
key: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
member_count: number;
};
export type StaffMember = {
user_id: string;
handle: string;
name: string;
email?: string | null;
avatar_url?: string | null;
role_id: string;
role_key: string;
role_name: string;
blocked_at?: string | null;
};
export const staff = {
/** Resolves only for staff; a non-staff or blocked account gets a 403. */
me: () => call<StaffMe>("/admin/me").then((r) => r.data),
catalog: () => call<Permission[]>("/admin/permissions").then((r) => r.data),
list: () => call<StaffMember[]>("/admin/staff").then((r) => r.data),
grant: (handle: string, roleId: string) =>
call<StaffMember>("/admin/staff", {
method: "POST",
body: JSON.stringify({ handle, role_id: roleId }),
}).then((r) => r.data),
changeRole: (userId: string, roleId: string) =>
call<StaffMember>(`/admin/staff/${userId}`, {
method: "PATCH",
body: JSON.stringify({ role_id: roleId }),
}).then((r) => r.data),
revoke: (userId: string) =>
call<void>(`/admin/staff/${userId}`, { method: "DELETE" }),
setBlocked: (userId: string, blocked: boolean) =>
call<StaffMember>(`/admin/staff/${userId}/block`, {
method: blocked ? "POST" : "DELETE",
}).then((r) => r.data),
roles: () => call<AdminRole[]>("/admin/roles").then((r) => r.data),
createRole: (input: { name: string; description: string; permissions: string[] }) =>
call<AdminRole>("/admin/roles", { method: "POST", body: JSON.stringify(input) }).then(
(r) => r.data,
),
updateRole: (
id: string,
input: { name: string; description: string; permissions: string[] },
) =>
call<AdminRole>(`/admin/roles/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
}).then((r) => r.data),
deleteRole: (id: string) => call<void>(`/admin/roles/${id}`, { method: "DELETE" }),
};
// ─── Quizzes ────────────────────────────────────────────────────────────────
export type QuizTargetKind = "chapter" | "lecture" | "conversation" | "standalone";
export type QuizOption = { id?: string; ord?: number; body: string; is_correct: boolean };
export type QuizQuestion = {
id?: string;
ord?: number;
prompt: string;
explanation: string;
/** "Select all that apply": one or more options correct. */
multi: boolean;
options: QuizOption[];
};
export type Quiz = {
id: string;
title: string;
description: string;
target_kind: QuizTargetKind;
target_path?: string | null;
target_title: string;
published_at?: string | null;
created_at: string;
updated_at: string;
question_count: number;
attempt_count?: number;
completed_count?: number;
questions?: QuizQuestion[];
};
export type QuizDraft = {
title: string;
description: string;
/** Pasted app link; empty for a stand-alone quiz. */
target_url: string;
/** Optional override for the resolved content title. */
target_title: string;
/** Omitted once a quiz has attempts — its questions are frozen. */
questions?: QuizQuestion[];
};
export type QuizParticipant = {
user_id: string;
handle: string;
name: string;
avatar_url?: string | null;
started_at: string;
completed_at?: string | null;
answered: number;
score: number;
total: number;
total_ms: number;
};
export type QuizQuestionStat = {
id: string;
ord: number;
prompt: string;
multi: boolean;
answered: number;
correct: number;
avg_ms: number;
median_ms: number;
options: (QuizOption & { id: string; picks: number })[];
};
export type QuizResultItem = QuizQuestion & {
chosen_option_ids: string[];
is_correct: boolean;
response_ms?: number | null;
};
export type QuizResult = {
quiz: Quiz;
score: number;
total: number;
started_at: string;
completed_at?: string | null;
total_ms: number;
items: QuizResultItem[];
};
export const quizzes = {
list: () => call<Quiz[]>("/admin/quizzes").then((r) => r.data),
get: (id: string) => call<Quiz>(`/admin/quizzes/${id}`).then((r) => r.data),
create: (draft: QuizDraft) =>
call<Quiz>("/admin/quizzes", { method: "POST", body: JSON.stringify(draft) }).then(
(r) => r.data,
),
update: (id: string, draft: QuizDraft) =>
call<Quiz>(`/admin/quizzes/${id}`, { method: "PUT", body: JSON.stringify(draft) }).then(
(r) => r.data,
),
setPublished: (id: string, on: boolean) =>
call<Quiz>(`/admin/quizzes/${id}/publish`, { method: on ? "POST" : "DELETE" }).then(
(r) => r.data,
),
remove: (id: string) => call<void>(`/admin/quizzes/${id}`, { method: "DELETE" }),
participants: (id: string) =>
call<QuizParticipant[]>(`/admin/quizzes/${id}/participants`).then((r) => r.data),
participant: (id: string, userId: string) =>
call<QuizResult>(`/admin/quizzes/${id}/participants/${userId}`).then((r) => r.data),
responses: (id: string) =>
call<QuizQuestionStat[]>(`/admin/quizzes/${id}/responses`).then((r) => r.data),
};
+178
View File
@@ -0,0 +1,178 @@
/**
* Browser-side image compression.
*
* NOTE: a verbatim copy of knowledgebase/lib/compress-image.ts. The two apps
* are separate Next projects with no shared package, and this is dependency-
* free browser code — so it is duplicated rather than reached across for. If a
* third copy appears, that is the point to extract a shared package.
*
* Resizes the longest dimension to `maxDimension` (preserving aspect
* ratio) and re-encodes as JPEG at `quality`. EXIF is stripped as a
* side effect of the canvas round-trip — smaller files plus a tiny
* privacy win since location data doesn't ride along to R2.
*
* Why client-side: every upload path (post images, collection covers,
* avatars, post background swaps) presigns directly to R2. Putting
* the size cut in the browser keeps that flow intact — no proxy hop,
* no queue, no temp storage. The trade-off is that the source bytes
* are lossy from the moment they leave the device; for a photo-share
* product this is the right call.
*
* Safe-by-default failure modes — any of these return the ORIGINAL
* file unchanged rather than blocking the upload:
*
* - The file is not an image (defensive — input is gated by accept= too).
* - The file is a GIF (canvas re-encode would flatten the animation).
* - The decoder fails (e.g. an old browser meeting a HEIC).
* - Compression actually grew the file (rare; tiny already-compressed inputs).
*
* Callers can therefore wrap their existing upload calls without
* adding new error paths.
*/
export type CompressOptions = {
/** Longest edge in px after resize. Default 1920. */
maxDimension?: number;
/** JPEG quality 0–1. Default 0.85 (visually lossless for photos). */
quality?: number;
/** Output MIME type. Default "image/jpeg". */
mimeType?: string;
};
const DEFAULT_MAX = 1920;
const DEFAULT_QUALITY = 0.85;
const DEFAULT_MIME = "image/jpeg";
export async function compressImage(
file: File,
opts: CompressOptions = {},
): Promise<File> {
const maxDimension = opts.maxDimension ?? DEFAULT_MAX;
const quality = opts.quality ?? DEFAULT_QUALITY;
const mimeType = opts.mimeType ?? DEFAULT_MIME;
// Bail-outs that should return the original untouched.
if (typeof window === "undefined") return file;
if (!file.type.startsWith("image/")) return file;
if (file.type === "image/gif") return file;
let drawable: ImageBitmap | HTMLImageElement;
try {
drawable = await loadDrawable(file);
} catch {
// Decoder couldn't read this image (e.g. corrupt, or HEIC on a
// browser that doesn't decode HEIC). Send the original up — R2
// will accept it; the share-image proxy + Cloudflare will sort
// out display sizing downstream.
return file;
}
const { width: srcW, height: srcH } = drawableDimensions(drawable);
const { width, height } = scaleToFit(srcW, srcH, maxDimension);
// No-op fast path: already within the cap AND a reasonable size.
// Skip the canvas dance because re-encoding a 200 KB image as JPEG
// q=85 might actually grow it.
if (width === srcW && height === srcH && file.size < 500_000) {
closeBitmap(drawable);
return file;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
closeBitmap(drawable);
return file;
}
// White backdrop so PNG/HEIC transparency doesn't become black
// pixels in the JPEG output.
if (mimeType === "image/jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(drawable, 0, 0, width, height);
closeBitmap(drawable);
const blob = await canvasToBlob(canvas, mimeType, quality);
if (!blob) return file;
// If the "compressed" version ended up larger (small already, or a
// weird input), keep the original.
if (blob.size >= file.size) return file;
const ext =
mimeType === "image/jpeg"
? "jpg"
: mimeType.split("/")[1]?.toLowerCase() || "bin";
const newName = file.name.replace(/\.[^.]+$/, "") + `.${ext}`;
return new File([blob], newName, {
type: mimeType,
lastModified: Date.now(),
});
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
async function loadDrawable(
file: File,
): Promise<ImageBitmap | HTMLImageElement> {
// createImageBitmap is the fast path on modern browsers — it decodes
// off the main thread when supported.
if (typeof createImageBitmap === "function") {
try {
return await createImageBitmap(file);
} catch {
// Fall through to the HTMLImageElement path.
}
}
const url = URL.createObjectURL(file);
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("image decode failed"));
img.src = url;
});
// Defer revoke so the image stays valid through the next canvas
// draw — we revoke a tick later when it's safe.
setTimeout(() => URL.revokeObjectURL(url), 0);
return img;
}
function drawableDimensions(
d: ImageBitmap | HTMLImageElement,
): { width: number; height: number } {
if ("naturalWidth" in d) {
return { width: d.naturalWidth, height: d.naturalHeight };
}
return { width: d.width, height: d.height };
}
function scaleToFit(
width: number,
height: number,
max: number,
): { width: number; height: number } {
if (width <= max && height <= max) return { width, height };
const ratio = width > height ? max / width : max / height;
return {
width: Math.round(width * ratio),
height: Math.round(height * ratio),
};
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), type, quality);
});
}
function closeBitmap(d: ImageBitmap | HTMLImageElement) {
if ("close" in d && typeof d.close === "function") d.close();
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Renders the light formatting the admin message fields allow:
*
* *bold* _italic_ __underline__ ~strike~ [label](https://…)
*
* plus bare http(s) URLs. The rules mirror pkg/richtext in the BFF, which
* renders the same markers into email — so the preview shown here is what
* the recipient's inbox gets.
*
* Output is React nodes, never an HTML string: React escapes all text, so
* author input cannot become markup, and links are limited to http(s).
*/
import type { ReactNode } from "react";
const LINK_RE =
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+)/g;
const TRAILING_PUNCT = /[.,;:!?)]+$/;
type Marker = { re: RegExp; wrap: (n: ReactNode, k: string) => ReactNode };
// Order matters: __underline__ before _italic_, so the double underscore
// isn't read as two italic markers.
const MARKERS: Marker[] = [
{ re: /__([^_\n]+?)__/, wrap: (n, k) => <u key={k}>{n}</u> },
{ re: /\*([^*\n]+?)\*/, wrap: (n, k) => <strong key={k}>{n}</strong> },
{ re: /~([^~\n]+?)~/, wrap: (n, k) => <s key={k}>{n}</s> },
{ re: /_([^_\n]+?)_/, wrap: (n, k) => <em key={k}>{n}</em> },
];
const isWord = (c: string | undefined) => !!c && /\w/.test(c);
/** Markers within a run of text that contains no links. */
function renderMarkers(text: string, keyBase: string): ReactNode[] {
// Earliest match across all markers wins, so nesting resolves outside-in.
let best: { idx: number; len: number; inner: string; m: Marker } | null =
null;
for (const m of MARKERS) {
const re = new RegExp(m.re.source, "g");
let hit: RegExpExecArray | null;
while ((hit = re.exec(text))) {
// Italic needs a non-word character either side, so snake_case and
// handles keep their underscores.
if (m.re.source.startsWith("_(")) {
const before = text[hit.index - 1];
const after = text[hit.index + hit[0].length];
if (isWord(before) || isWord(after)) continue;
}
if (!best || hit.index < best.idx) {
best = { idx: hit.index, len: hit[0].length, inner: hit[1], m };
}
break;
}
}
if (!best) return text ? [text] : [];
const before = text.slice(0, best.idx);
const after = text.slice(best.idx + best.len);
const k = `${keyBase}-${best.idx}`;
return [
...(before ? [before] : []),
best.m.wrap(renderMarkers(best.inner, `${k}i`), k),
...renderMarkers(after, `${k}a`),
];
}
/** Renders formatted text, newlines included. */
export function RichText({ text }: { text: string }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
return (
<>
{lines.map((line, li) => {
const out: ReactNode[] = [];
let last = 0;
for (const m of line.matchAll(LINK_RE)) {
const idx = m.index ?? 0;
out.push(...renderMarkers(line.slice(last, idx), `${li}-${last}`));
let href = m[2] ?? m[3];
let label = m[1] ?? href;
let tail = "";
if (!m[1]) {
// Sentence punctuation after a bare URL isn't part of it.
tail = href.match(TRAILING_PUNCT)?.[0] ?? "";
href = href.slice(0, href.length - tail.length);
label = href;
}
out.push(
<a
key={`${li}-l${idx}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline-offset-2 hover:underline"
>
{label}
</a>,
);
if (tail) out.push(tail);
last = idx + m[0].length;
}
out.push(...renderMarkers(line.slice(last), `${li}-${last}`));
return (
<span key={li}>
{li > 0 && <br />}
{out}
</span>
);
})}
</>
);
}