diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..bc689d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 + + diff --git a/app/admin/backend/page.tsx b/app/admin/backend/page.tsx index 47e7cf6..c6acffa 100644 --- a/app/admin/backend/page.tsx +++ b/app/admin/backend/page.tsx @@ -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 ( - + <>

Backend

@@ -33,6 +32,6 @@ export default async function BackendPage() {
-
+ ); } diff --git a/app/admin/client.tsx b/app/admin/client.tsx new file mode 100644 index 0000000..2f6cb2a --- /dev/null +++ b/app/admin/client.tsx @@ -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(null); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + setTipIndex(Math.floor(Math.random() * tips.length)); + }); + return () => cancelAnimationFrame(frame); + }, []); + + const tip = tipIndex === null ? null : tips[tipIndex]; + + return ( +
+
+

Welcome back!

+ {tip ? ( +
+

{tip.title}

+

{tip.description}

+
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/app/admin/form/page.tsx b/app/admin/form/page.tsx index 7f51316..7487645 100644 --- a/app/admin/form/page.tsx +++ b/app/admin/form/page.tsx @@ -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 ( - + <>
@@ -154,6 +153,6 @@ export default async function AdminFormsPage() { )}
-
+ ); } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 6946c4c..ac82379 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -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 ( + + + + + + +
+
+ {(["core", "integration"] as const).map((category) => ( +
+ {snapshot.services + .filter((service) => service.category === category) + .map((service) => { + const Icon = + iconByService[ + service.id as keyof typeof iconByService + ]; + return ( + + + 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}`} + > + + + + + {service.label}: {service.status} + + + ); + })} +
+ ))} +
+ {summary} +
+
+
+
+
+
+ ); +} + +function AdminHealthStatusSkeleton() { + return ( + + + + + + + + + + + ); +} export default async function AdminLayout({ children, @@ -15,5 +133,15 @@ export default async function AdminLayout({ redirect("/"); } - return <>{children}; + return ( + }> + + + } + > + {children} + + ); } diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index 38710b2..83fbb03 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -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 ( - + <>

Login

@@ -156,6 +155,6 @@ export default function AdminLoginPage() {
-
+ ); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index cdaee15..36534e4 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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 ( - - - {label} - - {formatNumber(value)} - - - - {detail} - - - ); -} - -function QuickAction({ - href, - title, - description, - icon: Icon, - disabled = false, -}: { - href: string; - title: string; - description: string; - icon: typeof ClipboardList; - disabled?: boolean; -}) { - return ( - - - - - {title} - - {description} - - {disabled ? ( - - ) : ( - - )} - - - - ); -} - -export default async function AdminPage() { - const { summary, recentSubmissions, activeForms } = - await getAdminDashboardData(); - const busiestFormResponses = Math.max( - ...activeForms.map((form) => form.responsesLast7Days), - 1, - ); - - return ( - - - -
-
-
-

Command center

-

Dashboard

-

- Live form operations · Bangkok time -

-
-
- -
-
- -
- - - Responses today - - {formatNumber(summary.responsesToday)} - - - Live - - - -

- Submissions received since midnight. Realtime updates keep this - workspace current while you are here. -

-
- - {formatNumber(summary.responsesLast7Days)} this week - - - {formatNumber(summary.openForms)} forms accepting responses - -
-
-
- -
- - - - -
-
- -
- - - Recent responses - - The latest submissions across every form. - - - - - - - {recentSubmissions.length === 0 ? ( -

- Responses will appear here after someone submits a form. -

- ) : ( - <> -
- - - - Respondent - Form - Submitted - Status - - - - {recentSubmissions.map((submission) => ( - - - - {respondentName(submission)} - - - - - {submission.formTitle} - - - - {formatDate(submission.submittedAt)} - - - - {submission.edited ? "Edited" : "Original"} - - - - ))} - -
-
-
- {recentSubmissions.map((submission) => ( - - - - - {respondentName(submission)} - - - - {submission.formTitle} · {formatDate(submission.submittedAt)} - - - - {submission.edited ? "Edited" : "Original"} - - - - - ))} -
- - )} -
-
- - - - Form momentum - - Response activity during the last 7 days. - - - - {activeForms.length === 0 ? ( -

- Create a form to start tracking activity. -

- ) : ( - activeForms.map((form) => ( -
-
- - {form.title} - - - {formatNumber(form.responsesLast7Days)} - -
- -
- - {form.isOpen ? "Open" : "Closed"} - - {formatNumber(form.totalResponses)} total -
-
- )) - )} -
-
-
- -
-
-

Workspace

-

- Go directly to the tools you operate most often. -

-
-
- - - -
-
-
-
- ); +export default function AdminPage() { + return ; } diff --git a/app/admin/upload/layout.tsx b/app/admin/upload/layout.tsx index 483d0b1..11acf1d 100644 --- a/app/admin/upload/layout.tsx +++ b/app/admin/upload/layout.tsx @@ -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 ( - + <>

Creator studio

@@ -21,6 +20,6 @@ export default function AdminUploadLayout({ {children}
-
+ ); } diff --git a/app/layout.tsx b/app/layout.tsx index 70941b0..2a065cf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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: "/", diff --git a/app/share/layout.tsx b/app/share/layout.tsx index 771a37c..aabe8a2 100644 --- a/app/share/layout.tsx +++ b/app/share/layout.tsx @@ -36,18 +36,6 @@ export default async function ShareLayout({ children }: { children: React.ReactN
- - {session?.user && ( {children} diff --git a/components/admin-shell.tsx b/components/admin-shell.tsx index 62c89be..3899821 100644 --- a/components/admin-shell.tsx +++ b/components/admin-shell.tsx @@ -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({ - - - - {NAV_ITEMS.map(({ href, label, icon: Icon, isActive, disabled }) => ( - - {disabled ? ( - - - {label} - - ) : ( - - + {healthStatus} + {NAV_GROUPS.map((group) => ( + + {group.label} + + + {group.items.map(({ href, label, icon: Icon, isActive, disabled }) => ( + + {disabled ? ( + {label} - - - )} - - ))} - - - + + ) : ( + + + + {label} + + + )} + + ))} + + + + ))} diff --git a/lib/admin/admin-dashboard.ts b/lib/admin/admin-dashboard.ts deleted file mode 100644 index 4fab7c1..0000000 --- a/lib/admin/admin-dashboard.ts +++ /dev/null @@ -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 { - 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`count(${submissions.id})::integer`; - const acceptingSubmissions = sql`${forms.isOpen} and ( - ${forms.closesAt} is null or ${forms.closesAt} > ${nowSql}::timestamptz - )`; - const responsesToday = sql`count(${submissions.id}) filter ( - where ${submissions.submittedAt} >= ${todayStartSql}::timestamp - and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp - )::integer`; - const responsesLast7Days = sql`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`count(*)::integer`, - openForms: sql`count(*) filter (where ${acceptingSubmissions})::integer`, - closedForms: sql`count(*) filter (where not (${acceptingSubmissions}))::integer`, - }) - .from(forms), - db - .select({ - totalResponses: sql`count(*)::integer`, - responsesToday: sql`count(*) filter ( - where ${submissions.submittedAt} >= ${todayStartSql}::timestamp - and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp - )::integer`, - responsesLast7Days: sql`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`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, - }; -} diff --git a/lib/admin/service-health-core.test.ts b/lib/admin/service-health-core.test.ts new file mode 100644 index 0000000..c45ffae --- /dev/null +++ b/lib/admin/service-health-core.test.ts @@ -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); + }); +}); diff --git a/lib/admin/service-health-core.ts b/lib/admin/service-health-core.ts new file mode 100644 index 0000000..676936c --- /dev/null +++ b/lib/admin/service-health-core.ts @@ -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; +}; + +export type ServiceHealthSnapshot = { + checkedAt: string; + services: { + id: string; + label: string; + category: ServiceHealthCategory; + status: ServiceHealthStatus; + }[]; +}; + +async function checkWithTimeout(check: () => Promise, timeoutMs: number) { + let timeout: ReturnType | undefined; + + try { + return await Promise.race([ + check(), + new Promise((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 { + 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, + options: { ttlMs?: number; now?: () => number } = {}, +) { + const ttlMs = options.ttlMs ?? 60_000; + const now = options.now ?? Date.now; + let cached: + | { expiresAt: number; value: Promise } + | 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; + }; +} diff --git a/lib/admin/service-health.ts b/lib/admin/service-health.ts new file mode 100644 index 0000000..28a9d6b --- /dev/null +++ b/lib/admin/service-health.ts @@ -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), +); diff --git a/skill/erika/SKILL.md b/skill/erika/SKILL.md index bf3b72e..66087e5 100644 --- a/skill/erika/SKILL.md +++ b/skill/erika/SKILL.md @@ -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., `
`). 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`. diff --git a/skill/erika/references/project.md b/skill/erika/references/project.md index 1318628..274815f 100644 --- a/skill/erika/references/project.md +++ b/skill/erika/references/project.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` | diff --git a/skill/erika/references/routes-and-actions.md b/skill/erika/references/routes-and-actions.md deleted file mode 100644 index 1dd8eac..0000000 --- a/skill/erika/references/routes-and-actions.md +++ /dev/null @@ -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 |