feat: admin dashboard
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user