feat: simplify admin workspace
This commit is contained in:
@@ -2,4 +2,14 @@
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
|
||||
# File structure
|
||||
|
||||
In components folder should be reuseable file such a like components/tooltip.tsx components/theme-provider.tsx components/theme-toggle.tsx
|
||||
Do not make file that use for 1 purpose, Code it on client.tsx or page.tsx is better
|
||||
|
||||
client.tsx is file where you code that require `use client`; to that
|
||||
app.tsx is file for server side render do not add `use client` to file
|
||||
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AdminShellServer } from "@/components/admin-shell-server";
|
||||
import { BackendClient } from "./client";
|
||||
export const dynamic = "force-dynamic";
|
||||
const platforms = ["youtube", "roblox", "discord", "tiktok"];
|
||||
@@ -23,7 +22,7 @@ export default async function BackendPage() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<AdminShellServer>
|
||||
<>
|
||||
<div className="p-6 space-y-6 max-w-6xl mx-auto w-full">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Backend</h1>
|
||||
@@ -33,6 +32,6 @@ export default async function BackendPage() {
|
||||
</div>
|
||||
<BackendClient platforms={platformData} />
|
||||
</div>
|
||||
</AdminShellServer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const tips = [
|
||||
{
|
||||
title: "Responses update while you work",
|
||||
description:
|
||||
"Open form results can refresh automatically when a new response arrives.",
|
||||
},
|
||||
{
|
||||
title: "Closed forms keep their history",
|
||||
description:
|
||||
"Closing a form stops new responses without removing its existing results.",
|
||||
},
|
||||
{
|
||||
title: "Platform counts can be refreshed",
|
||||
description:
|
||||
"Use Backend when a YouTube, TikTok, Discord, or Roblox count looks stale.",
|
||||
},
|
||||
{
|
||||
title: "Preview before sharing",
|
||||
description:
|
||||
"Every form editor includes a preview of what respondents will see.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AdminWelcome() {
|
||||
const [tipIndex, setTipIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setTipIndex(Math.floor(Math.random() * tips.length));
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
const tip = tipIndex === null ? null : tips[tipIndex];
|
||||
|
||||
return (
|
||||
<main className="flex min-h-[calc(100svh-3rem)] w-full items-center justify-center p-6 md:min-h-svh">
|
||||
<div className="flex max-w-2xl flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight">Welcome back!</h1>
|
||||
{tip ? (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<p className="text-sm font-medium">{tip.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{tip.description}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-1.5 pt-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus } from "lucide-react";
|
||||
import { DeleteFormButton } from "@/components/delete-buttons";
|
||||
import { AdminShellServer } from "@/components/admin-shell-server";
|
||||
import { createDefaultForm } from "./actions";
|
||||
import { RealtimeRefresh } from "@/components/realtime-refresh";
|
||||
import { getFormSubmissionStats } from "@/lib/form/form-submission-stats";
|
||||
@@ -31,7 +30,7 @@ export default async function AdminFormsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminShellServer>
|
||||
<>
|
||||
<RealtimeRefresh topic="forms" />
|
||||
<div className="flex w-full flex-col gap-6 p-4 pb-12 md:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -154,6 +153,6 @@ export default async function AdminFormsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminShellServer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+130
-2
@@ -1,7 +1,125 @@
|
||||
import { Suspense } from "react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth/auth-options";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
Database,
|
||||
MessageCircle,
|
||||
Music2,
|
||||
RadioTower,
|
||||
} from "lucide-react";
|
||||
import { AdminShellServer } from "@/components/admin-shell-server";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth/auth";
|
||||
import { authOptions } from "@/lib/auth/auth-options";
|
||||
import { getAdminServiceHealth } from "@/lib/admin/service-health";
|
||||
import { RobloxIcon, YoutubeIcon } from "@/lib/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const iconByService = {
|
||||
database: Database,
|
||||
redis: RadioTower,
|
||||
youtube: YoutubeIcon,
|
||||
tiktok: Music2,
|
||||
discord: MessageCircle,
|
||||
roblox: RobloxIcon,
|
||||
} as const;
|
||||
|
||||
async function AdminHealthStatus() {
|
||||
const snapshot = await getAdminServiceHealth();
|
||||
const unavailable = snapshot.services.filter(
|
||||
(service) => service.status === "unavailable",
|
||||
).length;
|
||||
const summary = unavailable
|
||||
? `${unavailable} ${unavailable === 1 ? "service" : "services"} unavailable`
|
||||
: "All systems operational";
|
||||
|
||||
return (
|
||||
<SidebarGroup className="pb-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" tooltip={summary} aria-label={summary}>
|
||||
<Activity
|
||||
className={cn(
|
||||
"hidden group-data-[collapsible=icon]:block",
|
||||
unavailable ? "text-destructive" : "text-emerald-500",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
|
||||
<div className="flex flex-col gap-1">
|
||||
{(["core", "integration"] as const).map((category) => (
|
||||
<div key={category} className="flex items-center gap-1">
|
||||
{snapshot.services
|
||||
.filter((service) => service.category === category)
|
||||
.map((service) => {
|
||||
const Icon =
|
||||
iconByService[
|
||||
service.id as keyof typeof iconByService
|
||||
];
|
||||
return (
|
||||
<Tooltip key={service.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex [&>svg]:shrink-0",
|
||||
category === "core"
|
||||
? "[&>svg]:size-5"
|
||||
: "[&>svg]:size-4",
|
||||
service.status === "operational"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive",
|
||||
)}
|
||||
aria-label={`${service.label}: ${service.status}`}
|
||||
>
|
||||
<Icon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{service.label}: {service.status}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="truncate text-xs">{summary}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminHealthStatusSkeleton() {
|
||||
return (
|
||||
<SidebarGroup className="pb-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="flex h-12 items-center gap-2 px-2">
|
||||
<Skeleton className="size-4" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -15,5 +133,15 @@ export default async function AdminLayout({
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<AdminShellServer
|
||||
healthStatus={
|
||||
<Suspense fallback={<AdminHealthStatusSkeleton />}>
|
||||
<AdminHealthStatus />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AdminShellServer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AdminShellServer } from "@/components/admin-shell-server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -105,7 +104,7 @@ export default function AdminLoginPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminShellServer>
|
||||
<>
|
||||
<main className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4 pb-12 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Login</h1>
|
||||
@@ -156,6 +155,6 @@ export default function AdminLoginPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</AdminShellServer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-370
@@ -1,372 +1,5 @@
|
||||
import { createDefaultForm } from "@/app/admin/form/actions";
|
||||
import { AdminShellServer } from "@/components/admin-shell-server";
|
||||
import { RealtimeRefresh } from "@/components/realtime-refresh";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getAdminDashboardData } from "@/lib/admin/admin-dashboard";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ClipboardList,
|
||||
FilePlus2,
|
||||
Server,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AdminWelcome } from "./client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) return "No responses";
|
||||
return date.toLocaleString("en-GB", {
|
||||
timeZone: "Asia/Bangkok",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function respondentName(submission: {
|
||||
userName: string | null;
|
||||
userDiscordId: string | null;
|
||||
}) {
|
||||
return submission.userName || submission.userDiscordId || "Anonymous";
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
detail: string;
|
||||
}) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{formatNumber(value)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{detail}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
disabled = false,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof ClipboardList;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon className="size-5 text-primary" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<CardAction>
|
||||
{disabled ? (
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`${title} disabled`}>
|
||||
<ArrowUpRight />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon-sm" asChild>
|
||||
<Link href={href} aria-label={`Open ${title}`}>
|
||||
<ArrowUpRight />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const { summary, recentSubmissions, activeForms } =
|
||||
await getAdminDashboardData();
|
||||
const busiestFormResponses = Math.max(
|
||||
...activeForms.map((form) => form.responsesLast7Days),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminShellServer>
|
||||
<RealtimeRefresh topic="forms" />
|
||||
<RealtimeRefresh topic="submissions" />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 pb-12 md:p-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Command center</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Live form operations · Bangkok time
|
||||
</p>
|
||||
</div>
|
||||
<form action={createDefaultForm}>
|
||||
<Button type="submit">
|
||||
<FilePlus2 data-icon="inline-start" />
|
||||
Create form
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1.25fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Responses today</CardDescription>
|
||||
<CardTitle className="text-5xl tabular-nums md:text-6xl">
|
||||
{formatNumber(summary.responsesToday)}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge>Live</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p className="max-w-xl text-sm text-muted-foreground">
|
||||
Submissions received since midnight. Realtime updates keep this
|
||||
workspace current while you are here.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{formatNumber(summary.responsesLast7Days)} this week
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{formatNumber(summary.openForms)} forms accepting responses
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
label="Last 7 days"
|
||||
value={summary.responsesLast7Days}
|
||||
detail="Including today"
|
||||
/>
|
||||
<MetricCard
|
||||
label="All responses"
|
||||
value={summary.totalResponses}
|
||||
detail="Lifetime submissions"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Open forms"
|
||||
value={summary.openForms}
|
||||
detail={`${formatNumber(summary.closedForms)} closed`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Total forms"
|
||||
value={summary.totalForms}
|
||||
detail="Across this workspace"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1.6fr)_minmax(20rem,0.9fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent responses</CardTitle>
|
||||
<CardDescription>
|
||||
The latest submissions across every form.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/form">View forms</Link>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentSubmissions.length === 0 ? (
|
||||
<p className="border p-6 text-sm text-muted-foreground">
|
||||
Responses will appear here after someone submits a form.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Respondent</TableHead>
|
||||
<TableHead>Form</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentSubmissions.map((submission) => (
|
||||
<TableRow key={submission.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/form/${submission.formId}/result/${submission.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{respondentName(submission)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/form/${submission.formId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{submission.formTitle}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(submission.submittedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
submission.edited ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{submission.edited ? "Edited" : "Original"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{recentSubmissions.map((submission) => (
|
||||
<Card key={submission.id} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="truncate">
|
||||
<Link
|
||||
href={`/admin/form/${submission.formId}/result/${submission.id}`}
|
||||
>
|
||||
{respondentName(submission)}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{submission.formTitle} · {formatDate(submission.submittedAt)}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge
|
||||
variant={
|
||||
submission.edited ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{submission.edited ? "Edited" : "Original"}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form momentum</CardTitle>
|
||||
<CardDescription>
|
||||
Response activity during the last 7 days.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{activeForms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a form to start tracking activity.
|
||||
</p>
|
||||
) : (
|
||||
activeForms.map((form) => (
|
||||
<div key={form.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={`/admin/form/${form.id}/result`}
|
||||
className="truncate font-medium hover:underline"
|
||||
>
|
||||
{form.title}
|
||||
</Link>
|
||||
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
|
||||
{formatNumber(form.responsesLast7Days)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
(form.responsesLast7Days / busiestFormResponses) * 100
|
||||
}
|
||||
aria-label={`${form.title}: ${form.responsesLast7Days} responses in the last 7 days`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<Badge variant={form.isOpen ? "default" : "secondary"}>
|
||||
{form.isOpen ? "Open" : "Closed"}
|
||||
</Badge>
|
||||
<span>{formatNumber(form.totalResponses)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h2 className="font-heading text-lg font-medium">Workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Go directly to the tools you operate most often.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<QuickAction
|
||||
href="/admin/form"
|
||||
title="Forms"
|
||||
description="Build forms and review submissions."
|
||||
icon={ClipboardList}
|
||||
/>
|
||||
<QuickAction
|
||||
href="/admin/upload"
|
||||
title="Video uploads"
|
||||
description="Publish and monitor platform jobs."
|
||||
icon={Upload}
|
||||
disabled
|
||||
/>
|
||||
<QuickAction
|
||||
href="/admin/backend"
|
||||
title="Backend"
|
||||
description="Manage follower APIs and cache."
|
||||
icon={Server}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</AdminShellServer>
|
||||
);
|
||||
export default function AdminPage() {
|
||||
return <AdminWelcome />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AdminShellServer } from "@/components/admin-shell-server";
|
||||
import { UploadNavigation } from "./upload-navigation";
|
||||
|
||||
export default function AdminUploadLayout({
|
||||
@@ -7,7 +6,7 @@ export default function AdminUploadLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AdminShellServer>
|
||||
<>
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 pb-12 md:p-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Creator studio</p>
|
||||
@@ -21,6 +20,6 @@ export default function AdminUploadLayout({
|
||||
<UploadNavigation />
|
||||
{children}
|
||||
</main>
|
||||
</AdminShellServer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
|
||||
title: "Erika!",
|
||||
description:
|
||||
"แหล่งรวมลิ้งค์ทั้งหมดของช่อง Erika!",
|
||||
keywords: ["Erika", "erika!", "erika jjs", "สุดหล่อ", "Roblox", "Discord", "TikTok", "YouTube", "Linktree", "ลิ้งค์"],
|
||||
keywords: ["Erika", "เอริกะ", "เอริก้า", "สุดหล่อ", "Roblox", "Discord", "TikTok", "YouTube", "Linktree", "ลิ้งค์"],
|
||||
metadataBase: new URL(siteUrl),
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
|
||||
@@ -36,18 +36,6 @@ export default async function ShareLayout({ children }: { children: React.ReactN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/share/all">
|
||||
<List data-icon="inline-start" />
|
||||
ทั้งหมด
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/share">
|
||||
<Plus data-icon="inline-start" />
|
||||
สร้างลิงก์
|
||||
</Link>
|
||||
</Button>
|
||||
{session?.user && (
|
||||
<DropdownMenuAvatar
|
||||
userName={session.user.name ?? ""}
|
||||
|
||||
@@ -4,7 +4,13 @@ import { getSessionDiscordId } from "@/lib/auth/auth";
|
||||
import { getDiscordMemberProfile } from "@/lib/discord/discord";
|
||||
import { AdminShell } from "@/components/admin-shell";
|
||||
|
||||
export async function AdminShellServer({ children }: { children: React.ReactNode }) {
|
||||
export async function AdminShellServer({
|
||||
children,
|
||||
healthStatus,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
healthStatus?: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const discordId = getSessionDiscordId(session);
|
||||
let profile = null;
|
||||
@@ -18,6 +24,7 @@ export async function AdminShellServer({ children }: { children: React.ReactNode
|
||||
userName={session?.user?.name ?? ""}
|
||||
userImage={session?.user?.image ?? ""}
|
||||
profile={profile}
|
||||
healthStatus={healthStatus}
|
||||
>
|
||||
{children}
|
||||
</AdminShell>
|
||||
|
||||
+74
-48
@@ -8,6 +8,7 @@ import {
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
@@ -17,8 +18,8 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
ClipboardList,
|
||||
House,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LucideIcon,
|
||||
Server,
|
||||
Upload,
|
||||
@@ -34,37 +35,52 @@ interface NavItem {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
const NAV_GROUPS: { label: string; items: NavItem[] }[] = [
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
isActive: (pathname) => pathname === "/admin",
|
||||
label: "Workspace",
|
||||
items: [
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Home",
|
||||
icon: House,
|
||||
isActive: (pathname) => pathname === "/admin",
|
||||
},
|
||||
{
|
||||
href: "/admin/form",
|
||||
label: "Forms",
|
||||
icon: ClipboardList,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/form"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/form",
|
||||
label: "Form",
|
||||
icon: ClipboardList,
|
||||
isActive: (pathname) => pathname === "/admin/form",
|
||||
label: "Operations",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/backend",
|
||||
label: "Backend",
|
||||
icon: Server,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/backend"),
|
||||
},
|
||||
{
|
||||
href: "/admin/upload",
|
||||
label: "Upload",
|
||||
icon: Upload,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/upload"),
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/backend",
|
||||
label: "Backend",
|
||||
icon: Server,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/backend"),
|
||||
},
|
||||
{
|
||||
href: "/admin/upload",
|
||||
label: "Upload",
|
||||
icon: Upload,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/upload"),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
href: "/admin/login",
|
||||
label: "Login",
|
||||
icon: KeyRound,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/login"),
|
||||
label: "Platform access",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/login",
|
||||
label: "Login",
|
||||
icon: KeyRound,
|
||||
isActive: (pathname) => pathname.startsWith("/admin/login"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,11 +89,13 @@ export function AdminShell({
|
||||
userName,
|
||||
userImage,
|
||||
profile,
|
||||
healthStatus,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
userName?: string;
|
||||
userImage?: string;
|
||||
profile?: DiscordMemberProfile | null;
|
||||
healthStatus?: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -105,29 +123,37 @@ export function AdminShell({
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, isActive, disabled }) => (
|
||||
<SidebarMenuItem key={href}>
|
||||
{disabled ? (
|
||||
<SidebarMenuButton disabled tooltip={`${label} temporarily disabled`}>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton asChild isActive={isActive(pathname)}>
|
||||
<Link href={href}>
|
||||
{healthStatus}
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map(({ href, label, icon: Icon, isActive, disabled }) => (
|
||||
<SidebarMenuItem key={href}>
|
||||
{disabled ? (
|
||||
<SidebarMenuButton disabled tooltip={`${label} temporarily disabled`}>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(pathname)}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link href={href}>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import { forms, submissions } from "@/db/schema";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { max } from "drizzle-orm/sql/functions/aggregate";
|
||||
|
||||
export type DashboardDateWindows = {
|
||||
todayStart: Date;
|
||||
tomorrowStart: Date;
|
||||
last7DaysStart: Date;
|
||||
};
|
||||
|
||||
export type AdminDashboardData = {
|
||||
summary: {
|
||||
totalForms: number;
|
||||
openForms: number;
|
||||
closedForms: number;
|
||||
totalResponses: number;
|
||||
responsesToday: number;
|
||||
responsesLast7Days: number;
|
||||
};
|
||||
recentSubmissions: {
|
||||
id: string;
|
||||
formId: string;
|
||||
formTitle: string;
|
||||
userName: string | null;
|
||||
userDiscordId: string | null;
|
||||
submittedAt: Date;
|
||||
edited: boolean;
|
||||
}[];
|
||||
activeForms: {
|
||||
id: string;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
totalResponses: number;
|
||||
responsesToday: number;
|
||||
responsesLast7Days: number;
|
||||
lastResponseAt: Date | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function getDashboardDateWindows(now = new Date()): DashboardDateWindows {
|
||||
const bangkokDate = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Bangkok",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(now);
|
||||
const todayStart = new Date(`${bangkokDate}T00:00:00+07:00`);
|
||||
const tomorrowStart = new Date(todayStart);
|
||||
tomorrowStart.setUTCDate(tomorrowStart.getUTCDate() + 1);
|
||||
|
||||
const last7DaysStart = new Date(todayStart);
|
||||
last7DaysStart.setUTCDate(last7DaysStart.getUTCDate() - 6);
|
||||
|
||||
return { todayStart, tomorrowStart, last7DaysStart };
|
||||
}
|
||||
|
||||
function toPostgresTimestamp(date: Date) {
|
||||
return date.toISOString().replace("T", " ").replace("Z", "");
|
||||
}
|
||||
|
||||
export async function getAdminDashboardData(
|
||||
now = new Date()
|
||||
): Promise<AdminDashboardData> {
|
||||
const { todayStart, tomorrowStart, last7DaysStart } =
|
||||
getDashboardDateWindows(now);
|
||||
const nowSql = now.toISOString();
|
||||
const todayStartSql = toPostgresTimestamp(todayStart);
|
||||
const tomorrowStartSql = toPostgresTimestamp(tomorrowStart);
|
||||
const last7DaysStartSql = toPostgresTimestamp(last7DaysStart);
|
||||
const totalResponses = sql<number>`count(${submissions.id})::integer`;
|
||||
const acceptingSubmissions = sql<boolean>`${forms.isOpen} and (
|
||||
${forms.closesAt} is null or ${forms.closesAt} > ${nowSql}::timestamptz
|
||||
)`;
|
||||
const responsesToday = sql<number>`count(${submissions.id}) filter (
|
||||
where ${submissions.submittedAt} >= ${todayStartSql}::timestamp
|
||||
and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
|
||||
)::integer`;
|
||||
const responsesLast7Days = sql<number>`count(${submissions.id}) filter (
|
||||
where ${submissions.submittedAt} >= ${last7DaysStartSql}::timestamp
|
||||
and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
|
||||
)::integer`;
|
||||
|
||||
const [formSummaryRows, responseSummaryRows, recentSubmissions, activeForms] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
totalForms: sql<number>`count(*)::integer`,
|
||||
openForms: sql<number>`count(*) filter (where ${acceptingSubmissions})::integer`,
|
||||
closedForms: sql<number>`count(*) filter (where not (${acceptingSubmissions}))::integer`,
|
||||
})
|
||||
.from(forms),
|
||||
db
|
||||
.select({
|
||||
totalResponses: sql<number>`count(*)::integer`,
|
||||
responsesToday: sql<number>`count(*) filter (
|
||||
where ${submissions.submittedAt} >= ${todayStartSql}::timestamp
|
||||
and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
|
||||
)::integer`,
|
||||
responsesLast7Days: sql<number>`count(*) filter (
|
||||
where ${submissions.submittedAt} >= ${last7DaysStartSql}::timestamp
|
||||
and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp
|
||||
)::integer`,
|
||||
})
|
||||
.from(submissions),
|
||||
db
|
||||
.select({
|
||||
id: submissions.id,
|
||||
formId: submissions.formId,
|
||||
formTitle: forms.title,
|
||||
userName: submissions.userName,
|
||||
userDiscordId: submissions.userDiscordId,
|
||||
submittedAt: submissions.submittedAt,
|
||||
edited: sql<boolean>`jsonb_array_length(coalesce(${submissions.editHistory}, '[]'::jsonb)) > 0`,
|
||||
})
|
||||
.from(submissions)
|
||||
.innerJoin(forms, eq(forms.id, submissions.formId))
|
||||
.orderBy(desc(submissions.submittedAt), desc(submissions.id))
|
||||
.limit(6),
|
||||
db
|
||||
.select({
|
||||
id: forms.id,
|
||||
title: forms.title,
|
||||
isOpen: acceptingSubmissions,
|
||||
totalResponses,
|
||||
responsesToday,
|
||||
responsesLast7Days,
|
||||
lastResponseAt: max(submissions.submittedAt),
|
||||
})
|
||||
.from(forms)
|
||||
.leftJoin(submissions, eq(submissions.formId, forms.id))
|
||||
.groupBy(forms.id, forms.title, forms.isOpen, forms.closesAt, forms.createdAt)
|
||||
.orderBy(
|
||||
desc(responsesLast7Days),
|
||||
desc(totalResponses),
|
||||
desc(forms.createdAt),
|
||||
desc(forms.id)
|
||||
)
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const formSummary = formSummaryRows[0] ?? {
|
||||
totalForms: 0,
|
||||
openForms: 0,
|
||||
closedForms: 0,
|
||||
};
|
||||
const responseSummary = responseSummaryRows[0] ?? {
|
||||
totalResponses: 0,
|
||||
responsesToday: 0,
|
||||
responsesLast7Days: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
...formSummary,
|
||||
...responseSummary,
|
||||
},
|
||||
recentSubmissions,
|
||||
activeForms,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
collectServiceHealth,
|
||||
createCachedHealthReader,
|
||||
type ServiceHealthProbe,
|
||||
type ServiceHealthSnapshot,
|
||||
} from "@/lib/admin/service-health-core";
|
||||
|
||||
function probe(
|
||||
id: string,
|
||||
check: ServiceHealthProbe["check"],
|
||||
): ServiceHealthProbe {
|
||||
return { id, label: id, category: "core", check };
|
||||
}
|
||||
|
||||
describe("admin service health", () => {
|
||||
test("reports healthy and unavailable services without rejecting the snapshot", async () => {
|
||||
const snapshot = await collectServiceHealth(
|
||||
[
|
||||
probe("healthy", async () => true),
|
||||
probe("unhealthy", async () => false),
|
||||
probe("failed", async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
],
|
||||
{ now: () => new Date("2026-08-27T12:00:00.000Z") },
|
||||
);
|
||||
|
||||
expect(snapshot.checkedAt).toBe("2026-08-27T12:00:00.000Z");
|
||||
expect(snapshot.services.map(({ id, status }) => ({ id, status }))).toEqual([
|
||||
{ id: "healthy", status: "operational" },
|
||||
{ id: "unhealthy", status: "unavailable" },
|
||||
{ id: "failed", status: "unavailable" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("marks probes unavailable after the timeout", async () => {
|
||||
const snapshot = await collectServiceHealth(
|
||||
[probe("slow", () => new Promise(() => undefined))],
|
||||
{ timeoutMs: 1 },
|
||||
);
|
||||
|
||||
expect(snapshot.services[0]?.status).toBe("unavailable");
|
||||
});
|
||||
|
||||
test("reuses snapshots until the cache expires", async () => {
|
||||
let currentTime = 0;
|
||||
let loads = 0;
|
||||
const snapshot: ServiceHealthSnapshot = {
|
||||
checkedAt: "2026-08-27T12:00:00.000Z",
|
||||
services: [],
|
||||
};
|
||||
const read = createCachedHealthReader(
|
||||
async () => {
|
||||
loads += 1;
|
||||
return snapshot;
|
||||
},
|
||||
{ ttlMs: 60_000, now: () => currentTime },
|
||||
);
|
||||
|
||||
expect(await read()).toBe(snapshot);
|
||||
expect(await read()).toBe(snapshot);
|
||||
expect(loads).toBe(1);
|
||||
|
||||
currentTime = 60_000;
|
||||
expect(await read()).toBe(snapshot);
|
||||
expect(loads).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export type ServiceHealthCategory = "core" | "integration";
|
||||
export type ServiceHealthStatus = "operational" | "unavailable";
|
||||
|
||||
export type ServiceHealthProbe = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ServiceHealthCategory;
|
||||
check: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
export type ServiceHealthSnapshot = {
|
||||
checkedAt: string;
|
||||
services: {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ServiceHealthCategory;
|
||||
status: ServiceHealthStatus;
|
||||
}[];
|
||||
};
|
||||
|
||||
async function checkWithTimeout(check: () => Promise<boolean>, timeoutMs: number) {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
check(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectServiceHealth(
|
||||
probes: ServiceHealthProbe[],
|
||||
options: { timeoutMs?: number; now?: () => Date } = {},
|
||||
): Promise<ServiceHealthSnapshot> {
|
||||
const timeoutMs = options.timeoutMs ?? 5_000;
|
||||
const checkedAt = (options.now ?? (() => new Date()))().toISOString();
|
||||
const services = await Promise.all(
|
||||
probes.map(async ({ id, label, category, check }) => ({
|
||||
id,
|
||||
label,
|
||||
category,
|
||||
status: (await checkWithTimeout(check, timeoutMs))
|
||||
? ("operational" as const)
|
||||
: ("unavailable" as const),
|
||||
})),
|
||||
);
|
||||
|
||||
return { checkedAt, services };
|
||||
}
|
||||
|
||||
export function createCachedHealthReader(
|
||||
load: () => Promise<ServiceHealthSnapshot>,
|
||||
options: { ttlMs?: number; now?: () => number } = {},
|
||||
) {
|
||||
const ttlMs = options.ttlMs ?? 60_000;
|
||||
const now = options.now ?? Date.now;
|
||||
let cached:
|
||||
| { expiresAt: number; value: Promise<ServiceHealthSnapshot> }
|
||||
| undefined;
|
||||
|
||||
return () => {
|
||||
const currentTime = now();
|
||||
if (cached && currentTime < cached.expiresAt) return cached.value;
|
||||
|
||||
const value = load();
|
||||
cached = { expiresAt: currentTime + ttlMs, value };
|
||||
void value.catch(() => {
|
||||
if (cached?.value === value) cached = undefined;
|
||||
});
|
||||
return value;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { getDiscordMemberCount } from "@/lib/followers/discord";
|
||||
import { getRobloxFollowerCount } from "@/lib/followers/roblox";
|
||||
import { getTiktokProfile } from "@/lib/followers/tiktok";
|
||||
import { getYoutubeSubscriberCount } from "@/lib/followers/youtube";
|
||||
import { getRedisClient } from "@/lib/redis";
|
||||
import {
|
||||
collectServiceHealth,
|
||||
createCachedHealthReader,
|
||||
type ServiceHealthProbe,
|
||||
} from "./service-health-core";
|
||||
|
||||
const probes: ServiceHealthProbe[] = [
|
||||
{
|
||||
id: "database",
|
||||
label: "PostgreSQL",
|
||||
category: "core",
|
||||
check: async () => {
|
||||
await db.execute(sql`select 1`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "redis",
|
||||
label: "Redis / realtime",
|
||||
category: "core",
|
||||
check: async () => (await (await getRedisClient()).ping()) === "PONG",
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
label: "YouTube",
|
||||
category: "integration",
|
||||
check: async () => (await getYoutubeSubscriberCount()) !== null,
|
||||
},
|
||||
{
|
||||
id: "tiktok",
|
||||
label: "TikTok",
|
||||
category: "integration",
|
||||
check: async () => (await getTiktokProfile()).followerCount !== null,
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
category: "integration",
|
||||
check: async () => (await getDiscordMemberCount()) !== null,
|
||||
},
|
||||
{
|
||||
id: "roblox",
|
||||
label: "Roblox",
|
||||
category: "integration",
|
||||
check: async () => (await getRobloxFollowerCount()) !== null,
|
||||
},
|
||||
];
|
||||
|
||||
export const getAdminServiceHealth = createCachedHealthReader(() =>
|
||||
collectServiceHealth(probes),
|
||||
);
|
||||
+3
-49
@@ -65,32 +65,11 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
|
||||
- Build all UI exclusively using ShadCN components.
|
||||
- Rely on the built-in component styling; it's already well-designed.
|
||||
- Do not add custom CSS or excessive utility classes unless absolutely necessary.
|
||||
- Do not add hard-code CSS or excessive utility classes unless absolutely necessary.
|
||||
- Do not use primary colors for everything use it sparingly to highlight important elements. Use secondary colors for less critical elements.
|
||||
- Avoid excessive inline styles on HTML elements (e.g., `<div>`). Keep styling clean and minimal to prevent an "AI slop" appearance.
|
||||
|
||||
## 6. Type Checking
|
||||
|
||||
**Use lightweight tools for validation. Avoid full builds.**
|
||||
|
||||
- Do not run `bun build` or `npm run build` solely to check for type errors.
|
||||
- Use `npx tsc --noEmit` instead for fast and focused type validation.
|
||||
|
||||
## 7. Docker Compose
|
||||
|
||||
**Standardize on Docker Compose. Avoid standalone Docker commands.**
|
||||
|
||||
- Use Docker Compose exclusively for container management.
|
||||
- Map external ports explicitly to the internal application port `3000` (e.g., `"8710:3000"`).
|
||||
- Keep the `docker-compose.yml` file clean and well-organized.
|
||||
|
||||
## 8. Commands
|
||||
|
||||
**Avoid inline code execution.**
|
||||
|
||||
- Do not pass code strings directly to execution tools (e.g., `bun -e 'console.log("hello world")'`).
|
||||
- Always write scripts to dedicated files before executing them.
|
||||
|
||||
## 9. Commit Messages
|
||||
## 6. Commit Messages
|
||||
|
||||
**Use Conventional Commits with a scope.**
|
||||
|
||||
@@ -106,28 +85,6 @@ Examples:
|
||||
|
||||
Prefer common types such as `feat`, `fix`, `test`, `docs`, `refactor`, `chore`, and `style`. Use a concise scope that names the affected area, such as `form`, `auth`, `webhook`, `leaderboard`, or `ui`.
|
||||
|
||||
## 10. Database
|
||||
|
||||
**Use standard tools for external databases.**
|
||||
|
||||
- Assume the use of an external database for persistent storage.
|
||||
- Use Drizzle ORM exclusively for database interactions.
|
||||
|
||||
## 11. Explicit Approval for Plans
|
||||
|
||||
**Achieve alignment before execution.**
|
||||
|
||||
- Ensure the implementation plan is completely clear and mutually understood.
|
||||
- Do not begin executing the plan until explicit approval (e.g., "let's do it") is given by the user.
|
||||
|
||||
## 12. Communication Style
|
||||
|
||||
**Be concise and professional. No fluff.**
|
||||
|
||||
- Keep responses short, direct, and straightforward.
|
||||
- Do not use emojis, unnecessary pleasantries, or filler words.
|
||||
- Maintain a clean and simple text format.
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
@@ -140,6 +97,3 @@ Load only the reference files relevant to the task:
|
||||
| Shared libraries, follower fetchers, and database schema files | `references/libraries-and-data.md` |
|
||||
| App Router routes, server actions, and API endpoints | `references/routes-and-actions.md` |
|
||||
| UI components, config, Docker, and infrastructure files | `references/components-and-infra.md` |
|
||||
| Auth model, access control, and performance-sensitive behavior | `references/behavior.md` |
|
||||
|
||||
Keep detailed project maps in these references rather than expanding `SKILL.md`.
|
||||
|
||||
@@ -15,25 +15,3 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
|
||||
- **UI**: ShadCN components + Tailwind CSS
|
||||
- **Fonts**: Anuphan for Thai, Geist Sans for Latin
|
||||
- **Deployment**: Docker Compose + Nginx reverse proxy
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `NEXTAUTH_SECRET` | Secret for signing NextAuth sessions |
|
||||
| `NEXTAUTH_URL` | Canonical URL of the site, such as `https://erika.sudloh.com` |
|
||||
| `REDIS_URL` | Redis connection string, used for follower-count caching and server-sent events |
|
||||
| `BASE_URL` | Public base URL, used in metadata/SEO and generated links |
|
||||
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Discord OAuth app credentials for user login |
|
||||
| `ADMIN_DISCORD_IDS` | Comma-separated Discord user IDs with admin access |
|
||||
| `CRON_SECRET` | Secret for the automated `/api/cron/tiktok` polling endpoint |
|
||||
| `DISCORD_BOT_TOKEN` | Bot token used to fetch Discord server member count |
|
||||
| `DISCORD_GUILD_ID` | Discord server ID to fetch member count from |
|
||||
| `YOUTUBE_API_KEY` / `YOUTUBE_CHANNEL_ID` | YouTube Data API credentials for subscriber count |
|
||||
| `ROBLOX_USER_ID` | Roblox user ID to fetch follower count |
|
||||
| `TIKTOK_CLIENT_KEY` / `TIKTOK_CLIENT_SECRET` | TikTok Official API credentials |
|
||||
| `TIKTOK_ACCESS_TOKEN` / `TIKTOK_REFRESH_TOKEN` / `TIKTOK_OPEN_ID` | TikTok OAuth tokens from one-time setup at `/api/auth/tiktok` |
|
||||
| `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` | YouTube OAuth application credentials |
|
||||
| `YOUTUBE_REFRESH_TOKEN` | YouTube OAuth refresh token from `/api/auth/youtube` |
|
||||
| `VIDEO_UPLOAD_DIR` | Optional override for the mounted video directory; defaults to `/nfs/erika` |
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Routes And Actions
|
||||
|
||||
## Root And Global
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/layout.tsx` | Root layout with fonts, theme provider, tooltip provider, toaster, page progress, and SEO metadata |
|
||||
| `app/page.tsx` | Linktree landing page with avatar, social links, follower counts, and video background |
|
||||
| `app/globals.css` | Global CSS styles |
|
||||
|
||||
## `/form` User-Facing Forms
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/form/page.tsx` | Lists open forms for users |
|
||||
| `app/form/layout.tsx` | Form layout with header, login state, theme toggle, and login dialog |
|
||||
| `app/form/client.tsx` | Client-side form components and submission UX |
|
||||
| `app/form/[id]/page.tsx` | Individual submission page; enforces Discord role access |
|
||||
| `app/form/[id]/layout.tsx` | Layout for individual form pages |
|
||||
|
||||
## `/admin` Admin Panel
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/admin/layout.tsx` | Centralized admin guard for all `/admin/*` routes |
|
||||
| `app/admin/page.tsx` | Admin dashboard homepage with Form and Backend cards |
|
||||
|
||||
## `/admin/form` Form Management
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/admin/form/page.tsx` | Admin form list with create/edit/delete actions |
|
||||
| `app/admin/form/actions.ts` | Admin-protected form CRUD and settings actions |
|
||||
| `app/admin/form/[id]/layout.tsx` | Form editor layout with form-specific sidebar |
|
||||
| `app/admin/form/[id]/page.tsx` | Admin form editor page |
|
||||
| `app/admin/form/[id]/client.tsx` | Client-side question builder, drag-and-drop, and settings |
|
||||
| `app/admin/form/[id]/result/` | Admin form submission results |
|
||||
| `app/admin/form/[id]/extra/` | Discord webhook templates and role-based access control settings |
|
||||
|
||||
## `/admin/backend` Backend Settings
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/admin/backend/page.tsx` | Backend settings page; auto-discovers `lib/followers/` platform files and reads cached counts |
|
||||
| `app/admin/backend/client.tsx` | Platform cards with cached counts and refetch buttons |
|
||||
| `app/admin/backend/actions.ts` | `refetchPlatform()` dynamically imports fetchers and updates Redis cache |
|
||||
|
||||
## `/discord`
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/discord/page.tsx` | Redirects to the Discord server invite |
|
||||
|
||||
## `/api`
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/api/auth/[...nextauth]/route.ts` | NextAuth.js route with Discord OAuth provider |
|
||||
| `app/api/auth/tiktok/route.ts` | Starts TikTok OAuth setup |
|
||||
| `app/api/auth/tiktok/callback/route.ts` | TikTok OAuth callback handler |
|
||||
| `app/api/cron/tiktok/route.ts` | TikTok polling endpoint; checks TikWM and notifies Discord |
|
||||
| `app/api/upload/route.ts` | Admin-only file upload endpoint |
|
||||
|
||||
## `app/actions/` Server Actions
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/actions/discord.ts` | Admin-protected Discord role loading |
|
||||
| `app/actions/questions.ts` | Admin-protected question CRUD within a form |
|
||||
| `app/actions/submissions.ts` | Form submission, update, deletion, and webhook notification actions |
|
||||
Reference in New Issue
Block a user