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