feat: quiz
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -43,7 +44,9 @@ export default function BroadcastsPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Composer onDone={refresh} />
|
||||
<Can perm="broadcasts:write">
|
||||
<Composer onDone={refresh} />
|
||||
</Can>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-foreground">Recent</h2>
|
||||
@@ -212,14 +215,16 @@ function Composer({ onDone }: { onDone: () => void }) {
|
||||
>
|
||||
Save draft
|
||||
</button>
|
||||
<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 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>
|
||||
);
|
||||
@@ -268,33 +273,37 @@ function BroadcastRow({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{!live && (
|
||||
<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("Publish now? This cannot be recalled.") &&
|
||||
act(() => broadcasts.publish(item.id), "Published")
|
||||
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 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Publish
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -40,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",
|
||||
})
|
||||
) {
|
||||
@@ -52,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",
|
||||
})
|
||||
) {
|
||||
@@ -132,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>
|
||||
@@ -206,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(
|
||||
|
||||
+117
-38
@@ -4,7 +4,9 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Flag,
|
||||
ShieldCheck,
|
||||
Gift,
|
||||
Megaphone,
|
||||
Quote,
|
||||
@@ -16,22 +18,57 @@ import {
|
||||
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: "/short-urls", label: "Short URLs", icon: Link2 },
|
||||
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
|
||||
{ href: "/reports", label: "Reports", icon: Flag },
|
||||
{ href: "/broadcasts", label: "Announcements", icon: Megaphone },
|
||||
{ href: "/reward-gifts", label: "Gifts", icon: Gift },
|
||||
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote },
|
||||
{ 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({
|
||||
@@ -42,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(() => {
|
||||
@@ -68,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…
|
||||
@@ -91,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"
|
||||
@@ -129,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -46,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
|
||||
@@ -56,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,
|
||||
})
|
||||
@@ -79,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,
|
||||
})
|
||||
@@ -100,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,
|
||||
})
|
||||
@@ -122,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.");
|
||||
@@ -134,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
|
||||
@@ -187,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}
|
||||
@@ -231,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>
|
||||
@@ -281,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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -48,7 +49,9 @@ export default function RewardGiftsPage() {
|
||||
: `Sent to ${granted} ${granted === 1 ? "devotee" : "devotees"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Could not send the gift");
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Could not send the gift",
|
||||
);
|
||||
} finally {
|
||||
setSending(null);
|
||||
}
|
||||
@@ -81,16 +84,18 @@ export default function RewardGiftsPage() {
|
||||
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.
|
||||
Send a sticker to everyone, no streak required. It arrives wrapped and
|
||||
opens with a celebration.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CustomGift
|
||||
onCreated={(reward) =>
|
||||
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
@@ -122,27 +127,31 @@ export default function RewardGiftsPage() {
|
||||
{r.name}
|
||||
</p>
|
||||
<div className="mt-auto w-full space-y-1.5">
|
||||
<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>
|
||||
{/* Seeded rewards have no delete: they would return on the
|
||||
next seed run, having been taken from the galleries of
|
||||
everyone who earned them. */}
|
||||
{r.key.startsWith("custom_") && (
|
||||
<Can perm="rewards:send">
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,10 @@ 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),
|
||||
@@ -79,22 +81,29 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
function menuItems(u: AdminUser): MenuItem[] {
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Send notification", onClick: () => setNotify(u) },
|
||||
{ label: "Edit limits", onClick: () => setLimitsUser(u) },
|
||||
{
|
||||
// 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),
|
||||
});
|
||||
if (u.disciple_id) {
|
||||
items.push({
|
||||
label: "Remove disciple link",
|
||||
onClick: () => removeDiscipleLink(u),
|
||||
});
|
||||
}
|
||||
}
|
||||
// No ban option for fellow admins (avoid locking each other out).
|
||||
if (u.role !== "admin") {
|
||||
// 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),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Can } from "@/components/staff-context";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
vani,
|
||||
@@ -142,80 +143,84 @@ export default function VaniPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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
|
||||
<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
|
||||
type="date"
|
||||
value={day}
|
||||
min={todayUTC()}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
className={`${field} ml-2`}
|
||||
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`}
|
||||
/>
|
||||
</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 ? (
|
||||
<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">
|
||||
Type at least two characters to search.
|
||||
Or search by words. A slug only matches its own words, so paste the
|
||||
link when you know exactly which item you want.
|
||||
</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"
|
||||
|
||||
<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"
|
||||
>
|
||||
Set for {day}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<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">
|
||||
@@ -243,14 +248,16 @@ export default function VaniPage() {
|
||||
{p.payload.book_title ? ` · ${p.payload.book_title}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => clear(p)}
|
||||
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<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>
|
||||
|
||||
+15
-9
@@ -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) {
|
||||
|
||||
@@ -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'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;
|
||||
}
|
||||
+184
@@ -121,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;
|
||||
@@ -561,3 +563,185 @@ export const vani = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user