From 5342123fba42227b0469791bcbb0e5372a40a4df Mon Sep 17 00:00:00 2001 From: gosangam Date: Tue, 16 Jun 2026 15:49:23 +0530 Subject: [PATCH] fix: ui fixes --- app/(dashboard)/feedback/page.tsx | 77 +++++++++++++++++++-- app/(dashboard)/layout.tsx | 2 + app/(dashboard)/page.tsx | 109 ++++++++++++++++++++++++++++-- app/(dashboard)/users/page.tsx | 59 +++++++++------- app/login/page.tsx | 2 +- components/ui/avatar.tsx | 49 ++++++++++++++ components/ui/menu.tsx | 98 +++++++++++++++++++++++++++ lib/api.ts | 26 +++++++ 8 files changed, 388 insertions(+), 34 deletions(-) create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/menu.tsx diff --git a/app/(dashboard)/feedback/page.tsx b/app/(dashboard)/feedback/page.tsx index 3f8a33a..e180624 100644 --- a/app/(dashboard)/feedback/page.tsx +++ b/app/(dashboard)/feedback/page.tsx @@ -8,6 +8,7 @@ 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 { Textarea } from "@/components/ui/input"; import { cn, fmtDate } from "@/lib/utils"; @@ -21,8 +22,43 @@ export default function FeedbackPage() { [status], ); const list = usePagedList(fetcher, [status]); + const confirm = useConfirm(); const [reply, setReply] = useState(null); + async function run(action: Promise, successMsg: string) { + try { + await action; + toast.success(successMsg); + list.refresh(); + } catch (err) { + toast.error(err instanceof AdminError ? err.message : "Action failed."); + } + } + + async function acknowledge(f: AdminFeedback) { + if ( + await confirm({ + title: "Acknowledge this feedback?", + message: "The author will be emailed that we've seen it and started work.", + confirmLabel: "Acknowledge", + }) + ) { + run(api.acknowledgeFeedback(f.id), "Feedback acknowledged."); + } + } + + async function resolve(f: AdminFeedback) { + if ( + await confirm({ + title: "Mark resolved?", + message: "The author will be emailed a thank-you. This can't be undone.", + confirmLabel: "Mark resolved", + }) + ) { + run(api.resolveFeedback(f.id), "Feedback resolved."); + } + } + return ( {fmtDate(f.created_at)} - +
+ {!f.resolved_at && !f.acknowledged_at ? ( + + ) : null} + {!f.resolved_at ? ( + + ) : null} + +
)) @@ -117,8 +173,19 @@ export default function FeedbackPage() { } 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"; + const label = f.replied_at + ? "Replied" + : f.resolved_at + ? "Resolved" + : f.acknowledged_at + ? "Acknowledged" + : "Open"; + const tone = + f.replied_at || f.resolved_at + ? "text-primary" + : f.acknowledged_at + ? "text-amber-600" + : "text-muted-foreground"; return {label}; } diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 1dc280d..1c9b665 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter } from "next/navigation"; import { Flag, FolderClosed, + LayoutDashboard, LayoutGrid, LogOut, MessageSquare, @@ -17,6 +18,7 @@ import { Button } from "@/components/ui/button"; import { ConfirmProvider } from "@/components/confirm"; const NAV = [ + { href: "/", label: "Overview", icon: LayoutDashboard }, { href: "/users", label: "Users", icon: Users }, { href: "/posts", label: "Posts", icon: LayoutGrid }, { href: "/collections", label: "Collections", icon: FolderClosed }, diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 6780eab..4e0dc3d 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -1,6 +1,107 @@ -import { redirect } from "next/navigation"; +"use client"; -// The dashboard root just lands on Users. -export default function DashboardIndex() { - redirect("/users"); +import { useEffect, useState } from "react"; +import { api, AdminError, type Stats } from "@/lib/api"; + +export default function OverviewPage() { + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .stats() + .then((s) => { + if (!cancelled) setStats(s); + }) + .catch((e) => { + if (!cancelled) + setError(e instanceof AdminError ? e.message : "Could not load stats."); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

Overview

+ + {error ? ( +

{error}

+ ) : !stats ? ( +

Loading…

+ ) : ( + <> +
+ + + + + + +
+ +
+

+ Feedback by category +

+ {stats.feedback_by_category.length === 0 ? ( +

No feedback yet.

+ ) : ( +
+ {stats.feedback_by_category.map((c) => ( +
+ + {c.category.replace(/_/g, " ")} + + + {c.count} + +
+ ))} +
+ )} +
+ + )} +
+ ); +} + +function StatCard({ + label, + value, + delta, +}: { + label: string; + value: number; + delta?: string; +}) { + return ( +
+
+ {label} +
+
+ + {value.toLocaleString()} + + {delta ? ( + {delta} + ) : null} +
+
+ ); } diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 751efb1..2d4f2e8 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -8,6 +8,8 @@ import { ListShell, EmptyRow } from "@/components/list-shell"; import { Table, THead, TR, TH, TD } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Modal } from "@/components/ui/modal"; +import { RowMenu, type MenuItem } from "@/components/ui/menu"; +import { Avatar } from "@/components/ui/avatar"; import { useConfirm } from "@/components/confirm"; import { Input, Textarea } from "@/components/ui/input"; import { fmtDate } from "@/lib/utils"; @@ -44,6 +46,21 @@ export default function UsersPage() { } } + function menuItems(u: AdminUser): MenuItem[] { + const items: MenuItem[] = [ + { label: "Send notification", onClick: () => setNotify(u) }, + ]; + // No ban option for fellow admins (avoid locking each other out). + if (u.role !== "admin") { + items.push({ + label: u.banned_at ? "Unban account" : "Ban account", + onClick: () => toggleBan(u), + destructive: !u.banned_at, + }); + } + return items; + } + return ( User Email - Role + Posts Joined Actions @@ -69,36 +86,30 @@ export default function UsersPage() { list.items.map((u) => ( -
- {u.name} - {u.banned_at ? ( - - Suspended - - ) : null} +
+ +
+
+ {u.name} + {u.banned_at ? ( + + Suspended + + ) : null} +
+
+ @{u.handle} +
+
-
@{u.handle}
{u.email ?? —} - {u.role} + {u.post_count} {fmtDate(u.created_at)} -
- - {u.role !== "admin" ? ( - - ) : null} -
+ )) diff --git a/app/login/page.tsx b/app/login/page.tsx index 62b7b03..8a81a49 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -22,7 +22,7 @@ export default function LoginPage() { // state against the admin origin. async function gateAdmin(user: Me) { if (user.role === "admin") { - router.replace("/users"); + router.replace("/"); return; } await api.logout().catch(() => {}); diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..a5126b9 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +import { cn } from "@/lib/utils"; + +function initials(name: string): string { + return ( + name + .split(/\s+/) + .filter(Boolean) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?" + ); +} + +/** Small round avatar — the user's image, or their initials as a fallback. */ +export function Avatar({ + src, + name, + className, +}: { + src: string | null; + name: string; + className?: string; +}) { + if (src) { + return ( + // eslint-disable-next-line @next/next/no-img-element -- arbitrary R2/CDN + // hosts; next/image optimization isn't worth it for tiny admin avatars. + {name} + ); + } + return ( +
+ {initials(name)} +
+ ); +} diff --git a/components/ui/menu.tsx b/components/ui/menu.tsx new file mode 100644 index 0000000..273582c --- /dev/null +++ b/components/ui/menu.tsx @@ -0,0 +1,98 @@ +"use client"; + +import * as React from "react"; +import { MoreVertical } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type MenuItem = { + label: string; + onClick: () => void; + destructive?: boolean; +}; + +/** + * A kebab (⋯) trigger with a small dropdown of actions — keeps secondary / + * destructive row actions hidden until opened. The menu is fixed-positioned + * from the trigger so the table's overflow container can't clip it; it closes + * on outside-click, Escape, or scroll. + */ +export function RowMenu({ items }: { items: MenuItem[] }) { + const [open, setOpen] = React.useState(false); + const [pos, setPos] = React.useState<{ top: number; right: number } | null>( + null, + ); + const btnRef = React.useRef(null); + const menuRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + function onDown(e: MouseEvent) { + const t = e.target as Node; + if (btnRef.current?.contains(t) || menuRef.current?.contains(t)) return; + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + function onScroll() { + setOpen(false); + } + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + window.addEventListener("scroll", onScroll, true); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + window.removeEventListener("scroll", onScroll, true); + }; + }, [open]); + + function toggle() { + if (!open && btnRef.current) { + const r = btnRef.current.getBoundingClientRect(); + setPos({ top: r.bottom + 4, right: window.innerWidth - r.right }); + } + setOpen((v) => !v); + } + + return ( + <> + + {open && pos ? ( +
+ {items.map((it, i) => ( + + ))} +
+ ) : null} + + ); +} diff --git a/lib/api.ts b/lib/api.ts index ff429bd..ff9d81f 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -78,6 +78,20 @@ export type Me = { role: string; }; +export type Stats = { + users: number; + users_last_7_days: number; + posts: number; + posts_last_7_days: number; + collections: number; + public_collections: number; + bookmarks: number; + reshares: number; + feedback: number; + feedback_last_7_days: number; + feedback_by_category: { category: string; count: number }[]; +}; + export type AdminUser = { id: string; email: string | null; @@ -88,6 +102,7 @@ export type AdminUser = { plan: string; email_verified_at: string | null; banned_at: string | null; + post_count: number; created_at: string; }; @@ -124,6 +139,7 @@ export type AdminFeedback = { message: string; contact_email: string | null; created_at: string; + acknowledged_at: string | null; resolved_at: string | null; replied_at: string | null; reply_message: string | null; @@ -207,6 +223,10 @@ export const api = { }, // Admin resources + async stats(): Promise { + const { data } = await call("/admin/stats"); + return data; + }, users(q: string, limit: number, offset: number) { return listed(`/admin/users${qs({ q, limit, offset })}`); }, @@ -255,6 +275,12 @@ export const api = { body: JSON.stringify({ message }), }); }, + acknowledgeFeedback(id: string) { + return call(`/admin/feedback/${id}/acknowledge`, { method: "POST" }); + }, + resolveFeedback(id: string) { + return call(`/admin/feedback/${id}/resolve`, { method: "POST" }); + }, reports(type: ReportType, q: string, limit: number, offset: number) { return listed(`/admin/reports${qs({ type, q, limit, offset })}`); },