feat: admin dashboard
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# The BFF base URL (no trailing slash). The admin app calls ${NEXT_PUBLIC_BFF_URL}/api/v1/...
|
||||
# Locally the BFF runs on :8080; in production point this at your API host.
|
||||
NEXT_PUBLIC_BFF_URL=http://localhost:8080
|
||||
|
||||
# IMPORTANT — also set ADMIN_URL on the BFF to this app's origin so CORS allows
|
||||
# credentialed requests from here:
|
||||
# dev: ADMIN_URL=http://localhost:3001
|
||||
# prod: ADMIN_URL=https://admin.prabhupadasays.org
|
||||
@@ -0,0 +1,9 @@
|
||||
/node_modules
|
||||
/.next
|
||||
/out
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env
|
||||
.env*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,69 @@
|
||||
# Prabhupāda Says — Admin
|
||||
|
||||
A separate Next.js 16 + Tailwind v4 admin panel that talks to the existing Go
|
||||
BFF. It reuses the BFF's cookie session (no separate auth system) and every
|
||||
action is gated server-side by the new `/api/v1/admin/*` endpoints, which
|
||||
require `role = "admin"`.
|
||||
|
||||
## Features
|
||||
|
||||
- Email/password login (with the BFF's OTP step when required) + forgot/reset password
|
||||
- Users — search, paginate, **send a notification** to an individual user
|
||||
- Posts — search, paginate
|
||||
- Collections — search, paginate
|
||||
- Feedback — paginate, filter (all/open/resolved), **reply** (emails the submitter)
|
||||
- Reports — posts / comments / accounts tabs, search, paginate, **resolve**
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The BFF must be running with migration `0038_admin_panel` applied (it runs
|
||||
automatically on BFF startup) and these env vars set **on the BFF**:
|
||||
|
||||
```
|
||||
ADMIN_URL=http://localhost:3001 # this app's origin — added to CORS
|
||||
# (production: ADMIN_URL=https://admin.prabhupadasays.org)
|
||||
```
|
||||
|
||||
Without `ADMIN_URL`, the browser's credentialed requests from this origin are
|
||||
blocked by CORS.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd spsays-admin
|
||||
cp .env.example .env.local # set NEXT_PUBLIC_BFF_URL
|
||||
yarn install
|
||||
yarn dev # http://localhost:3001
|
||||
```
|
||||
|
||||
## Make yourself an admin
|
||||
|
||||
The panel only admits users whose role is `admin`. Promote your account once,
|
||||
directly in Postgres:
|
||||
|
||||
```sql
|
||||
UPDATE users SET role = 'admin' WHERE email = 'you@example.com';
|
||||
```
|
||||
|
||||
Then sign in at `/login` with that account's email + password.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Deploy on its own subdomain, e.g. `admin.prabhupadasays.org`, and set
|
||||
`ADMIN_URL` on the BFF to that origin. The session cookie is shared because
|
||||
the BFF sets it on `.prabhupadasays.org`.
|
||||
- The app sets `robots: noindex` and should additionally be access-restricted
|
||||
at the edge if desired — the BFF's `RequireAdmin` is the real security
|
||||
boundary, but defense in depth doesn't hurt.
|
||||
|
||||
## How it maps to the BFF
|
||||
|
||||
| Screen | Endpoint |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| Login | `POST /auth/login` (+ `verify-login-otp`) |
|
||||
| Forgot/Reset| `POST /auth/forgot-password` / `reset-password` |
|
||||
| Users | `GET /admin/users`, `POST /admin/users/{id}/notify` |
|
||||
| Posts | `GET /admin/posts` |
|
||||
| Collections | `GET /admin/collections` |
|
||||
| Feedback | `GET /admin/feedback`, `POST /admin/feedback/{id}/reply` |
|
||||
| Reports | `GET /admin/reports?type=`, `POST /admin/reports/{type}/{id}/resolve` |
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { api, type AdminCollection } from "@/lib/api";
|
||||
import { usePagedList } from "@/lib/use-paged-list";
|
||||
import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { fmtDate } from "@/lib/utils";
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const fetcher = useCallback(
|
||||
(limit: number, offset: number) => api.collections(q, limit, offset),
|
||||
[q],
|
||||
);
|
||||
const list = usePagedList<AdminCollection>(fetcher, [q]);
|
||||
|
||||
return (
|
||||
<ListShell
|
||||
title="Collections"
|
||||
total={list.total}
|
||||
searchPlaceholder="Search title, description, or owner…"
|
||||
onQueryChange={setQ}
|
||||
list={list}
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Collection</TH>
|
||||
<TH>Owner</TH>
|
||||
<TH>Items</TH>
|
||||
<TH>Visibility</TH>
|
||||
<TH>Created</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{list.items.length === 0 && !list.loading ? (
|
||||
<EmptyRow cols={5} label="No collections found." />
|
||||
) : (
|
||||
list.items.map((c) => (
|
||||
<TR key={c.id}>
|
||||
<TD className="max-w-md">
|
||||
<div className="font-medium">{c.title}</div>
|
||||
{c.description ? (
|
||||
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
|
||||
{c.description}
|
||||
</div>
|
||||
) : null}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">
|
||||
<div className="font-medium">{c.owner_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@{c.owner_handle}
|
||||
</div>
|
||||
</TD>
|
||||
<TD>{c.item_count}</TD>
|
||||
<TD>
|
||||
<span
|
||||
className={
|
||||
c.is_public ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{c.is_public ? "Public" : "Private"}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">{fmtDate(c.created_at)}</TD>
|
||||
</TR>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ListShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api, AdminError, type AdminFeedback } from "@/lib/api";
|
||||
import { usePagedList } from "@/lib/use-paged-list";
|
||||
import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Textarea } from "@/components/ui/input";
|
||||
import { cn, fmtDate } from "@/lib/utils";
|
||||
|
||||
const STATUSES = ["all", "open", "resolved"] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const [status, setStatus] = useState<Status>("all");
|
||||
const fetcher = useCallback(
|
||||
(limit: number, offset: number) => api.feedback(status, limit, offset),
|
||||
[status],
|
||||
);
|
||||
const list = usePagedList<AdminFeedback>(fetcher, [status]);
|
||||
const [reply, setReply] = useState<AdminFeedback | null>(null);
|
||||
|
||||
return (
|
||||
<ListShell
|
||||
title="Feedback"
|
||||
total={list.total}
|
||||
list={list}
|
||||
toolbar={
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
{STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatus(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm capitalize transition-colors",
|
||||
status === s
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>From</TH>
|
||||
<TH>Message</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Received</TH>
|
||||
<TH className="text-right">Actions</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{list.items.length === 0 && !list.loading ? (
|
||||
<EmptyRow cols={5} label="No feedback." />
|
||||
) : (
|
||||
list.items.map((f) => (
|
||||
<TR key={f.id}>
|
||||
<TD className="whitespace-nowrap">
|
||||
{f.author_handle ? (
|
||||
<>
|
||||
<div className="font-medium">{f.author_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@{f.author_handle}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{f.contact_email ?? "Anonymous"}
|
||||
</div>
|
||||
)}
|
||||
</TD>
|
||||
<TD className="max-w-md">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{f.category}
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-line">{f.message}</div>
|
||||
{f.reply_message ? (
|
||||
<div className="mt-2 rounded-md bg-muted px-3 py-2 text-xs">
|
||||
<span className="font-medium">Your reply: </span>
|
||||
{f.reply_message}
|
||||
</div>
|
||||
) : null}
|
||||
</TD>
|
||||
<TD>
|
||||
<StatusBadge f={f} />
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
|
||||
<TD className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setReply(f)}>
|
||||
{f.replied_at ? "Reply again" : "Reply"}
|
||||
</Button>
|
||||
</TD>
|
||||
</TR>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<ReplyModal
|
||||
feedback={reply}
|
||||
onClose={() => setReply(null)}
|
||||
onDone={() => {
|
||||
setReply(null);
|
||||
list.refresh();
|
||||
}}
|
||||
/>
|
||||
</ListShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ f }: { f: AdminFeedback }) {
|
||||
const label = f.replied_at ? "Replied" : f.resolved_at ? "Resolved" : "Open";
|
||||
const tone = f.resolved_at || f.replied_at ? "text-primary" : "text-muted-foreground";
|
||||
return <span className={cn("text-sm", tone)}>{label}</span>;
|
||||
}
|
||||
|
||||
function ReplyModal({
|
||||
feedback,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
feedback: AdminFeedback | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function send() {
|
||||
if (!feedback || message.trim() === "") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data } = (await api.replyFeedback(feedback.id, message.trim())) as {
|
||||
data: { replied: boolean; emailed: boolean };
|
||||
};
|
||||
toast.success(
|
||||
data?.emailed
|
||||
? "Reply saved and emailed to the submitter."
|
||||
: "Reply saved (no contact email on file to send to).",
|
||||
);
|
||||
setMessage("");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof AdminError ? err.message : "Could not reply.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={!!feedback} onClose={onClose} title="Reply to feedback">
|
||||
<div className="space-y-4">
|
||||
{feedback ? (
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{feedback.category}
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-line">{feedback.message}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Your reply</span>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
placeholder="Write your reply… (emailed to the submitter, marks this resolved)"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={send} disabled={busy || message.trim() === ""}>
|
||||
{busy ? "Sending…" : "Send reply"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Flag,
|
||||
FolderClosed,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { api, type Me } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmProvider } from "@/components/confirm";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/users", label: "Users", icon: Users },
|
||||
{ href: "/posts", label: "Posts", icon: LayoutGrid },
|
||||
{ href: "/collections", label: "Collections", icon: FolderClosed },
|
||||
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
|
||||
{ href: "/reports", label: "Reports", icon: Flag },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
// Admin gate: only an authenticated admin may see the dashboard. Anyone
|
||||
// else is bounced to /login. The BFF independently enforces this on every
|
||||
// /admin/* call, so this is UX, not the security boundary.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.me()
|
||||
.then((u) => {
|
||||
if (cancelled) return;
|
||||
if (!u || u.role !== "admin") {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
setMe(u);
|
||||
setChecked(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) router.replace("/login");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
async function signOut() {
|
||||
await api.logout().catch(() => {});
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
if (!checked || !me) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r border-border bg-card">
|
||||
<div className="px-5 py-5">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Prabhupāda Says
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-foreground">Admin</p>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{NAV.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||
<p className="truncate font-medium text-foreground">{me.name}</p>
|
||||
<p className="truncate">@{me.handle}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={signOut}
|
||||
className="w-full justify-start text-muted-foreground"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-x-hidden px-6 py-6 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<ConfirmProvider>{children}</ConfirmProvider>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// The dashboard root just lands on Users.
|
||||
export default function DashboardIndex() {
|
||||
redirect("/users");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api, AdminError, type AdminPost } from "@/lib/api";
|
||||
import { usePagedList } from "@/lib/use-paged-list";
|
||||
import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
import { fmtDate } from "@/lib/utils";
|
||||
|
||||
export default function PostsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const fetcher = useCallback(
|
||||
(limit: number, offset: number) => api.posts(q, limit, offset),
|
||||
[q],
|
||||
);
|
||||
const list = usePagedList<AdminPost>(fetcher, [q]);
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function run(action: Promise<unknown>, successMsg: string) {
|
||||
try {
|
||||
await action;
|
||||
toast.success(successMsg);
|
||||
list.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof AdminError ? err.message : "Action failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(p: AdminPost) {
|
||||
if (
|
||||
await confirm({
|
||||
title: "Delete this post?",
|
||||
message: "It will be removed from the app. You can restore it here later.",
|
||||
confirmLabel: "Delete post",
|
||||
destructive: true,
|
||||
})
|
||||
) {
|
||||
run(api.deletePost(p.id), "Post deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListShell
|
||||
title="Posts"
|
||||
total={list.total}
|
||||
searchPlaceholder="Search caption, source, or author…"
|
||||
onQueryChange={setQ}
|
||||
list={list}
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Post</TH>
|
||||
<TH>Author</TH>
|
||||
<TH>Engagement</TH>
|
||||
<TH>Created</TH>
|
||||
<TH className="text-right">Actions</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{list.items.length === 0 && !list.loading ? (
|
||||
<EmptyRow cols={5} label="No posts found." />
|
||||
) : (
|
||||
list.items.map((p) => (
|
||||
<TR key={p.id} className={p.deleted_at ? "opacity-60" : ""}>
|
||||
<TD className="max-w-md">
|
||||
<div className="line-clamp-2">{p.caption || "—"}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{p.source_title}
|
||||
{p.deleted_at ? (
|
||||
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive">
|
||||
Deleted
|
||||
</span>
|
||||
) : p.is_hidden ? (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
|
||||
Hidden
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">
|
||||
<div className="font-medium">{p.author_name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@{p.author_handle}
|
||||
</div>
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{p.likes_count} likes · {p.comments_count} comments
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">{fmtDate(p.created_at)}</TD>
|
||||
<TD className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{p.deleted_at ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => run(api.restorePost(p.id), "Post restored.")}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
run(
|
||||
p.is_hidden
|
||||
? api.unhidePost(p.id)
|
||||
: api.hidePost(p.id),
|
||||
p.is_hidden ? "Post unhidden." : "Post hidden.",
|
||||
)
|
||||
}
|
||||
>
|
||||
{p.is_hidden ? "Unhide" : "Hide"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(p)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ListShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api, AdminError, type AdminReport, type ReportType } from "@/lib/api";
|
||||
import { usePagedList } from "@/lib/use-paged-list";
|
||||
import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
import { cn, fmtDate } from "@/lib/utils";
|
||||
|
||||
const TYPES: { key: ReportType; label: string }[] = [
|
||||
{ key: "post", label: "Posts" },
|
||||
{ key: "comment", label: "Comments" },
|
||||
{ key: "user", label: "Accounts" },
|
||||
];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [type, setType] = useState<ReportType>("post");
|
||||
const [q, setQ] = useState("");
|
||||
const fetcher = useCallback(
|
||||
(limit: number, offset: number) => api.reports(type, q, limit, offset),
|
||||
[type, q],
|
||||
);
|
||||
const list = usePagedList<AdminReport>(fetcher, [type, q]);
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function run(action: Promise<unknown>, successMsg: string) {
|
||||
try {
|
||||
await action;
|
||||
toast.success(successMsg);
|
||||
list.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof AdminError ? err.message : "Action failed.");
|
||||
}
|
||||
}
|
||||
|
||||
// Per-report moderation buttons. Destructive actions confirm first.
|
||||
function actionsFor(r: AdminReport) {
|
||||
if (r.type === "post") {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => run(api.hidePost(r.target_id), "Post hidden.")}>
|
||||
Hide
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: "Delete this post?",
|
||||
message:
|
||||
"It will be removed from the app. You can restore it later from the Posts tab.",
|
||||
confirmLabel: "Delete post",
|
||||
destructive: true,
|
||||
})
|
||||
) {
|
||||
run(api.deletePost(r.target_id), "Post deleted.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (r.type === "comment") {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: "Delete this comment?",
|
||||
message: "It will be removed from the thread (restorable).",
|
||||
confirmLabel: "Delete comment",
|
||||
destructive: true,
|
||||
})
|
||||
) {
|
||||
run(api.deleteComment(r.target_id), "Comment deleted.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: "Suspend this account?",
|
||||
message: "They'll be signed out and unable to log in until you unban them.",
|
||||
confirmLabel: "Suspend account",
|
||||
destructive: true,
|
||||
})
|
||||
) {
|
||||
run(api.banUser(r.target_id), "Account suspended.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const targetLabel =
|
||||
type === "user" ? "Reported account" : type === "comment" ? "Comment" : "Post";
|
||||
|
||||
return (
|
||||
<ListShell
|
||||
title="Reports"
|
||||
total={list.total}
|
||||
searchPlaceholder="Search content or reporter…"
|
||||
onQueryChange={setQ}
|
||||
list={list}
|
||||
toolbar={
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
{TYPES.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setType(t.key)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
type === t.key
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>{targetLabel}</TH>
|
||||
<TH>Reason</TH>
|
||||
<TH>Reporter</TH>
|
||||
<TH>Reported</TH>
|
||||
<TH className="text-right">Actions</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{list.items.length === 0 && !list.loading ? (
|
||||
<EmptyRow cols={5} label="No reports." />
|
||||
) : (
|
||||
list.items.map((r) => (
|
||||
<TR key={r.id} className={r.resolved_at ? "opacity-60" : ""}>
|
||||
<TD className="max-w-xs">
|
||||
{type === "user" ? (
|
||||
<span className="font-medium">
|
||||
{r.target_text ? `@${r.target_text}` : "—"}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="line-clamp-2">{r.target_text || "—"}</div>
|
||||
{r.target_author_handle ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
by @{r.target_author_handle}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</TD>
|
||||
<TD className="max-w-xs">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{r.category}
|
||||
</div>
|
||||
{r.reason ? (
|
||||
<div className="mt-0.5 line-clamp-2">{r.reason}</div>
|
||||
) : null}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{r.reporter_handle ? `@${r.reporter_handle}` : "—"}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap">{fmtDate(r.created_at)}</TD>
|
||||
<TD className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{actionsFor(r)}
|
||||
{r.resolved_at ? (
|
||||
<span className="self-center text-xs text-muted-foreground">
|
||||
Resolved
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
run(api.resolveReport(r.type, r.id), "Report resolved.")
|
||||
}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ListShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api, AdminError, type AdminUser } from "@/lib/api";
|
||||
import { usePagedList } from "@/lib/use-paged-list";
|
||||
import { ListShell, EmptyRow } from "@/components/list-shell";
|
||||
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { fmtDate } from "@/lib/utils";
|
||||
|
||||
export default function UsersPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const fetcher = useCallback(
|
||||
(limit: number, offset: number) => api.users(q, limit, offset),
|
||||
[q],
|
||||
);
|
||||
const list = usePagedList<AdminUser>(fetcher, [q]);
|
||||
const confirm = useConfirm();
|
||||
const [notify, setNotify] = useState<AdminUser | null>(null);
|
||||
|
||||
async function toggleBan(u: AdminUser) {
|
||||
const banning = !u.banned_at;
|
||||
if (
|
||||
banning &&
|
||||
!(await confirm({
|
||||
title: `Suspend @${u.handle}?`,
|
||||
message: "They'll be signed out and unable to log in until you unban them.",
|
||||
confirmLabel: "Suspend account",
|
||||
destructive: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await (banning ? api.banUser(u.id) : api.unbanUser(u.id));
|
||||
toast.success(banning ? "Account suspended." : "Account restored.");
|
||||
list.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof AdminError ? err.message : "Action failed.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListShell
|
||||
title="Users"
|
||||
total={list.total}
|
||||
searchPlaceholder="Search name, handle, or email…"
|
||||
onQueryChange={setQ}
|
||||
list={list}
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>User</TH>
|
||||
<TH>Email</TH>
|
||||
<TH>Role</TH>
|
||||
<TH>Joined</TH>
|
||||
<TH className="text-right">Actions</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{list.items.length === 0 && !list.loading ? (
|
||||
<EmptyRow cols={5} label="No users found." />
|
||||
) : (
|
||||
list.items.map((u) => (
|
||||
<TR key={u.id}>
|
||||
<TD>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{u.name}</span>
|
||||
{u.banned_at ? (
|
||||
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive">
|
||||
Suspended
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">@{u.handle}</div>
|
||||
</TD>
|
||||
<TD>
|
||||
{u.email ?? <span className="text-muted-foreground">—</span>}
|
||||
</TD>
|
||||
<TD className="capitalize">{u.role}</TD>
|
||||
<TD className="whitespace-nowrap">{fmtDate(u.created_at)}</TD>
|
||||
<TD className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setNotify(u)}>
|
||||
Notify
|
||||
</Button>
|
||||
{u.role !== "admin" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={u.banned_at ? "outline" : "destructive"}
|
||||
onClick={() => toggleBan(u)}
|
||||
>
|
||||
{u.banned_at ? "Unban" : "Ban"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<NotifyModal user={notify} onClose={() => setNotify(null)} />
|
||||
</ListShell>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyModal({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function send() {
|
||||
if (!user || body.trim() === "") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.notifyUser(user.id, title.trim(), body.trim());
|
||||
toast.success(`Notification sent to @${user.handle}`);
|
||||
setTitle("");
|
||||
setBody("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof AdminError ? err.message : "Could not send.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!user}
|
||||
onClose={onClose}
|
||||
title={user ? `Notify @${user.handle}` : "Notify"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sends an in-app notification, plus web push and email where available.
|
||||
</p>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Title (optional)</span>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder="A note from the team"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Message</span>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
placeholder="Write your message…"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={send} disabled={busy || body.trim() === ""}>
|
||||
{busy ? "Sending…" : "Send notification"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api, AdminError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AuthShell, Field } from "@/app/login/page";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.forgotPassword(email.trim());
|
||||
// The BFF always responds 200 (it doesn't reveal whether an account
|
||||
// exists), so we show the same confirmation regardless.
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof AdminError ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Reset your password">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
If an account exists for <strong>{email}</strong>, we've sent a
|
||||
password-reset link. Check your inbox.
|
||||
</p>
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button type="submit" disabled={busy} className="w-full">
|
||||
{busy ? "Sending…" : "Send reset link"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="hover:text-foreground">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Admin theme — low-chroma navy/slate surfaces with a peacock-green accent,
|
||||
* matching the Prabhupāda Says palette direction (hue lives in accents only).
|
||||
*/
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: #f6f8f8;
|
||||
--foreground: #14202a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #14202a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #14202a;
|
||||
--primary: #0f7b6c;
|
||||
--primary-foreground: #ffffff;
|
||||
--muted: #eef1f2;
|
||||
--muted-foreground: #5a6b73;
|
||||
--accent: #e7f1ef;
|
||||
--accent-foreground: #0f7b6c;
|
||||
--destructive: #c0392b;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e1e6e7;
|
||||
--input: #e1e6e7;
|
||||
--ring: #0f7b6c;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Prabhupāda Says — Admin",
|
||||
description: "Administration panel for Prabhupāda Says.",
|
||||
// The admin panel must never be indexed.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{children}
|
||||
<Toaster richColors position="top-center" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { api, AdminError, type Me } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [stage, setStage] = useState<"credentials" | "otp">("credentials");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Once authenticated, only admins may proceed. Non-admins are signed back
|
||||
// out so an ordinary account can't sit in an authenticated-but-forbidden
|
||||
// state against the admin origin.
|
||||
async function gateAdmin(user: Me) {
|
||||
if (user.role === "admin") {
|
||||
router.replace("/users");
|
||||
return;
|
||||
}
|
||||
await api.logout().catch(() => {});
|
||||
setError("This account doesn't have admin access.");
|
||||
setStage("credentials");
|
||||
}
|
||||
|
||||
async function onCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.login(email.trim(), password);
|
||||
if (res.kind === "otp_required") {
|
||||
setStage("otp");
|
||||
toast("We emailed you a verification code.");
|
||||
} else {
|
||||
await gateAdmin(res.user);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof AdminError ? err.message : "Could not sign in.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onOtp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const user = await api.verifyLoginOtp(email.trim(), password, otp.trim());
|
||||
await gateAdmin(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof AdminError ? err.message : "Invalid code.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Admin sign in">
|
||||
{stage === "credentials" ? (
|
||||
<form onSubmit={onCredentials} className="space-y-4">
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button type="submit" disabled={busy} className="w-full">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/forgot-password" className="hover:text-foreground">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={onOtp} className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the verification code we emailed to {email}.
|
||||
</p>
|
||||
<Field label="Verification code">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
required
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button type="submit" disabled={busy} className="w-full">
|
||||
{busy ? "Verifying…" : "Verify & sign in"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStage("credentials");
|
||||
setOtp("");
|
||||
setError(null);
|
||||
}}
|
||||
className="w-full text-center text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Prabhupāda Says
|
||||
</p>
|
||||
<h1 className="mt-1 text-xl font-semibold text-foreground">{title}</h1>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { api, AdminError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AuthShell, Field } from "@/app/login/page";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<AuthShell title="Reset your password">…</AuthShell>}>
|
||||
<ResetInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetInner() {
|
||||
const router = useRouter();
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords don't match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.resetPassword(token, password);
|
||||
toast.success("Password updated — please sign in.");
|
||||
router.replace("/login");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof AdminError ? err.message : "Could not reset password.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthShell title="Reset your password">
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>This reset link is missing its token. Request a new one.</p>
|
||||
<Link href="/forgot-password" className="text-primary hover:underline">
|
||||
Request a reset link
|
||||
</Link>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Choose a new password">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="New password">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Confirm password">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button type="submit" disabled={busy} className="w-full">
|
||||
{busy ? "Saving…" : "Update password"}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ConfirmOpts = {
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
type ConfirmFn = (opts: ConfirmOpts) => Promise<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn | null>(null);
|
||||
|
||||
/** Imperative confirm — `if (await confirm({...})) { … }`. Renders a styled
|
||||
* modal instead of the browser's window.confirm. Provided by ConfirmProvider. */
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const fn = useContext(ConfirmContext);
|
||||
if (!fn) throw new Error("useConfirm must be used within a ConfirmProvider");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState<{
|
||||
opts: ConfirmOpts;
|
||||
resolve: (v: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback<ConfirmFn>(
|
||||
(opts) => new Promise<boolean>((resolve) => setPending({ opts, resolve })),
|
||||
[],
|
||||
);
|
||||
|
||||
function settle(value: boolean) {
|
||||
pending?.resolve(value);
|
||||
setPending(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<Modal
|
||||
open={!!pending}
|
||||
onClose={() => settle(false)}
|
||||
title={pending?.opts.title ?? ""}
|
||||
>
|
||||
{pending ? (
|
||||
<div className="space-y-5">
|
||||
{pending.opts.message ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pending.opts.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => settle(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={pending.opts.destructive ? "destructive" : "primary"}
|
||||
onClick={() => settle(true)}
|
||||
>
|
||||
{pending.opts.confirmLabel ?? "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ListLike = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
setPage: (p: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared chrome for every admin list page: a title with a live total, an
|
||||
* optional debounced search box, an optional toolbar (filters/tabs), the
|
||||
* table (children), and a pagination footer. Keeps the resource pages tiny.
|
||||
*/
|
||||
export function ListShell({
|
||||
title,
|
||||
total,
|
||||
searchPlaceholder,
|
||||
onQueryChange,
|
||||
toolbar,
|
||||
list,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
total: number;
|
||||
searchPlaceholder?: string;
|
||||
onQueryChange?: (q: string) => void;
|
||||
toolbar?: React.ReactNode;
|
||||
list: ListLike;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [raw, setRaw] = React.useState("");
|
||||
|
||||
// Debounce search input → parent query (300ms).
|
||||
React.useEffect(() => {
|
||||
if (!onQueryChange) return;
|
||||
const t = setTimeout(() => onQueryChange(raw.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [raw, onQueryChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{title}
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</h1>
|
||||
{toolbar}
|
||||
</div>
|
||||
|
||||
{onQueryChange ? (
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.target.value)}
|
||||
placeholder={searchPlaceholder ?? "Search…"}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{list.error ? (
|
||||
<p className="text-sm text-destructive">{list.error}</p>
|
||||
) : null}
|
||||
|
||||
<div className={list.loading ? "opacity-60 transition-opacity" : ""}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Page {list.page} of {list.totalPages}
|
||||
{list.loading ? " · loading…" : ""}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={list.page <= 1 || list.loading}
|
||||
onClick={() => list.setPage(list.page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={list.page >= list.totalPages || list.loading}
|
||||
onClick={() => list.setPage(list.page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Centered empty-state row spanning the table. */
|
||||
export function EmptyRow({ cols, label }: { cols: number; label: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={cols}
|
||||
className="px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "outline" | "ghost" | "destructive";
|
||||
type Size = "sm" | "md";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
outline: "border border-border bg-card text-foreground hover:bg-muted",
|
||||
ghost: "text-foreground hover:bg-muted",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
|
||||
};
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-10 px-4 text-sm",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(function Input({ className, ...props }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-lg border border-input bg-card px-3 text-sm text-foreground",
|
||||
"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(function Textarea({ className, ...props }, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full rounded-lg border border-input bg-card px-3 py-2 text-sm text-foreground",
|
||||
"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** A minimal centered modal dialog. Closes on overlay click and Escape. */
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="size-8 px-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Table({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||
<table className="w-full border-collapse text-sm">{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function THead({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<thead className="border-b border-border bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function TH({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <th className={cn("px-4 py-3 font-medium", className)}>{children}</th>;
|
||||
}
|
||||
|
||||
export function TR({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<tr className={cn("border-b border-border last:border-0 align-top", className)}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TD({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <td className={cn("px-4 py-3 text-foreground", className)}>{children}</td>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({ baseDirectory: __dirname });
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Admin API client. Talks to the same Go BFF as the main app, reusing its
|
||||
* cookie session: auth uses the shared /auth/* endpoints, and every admin
|
||||
* action lives under /admin/* (gated server-side by RequireAdmin). All
|
||||
* requests send credentials so the httpOnly session cookie travels.
|
||||
*
|
||||
* For this to work cross-origin, the BFF must list this app's origin in CORS
|
||||
* via the ADMIN_URL env var (see .env.example).
|
||||
*/
|
||||
|
||||
const BFF = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||
|
||||
export class AdminError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AdminError";
|
||||
}
|
||||
}
|
||||
|
||||
type Envelope<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: number; message: string };
|
||||
meta?: { total?: number; page?: number; limit?: number; total_pages?: number };
|
||||
};
|
||||
|
||||
async function call<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ data: T; meta?: Envelope<T>["meta"] }> {
|
||||
const res = await fetch(`${BFF}/api/v1${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
let body: Envelope<T>;
|
||||
try {
|
||||
body = (await res.json()) as Envelope<T>;
|
||||
} catch {
|
||||
throw new AdminError(res.status, res.statusText || "Request failed");
|
||||
}
|
||||
if (!res.ok || body.success === false) {
|
||||
throw new AdminError(
|
||||
res.status,
|
||||
body.error?.message || res.statusText || "Request failed",
|
||||
);
|
||||
}
|
||||
return { data: body.data as T, meta: body.meta };
|
||||
}
|
||||
|
||||
export type Paged<T> = { items: T[]; total: number };
|
||||
|
||||
async function listed<T>(path: string): Promise<Paged<T>> {
|
||||
const { data, meta } = await call<T[]>(path);
|
||||
const items = data ?? [];
|
||||
return { items, total: meta?.total ?? items.length };
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | undefined>): string {
|
||||
const u = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== "") u.set(k, String(v));
|
||||
}
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ─── Types (mirror the BFF admin DTOs) ──────────────────────────────────────
|
||||
|
||||
export type Me = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
handle: string;
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
plan: string;
|
||||
email_verified_at: string | null;
|
||||
banned_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AdminPost = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
caption: string;
|
||||
source_title: string;
|
||||
source_url: string;
|
||||
is_hidden: boolean;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
author_name: string;
|
||||
author_handle: string;
|
||||
likes_count: number;
|
||||
comments_count: number;
|
||||
};
|
||||
|
||||
export type AdminCollection = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
owner_name: string;
|
||||
owner_handle: string;
|
||||
};
|
||||
|
||||
export type AdminFeedback = {
|
||||
id: string;
|
||||
category: string;
|
||||
message: string;
|
||||
contact_email: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
replied_at: string | null;
|
||||
reply_message: string | null;
|
||||
author_handle: string | null;
|
||||
author_name: string | null;
|
||||
author_email: string | null;
|
||||
};
|
||||
|
||||
export type ReportType = "post" | "comment" | "user";
|
||||
|
||||
export type AdminReport = {
|
||||
id: string;
|
||||
type: ReportType;
|
||||
category: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
reporter_handle: string | null;
|
||||
reporter_name: string | null;
|
||||
target_id: string;
|
||||
target_text: string | null;
|
||||
target_author_handle: string | null;
|
||||
};
|
||||
|
||||
// Login can complete immediately (session) or require an emailed OTP (the
|
||||
// BFF's 30-day-stale gate). The admin login form handles both.
|
||||
export type LoginResult =
|
||||
| { kind: "session"; user: Me }
|
||||
| { kind: "otp_required" };
|
||||
|
||||
// ─── API surface ────────────────────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// Auth (shared endpoints)
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const { data } = await call<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async verifyLoginOtp(
|
||||
email: string,
|
||||
password: string,
|
||||
otp: string,
|
||||
): Promise<Me> {
|
||||
const { data } = await call<{ user: Me }>("/auth/verify-login-otp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, otp }),
|
||||
});
|
||||
return data.user;
|
||||
},
|
||||
async me(): Promise<Me | null> {
|
||||
try {
|
||||
const { data } = await call<Me>("/auth/me");
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (e instanceof AdminError && e.status === 401) return null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
await call("/auth/logout", { method: "POST" });
|
||||
},
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
await call("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
},
|
||||
async resetPassword(token: string, password: string): Promise<void> {
|
||||
await call("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
},
|
||||
|
||||
// Admin resources
|
||||
users(q: string, limit: number, offset: number) {
|
||||
return listed<AdminUser>(`/admin/users${qs({ q, limit, offset })}`);
|
||||
},
|
||||
notifyUser(id: string, title: string, body: string) {
|
||||
return call(`/admin/users/${id}/notify`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body }),
|
||||
});
|
||||
},
|
||||
banUser(id: string) {
|
||||
return call(`/admin/users/${id}/ban`, { method: "POST" });
|
||||
},
|
||||
unbanUser(id: string) {
|
||||
return call(`/admin/users/${id}/ban`, { method: "DELETE" });
|
||||
},
|
||||
posts(q: string, limit: number, offset: number) {
|
||||
return listed<AdminPost>(`/admin/posts${qs({ q, limit, offset })}`);
|
||||
},
|
||||
hidePost(id: string) {
|
||||
return call(`/admin/posts/${id}/hide`, { method: "POST" });
|
||||
},
|
||||
unhidePost(id: string) {
|
||||
return call(`/admin/posts/${id}/hide`, { method: "DELETE" });
|
||||
},
|
||||
deletePost(id: string) {
|
||||
return call(`/admin/posts/${id}`, { method: "DELETE" });
|
||||
},
|
||||
restorePost(id: string) {
|
||||
return call(`/admin/posts/${id}/restore`, { method: "POST" });
|
||||
},
|
||||
deleteComment(id: string) {
|
||||
return call(`/admin/comments/${id}`, { method: "DELETE" });
|
||||
},
|
||||
restoreComment(id: string) {
|
||||
return call(`/admin/comments/${id}/restore`, { method: "POST" });
|
||||
},
|
||||
collections(q: string, limit: number, offset: number) {
|
||||
return listed<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
|
||||
},
|
||||
feedback(status: string, limit: number, offset: number) {
|
||||
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
|
||||
},
|
||||
replyFeedback(id: string, message: string) {
|
||||
return call(`/admin/feedback/${id}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
},
|
||||
reports(type: ReportType, q: string, limit: number, offset: number) {
|
||||
return listed<AdminReport>(`/admin/reports${qs({ type, q, limit, offset })}`);
|
||||
},
|
||||
resolveReport(type: ReportType, id: string) {
|
||||
return call(`/admin/reports/${type}/${id}/resolve`, { method: "POST" });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Paged } from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export type PagedListState<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setPage: (p: number) => void;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared search + offset-pagination logic for the admin list pages. `fetcher`
|
||||
* receives (limit, offset) plus whatever extra deps the caller closes over
|
||||
* (search query, status filter, report type); pass those in `deps` so a change
|
||||
* resets to page 1 and refetches. A request token guards against out-of-order
|
||||
* responses when the user types/pages quickly.
|
||||
*/
|
||||
export function usePagedList<T>(
|
||||
fetcher: (limit: number, offset: number) => Promise<Paged<T>>,
|
||||
deps: unknown[],
|
||||
pageSize = PAGE_SIZE,
|
||||
): PagedListState<T> {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const token = useRef(0);
|
||||
|
||||
// Reset to page 1 whenever the external filters change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => setPage(1), deps);
|
||||
|
||||
const load = useCallback(() => {
|
||||
const my = ++token.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetcher(pageSize, (page - 1) * pageSize)
|
||||
.then((res) => {
|
||||
if (my !== token.current) return;
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (my !== token.current) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (my === token.current) setLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, ...deps]);
|
||||
|
||||
useEffect(() => load(), [load]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
loading,
|
||||
error,
|
||||
setPage,
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp as a short, locale-stable date-time for tables. */
|
||||
export function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// The admin panel renders avatars/post images served from R2/CDN. Allow any
|
||||
// https host since those URLs are admin-trusted content references.
|
||||
images: {
|
||||
remotePatterns: [{ protocol: "https", hostname: "**" }],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "spsays-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user