feat: quiz
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Can } from "@/components/staff-context";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { broadcasts, type Broadcast } from "@/lib/api";
|
import { broadcasts, type Broadcast } from "@/lib/api";
|
||||||
import { compressImage } from "@/lib/compress-image";
|
import { compressImage } from "@/lib/compress-image";
|
||||||
@@ -43,7 +44,9 @@ export default function BroadcastsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Composer onDone={refresh} />
|
<Can perm="broadcasts:write">
|
||||||
|
<Composer onDone={refresh} />
|
||||||
|
</Can>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h2 className="text-sm font-medium text-foreground">Recent</h2>
|
<h2 className="text-sm font-medium text-foreground">Recent</h2>
|
||||||
@@ -212,14 +215,16 @@ function Composer({ onDone }: { onDone: () => void }) {
|
|||||||
>
|
>
|
||||||
Save draft
|
Save draft
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Can perm="broadcasts:publish">
|
||||||
type="button"
|
<button
|
||||||
disabled={busy || !title.trim() || !body.trim()}
|
type="button"
|
||||||
onClick={() => submit(true)}
|
disabled={busy || !title.trim() || !body.trim()}
|
||||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
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>
|
Publish now
|
||||||
|
</button>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -268,33 +273,37 @@ function BroadcastRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.confirm("Publish now? This cannot be recalled.") &&
|
window.confirm(
|
||||||
act(() => broadcasts.publish(item.id), "Published")
|
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>
|
||||||
)}
|
</Can>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { Can } from "@/components/staff-context";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api, AdminError, type AdminFeedback } from "@/lib/api";
|
import { api, AdminError, type AdminFeedback } from "@/lib/api";
|
||||||
import { usePagedList } from "@/lib/use-paged-list";
|
import { usePagedList } from "@/lib/use-paged-list";
|
||||||
@@ -40,7 +41,8 @@ export default function FeedbackPage() {
|
|||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
title: "Acknowledge this feedback?",
|
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",
|
confirmLabel: "Acknowledge",
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
@@ -52,7 +54,8 @@ export default function FeedbackPage() {
|
|||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
title: "Mark resolved?",
|
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",
|
confirmLabel: "Mark resolved",
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
@@ -132,27 +135,35 @@ export default function FeedbackPage() {
|
|||||||
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
|
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
|
||||||
<TD className="text-right">
|
<TD className="text-right">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{!f.resolved_at && !f.acknowledged_at ? (
|
<Can perm="feedback:resolve">
|
||||||
<Button
|
{!f.resolved_at && !f.acknowledged_at ? (
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
onClick={() => acknowledge(f)}
|
variant="ghost"
|
||||||
>
|
onClick={() => acknowledge(f)}
|
||||||
Acknowledge
|
>
|
||||||
</Button>
|
Acknowledge
|
||||||
) : null}
|
</Button>
|
||||||
{!f.resolved_at ? (
|
) : null}
|
||||||
|
{!f.resolved_at ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => resolve(f)}
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Can>
|
||||||
|
<Can perm="feedback:reply">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => resolve(f)}
|
onClick={() => setReply(f)}
|
||||||
>
|
>
|
||||||
Resolve
|
{f.replied_at ? "Reply again" : "Reply"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</Can>
|
||||||
<Button size="sm" variant="outline" onClick={() => setReply(f)}>
|
|
||||||
{f.replied_at ? "Reply again" : "Reply"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TD>
|
</TD>
|
||||||
</TR>
|
</TR>
|
||||||
@@ -206,7 +217,10 @@ function ReplyModal({
|
|||||||
if (!feedback || message.trim() === "") return;
|
if (!feedback || message.trim() === "") return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
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 };
|
data: { replied: boolean; emailed: boolean };
|
||||||
};
|
};
|
||||||
toast.success(
|
toast.success(
|
||||||
|
|||||||
+117
-38
@@ -4,7 +4,9 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
Flag,
|
Flag,
|
||||||
|
ShieldCheck,
|
||||||
Gift,
|
Gift,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Quote,
|
Quote,
|
||||||
@@ -16,22 +18,57 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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 { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmProvider } from "@/components/confirm";
|
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 = [
|
const NAV = [
|
||||||
{ href: "/", label: "Overview", icon: LayoutDashboard },
|
{ href: "/", label: "Overview", icon: LayoutDashboard, perm: "stats:read" },
|
||||||
{ href: "/users", label: "Users", icon: Users },
|
{ href: "/users", label: "Users", icon: Users, perm: "users:read" },
|
||||||
{ href: "/posts", label: "Posts", icon: LayoutGrid },
|
{ href: "/posts", label: "Posts", icon: LayoutGrid, perm: "posts:moderate" },
|
||||||
{ href: "/collections", label: "Collections", icon: FolderClosed },
|
{
|
||||||
{ href: "/short-urls", label: "Short URLs", icon: Link2 },
|
href: "/collections",
|
||||||
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
|
label: "Collections",
|
||||||
{ href: "/reports", label: "Reports", icon: Flag },
|
icon: FolderClosed,
|
||||||
{ href: "/broadcasts", label: "Announcements", icon: Megaphone },
|
perm: "collections:read",
|
||||||
{ href: "/reward-gifts", label: "Gifts", icon: Gift },
|
},
|
||||||
{ href: "/vani", label: "Vāṇī of the Day", icon: Quote },
|
{
|
||||||
|
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({
|
export default function DashboardLayout({
|
||||||
@@ -42,22 +79,24 @@ export default function DashboardLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [staffMe, setStaffMe] = useState<StaffMe | null>(null);
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
// Admin gate: only an authenticated admin may see the dashboard. Anyone
|
// Staff gate: only an account holding an admin role, and not blocked from
|
||||||
// else is bounced to /login. The BFF independently enforces this on every
|
// the panel, may see the dashboard. /admin/me answers both at once — it 403s
|
||||||
// /admin/* call, so this is UX, not the security boundary.
|
// otherwise. The BFF enforces this on every /admin/* call regardless, so
|
||||||
|
// this is UX, not the security boundary.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api
|
Promise.all([api.me(), staff.me()])
|
||||||
.me()
|
.then(([u, s]) => {
|
||||||
.then((u) => {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!u || u.role !== "admin") {
|
if (!u || !s) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMe(u);
|
setMe(u);
|
||||||
|
setStaffMe(s);
|
||||||
setChecked(true);
|
setChecked(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -68,12 +107,25 @@ export default function DashboardLayout({
|
|||||||
};
|
};
|
||||||
}, [router]);
|
}, [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() {
|
async function signOut() {
|
||||||
await api.logout().catch(() => {});
|
await api.logout().catch(() => {});
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checked || !me) {
|
if (!checked || !me || !staffMe) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||||
Loading…
|
Loading…
|
||||||
@@ -91,29 +143,35 @@ export default function DashboardLayout({
|
|||||||
<p className="mt-0.5 text-sm font-semibold text-foreground">Admin</p>
|
<p className="mt-0.5 text-sm font-semibold text-foreground">Admin</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-1 px-3">
|
<nav className="flex-1 space-y-1 px-3">
|
||||||
{NAV.map(({ href, label, icon: Icon }) => {
|
{NAV.filter((n) => staffMe.permissions.includes(n.perm)).map(
|
||||||
const active = pathname === href || pathname.startsWith(href + "/");
|
({ href, label, icon: Icon }) => {
|
||||||
return (
|
const active =
|
||||||
<Link
|
pathname === href || pathname.startsWith(href + "/");
|
||||||
key={href}
|
return (
|
||||||
href={href}
|
<Link
|
||||||
className={cn(
|
key={href}
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
href={href}
|
||||||
active
|
className={cn(
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
active
|
||||||
)}
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
>
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
<Icon className="size-4" />
|
)}
|
||||||
{label}
|
>
|
||||||
</Link>
|
<Icon className="size-4" />
|
||||||
);
|
{label}
|
||||||
})}
|
</Link>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-border px-3 py-3">
|
<div className="border-t border-border px-3 py-3">
|
||||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||||
<p className="truncate font-medium text-foreground">{me.name}</p>
|
<p className="truncate font-medium text-foreground">{me.name}</p>
|
||||||
<p className="truncate">@{me.handle}</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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -129,7 +187,28 @@ export default function DashboardLayout({
|
|||||||
|
|
||||||
<main className="flex-1 overflow-x-hidden px-6 py-6 lg:px-10">
|
<main className="flex-1 overflow-x-hidden px-6 py-6 lg:px-10">
|
||||||
<div className="mx-auto max-w-6xl">
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function OverviewPage() {
|
|||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Feedback by category
|
Feedback by category
|
||||||
</h2>
|
</h2>
|
||||||
{stats.feedback_by_category.length === 0 ? (
|
{!stats.feedback_by_category?.length ? (
|
||||||
<p className="text-sm text-muted-foreground">No feedback yet.</p>
|
<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">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { Can } from "@/components/staff-context";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api, AdminError, type AdminReport, type ReportType } from "@/lib/api";
|
import { api, AdminError, type AdminReport, type ReportType } from "@/lib/api";
|
||||||
import { usePagedList } from "@/lib/use-paged-list";
|
import { usePagedList } from "@/lib/use-paged-list";
|
||||||
@@ -46,7 +47,11 @@ export default function ReportsPage() {
|
|||||||
if (r.type === "post") {
|
if (r.type === "post") {
|
||||||
return (
|
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
|
Hide
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -56,7 +61,8 @@ export default function ReportsPage() {
|
|||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
title: "Delete this post?",
|
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",
|
confirmLabel: "Delete post",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
})
|
})
|
||||||
@@ -79,7 +85,8 @@ export default function ReportsPage() {
|
|||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
title: "Delete this comment?",
|
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",
|
confirmLabel: "Delete comment",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
})
|
})
|
||||||
@@ -100,7 +107,8 @@ export default function ReportsPage() {
|
|||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
title: "Suspend this account?",
|
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",
|
confirmLabel: "Suspend account",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
})
|
})
|
||||||
@@ -122,7 +130,8 @@ export default function ReportsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
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")
|
else if (r.type === "comment")
|
||||||
run(api.restoreComment(r.target_id), "Comment restored.");
|
run(api.restoreComment(r.target_id), "Comment restored.");
|
||||||
else run(api.unbanUser(r.target_id), "Account restored.");
|
else run(api.unbanUser(r.target_id), "Account restored.");
|
||||||
@@ -134,7 +143,11 @@ export default function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetLabel =
|
const targetLabel =
|
||||||
type === "user" ? "Reported account" : type === "comment" ? "Comment" : "Post";
|
type === "user"
|
||||||
|
? "Reported account"
|
||||||
|
: type === "comment"
|
||||||
|
? "Comment"
|
||||||
|
: "Post";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListShell
|
<ListShell
|
||||||
@@ -187,7 +200,9 @@ export default function ReportsPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="line-clamp-2">{r.target_text || "—"}</div>
|
<div className="line-clamp-2">
|
||||||
|
{r.target_text || "—"}
|
||||||
|
</div>
|
||||||
{r.target_author_handle ? (
|
{r.target_author_handle ? (
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
by @{r.target_author_handle}
|
by @{r.target_author_handle}
|
||||||
@@ -231,13 +246,15 @@ export default function ReportsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{r.target_removed ? undoButton(r) : modButton(r)}
|
{r.target_removed ? undoButton(r) : modButton(r)}
|
||||||
<Button
|
<Can perm="reports:resolve">
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
onClick={() => setResolveTarget(r)}
|
variant="ghost"
|
||||||
>
|
onClick={() => setResolveTarget(r)}
|
||||||
Resolve
|
>
|
||||||
</Button>
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TD>
|
</TD>
|
||||||
@@ -281,7 +298,9 @@ function ResolveModal({
|
|||||||
setNote("");
|
setNote("");
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof AdminError ? err.message : "Could not resolve.");
|
toast.error(
|
||||||
|
err instanceof AdminError ? err.message : "Could not resolve.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Can } from "@/components/staff-context";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { rewardGifts, type GiftableReward } from "@/lib/api";
|
import { rewardGifts, type GiftableReward } from "@/lib/api";
|
||||||
@@ -48,7 +49,9 @@ export default function RewardGiftsPage() {
|
|||||||
: `Sent to ${granted} ${granted === 1 ? "devotee" : "devotees"}`,
|
: `Sent to ${granted} ${granted === 1 ? "devotee" : "devotees"}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setSending(null);
|
setSending(null);
|
||||||
}
|
}
|
||||||
@@ -81,16 +84,18 @@ export default function RewardGiftsPage() {
|
|||||||
Occasional gifts
|
Occasional gifts
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Send a sticker to everyone, no streak required. It arrives wrapped
|
Send a sticker to everyone, no streak required. It arrives wrapped and
|
||||||
and opens with a celebration.
|
opens with a celebration.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<CustomGift
|
<Can perm="rewards:write">
|
||||||
onCreated={(reward) =>
|
<CustomGift
|
||||||
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
|
onCreated={(reward) =>
|
||||||
}
|
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Can>
|
||||||
|
|
||||||
{catalog === null ? (
|
{catalog === null ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
@@ -122,27 +127,31 @@ export default function RewardGiftsPage() {
|
|||||||
{r.name}
|
{r.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-auto w-full space-y-1.5">
|
<div className="mt-auto w-full space-y-1.5">
|
||||||
<button
|
<Can perm="rewards:send">
|
||||||
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_") && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={sending !== null}
|
disabled={sending !== null}
|
||||||
onClick={() => remove(r)}
|
onClick={() => send(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"
|
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>
|
</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>
|
</div>
|
||||||
</li>
|
</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 { RichTextarea } from "@/components/ui/rich-textarea";
|
||||||
import { fmtDate } from "@/lib/utils";
|
import { fmtDate } from "@/lib/utils";
|
||||||
import { DiscipleLinkModal } from "./disciple-link-modal";
|
import { DiscipleLinkModal } from "./disciple-link-modal";
|
||||||
|
import { useStaff } from "@/components/staff-context";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
|
const { can } = useStaff();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const fetcher = useCallback(
|
const fetcher = useCallback(
|
||||||
(limit: number, offset: number) => api.users(q, limit, offset),
|
(limit: number, offset: number) => api.users(q, limit, offset),
|
||||||
@@ -79,22 +81,29 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function menuItems(u: AdminUser): MenuItem[] {
|
function menuItems(u: AdminUser): MenuItem[] {
|
||||||
const items: MenuItem[] = [
|
// Only what this role may do. The BFF refuses the rest regardless.
|
||||||
{ label: "Send notification", onClick: () => setNotify(u) },
|
const items: MenuItem[] = [];
|
||||||
{ label: "Edit limits", onClick: () => setLimitsUser(u) },
|
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",
|
label: u.disciple_id ? "Change disciple link" : "Link direct disciple",
|
||||||
onClick: () => setDiscipleUser(u),
|
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).
|
// No ban option for staff: removing a staff member is a superadmin job,
|
||||||
if (u.role !== "admin") {
|
// done from the Staff page — and the BFF refuses it from here anyway.
|
||||||
|
if (can("users:ban") && !u.admin_role) {
|
||||||
items.push({
|
items.push({
|
||||||
label: u.banned_at ? "Unban account" : "Ban account",
|
label: u.banned_at ? "Unban account" : "Ban account",
|
||||||
onClick: () => toggleBan(u),
|
onClick: () => toggleBan(u),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Can } from "@/components/staff-context";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
vani,
|
vani,
|
||||||
@@ -142,80 +143,84 @@ export default function VaniPage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-3 rounded-xl border border-border p-4">
|
<Can perm="vani:write">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<section className="space-y-3 rounded-xl border border-border p-4">
|
||||||
<input
|
<div className="flex flex-wrap items-center gap-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`}
|
|
||||||
/>
|
|
||||||
<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
|
<input
|
||||||
type="date"
|
value={link}
|
||||||
value={day}
|
onChange={(e) => setLink(e.target.value)}
|
||||||
min={todayUTC()}
|
onKeyDown={(e) => e.key === "Enter" && void pinByLink()}
|
||||||
onChange={(e) => setDay(e.target.value)}
|
placeholder="Paste a link — /transcriptions/720720sb-london"
|
||||||
className={`${field} ml-2`}
|
className={`${field} min-w-0 flex-1`}
|
||||||
/>
|
/>
|
||||||
</label>
|
<button
|
||||||
<input
|
type="button"
|
||||||
value={query}
|
disabled={busy || !link.trim()}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onClick={pinByLink}
|
||||||
placeholder="Search verses, chapters and lectures"
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
className={`${field} min-w-0 flex-1`}
|
>
|
||||||
/>
|
Use this link
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
{!searchable ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : visible.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">Nothing found.</p>
|
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
|
||||||
) : (
|
<label className="text-sm text-muted-foreground">
|
||||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
Date
|
||||||
{visible.map((c) => (
|
<input
|
||||||
<li
|
type="date"
|
||||||
key={c.doc_id}
|
value={day}
|
||||||
className="flex items-center gap-3 px-3 py-2.5"
|
min={todayUTC()}
|
||||||
>
|
onChange={(e) => setDay(e.target.value)}
|
||||||
<div className="min-w-0 flex-1">
|
className={`${field} ml-2`}
|
||||||
<p className="truncate text-sm text-foreground">{c.title}</p>
|
/>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
</label>
|
||||||
{vaniKindFor(c.collection)} · {c.snippet}
|
<input
|
||||||
</p>
|
value={query}
|
||||||
</div>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
<button
|
placeholder="Search verses, chapters and lectures"
|
||||||
type="button"
|
className={`${field} min-w-0 flex-1`}
|
||||||
disabled={busy}
|
/>
|
||||||
onClick={() => pin(c)}
|
</div>
|
||||||
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"
|
|
||||||
|
{!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}
|
<div className="min-w-0 flex-1">
|
||||||
</button>
|
<p className="truncate text-sm text-foreground">
|
||||||
</li>
|
{c.title}
|
||||||
))}
|
</p>
|
||||||
</ul>
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
)}
|
{vaniKindFor(c.collection)} · {c.snippet}
|
||||||
</section>
|
</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">
|
<section className="space-y-2">
|
||||||
<h2 className="text-sm font-medium text-foreground">
|
<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.payload.book_title ? ` · ${p.payload.book_title}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Can perm="vani:write">
|
||||||
type="button"
|
<button
|
||||||
disabled={busy}
|
type="button"
|
||||||
onClick={() => clear(p)}
|
disabled={busy}
|
||||||
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
|
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>
|
Clear
|
||||||
|
</button>
|
||||||
|
</Can>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+15
-9
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
@@ -17,17 +17,23 @@ export default function LoginPage() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Once authenticated, only admins may proceed. Non-admins are signed back
|
// Once authenticated, only staff may proceed. Non-staff — and staff a
|
||||||
// out so an ordinary account can't sit in an authenticated-but-forbidden
|
// superadmin has blocked — are signed back out, so an ordinary account
|
||||||
// state against the admin origin.
|
// can't sit in an authenticated-but-forbidden state against this origin.
|
||||||
async function gateAdmin(user: Me) {
|
async function gateAdmin(_user: Me) {
|
||||||
if (user.role === "admin") {
|
try {
|
||||||
|
await staff.me();
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
return;
|
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) {
|
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;
|
name: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
/** Staff role key (superadmin/admin/moderator/custom), absent for users. */
|
||||||
|
admin_role?: string | null;
|
||||||
plan: string;
|
plan: string;
|
||||||
email_verified_at: string | null;
|
email_verified_at: string | null;
|
||||||
banned_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