From e2f480af62c71a16563fdf09fdc2a41a44b6cb06 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Tue, 30 Jun 2026 22:56:25 +0700 Subject: [PATCH] refactor: consolidate admin authorization into layout and introduce AdminShell component --- app/admin/backend/actions.ts | 78 +++++++++ app/admin/backend/client.tsx | 81 ++++++++++ app/admin/backend/page.tsx | 46 ++++++ app/admin/form/[id]/client.tsx | 2 +- app/admin/form/[id]/extra/client.tsx | 2 +- app/admin/form/[id]/layout.tsx | 13 -- .../form.ts => admin/form/actions.ts} | 1 - app/admin/form/page.tsx | 151 ++++++++---------- app/admin/layout.tsx | 22 +++ app/admin/page.tsx | 56 +++++++ components/admin-shell.tsx | 89 +++++++++++ components/delete-buttons.tsx | 2 +- 12 files changed, 444 insertions(+), 99 deletions(-) create mode 100644 app/admin/backend/actions.ts create mode 100644 app/admin/backend/client.tsx create mode 100644 app/admin/backend/page.tsx rename app/{actions/form.ts => admin/form/actions.ts} (98%) create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 components/admin-shell.tsx diff --git a/app/admin/backend/actions.ts b/app/admin/backend/actions.ts new file mode 100644 index 0000000..6efabff --- /dev/null +++ b/app/admin/backend/actions.ts @@ -0,0 +1,78 @@ +"use server"; + +import { readdirSync } from "fs"; +import { join } from "path"; + +export async function refetchPlatform( + platform: string +): Promise<{ count: number | null }> { + // Validate platform against actual files to prevent path traversal + const dir = join(process.cwd(), "lib", "followers"); + const validPlatforms = readdirSync(dir) + .filter((f) => f.endsWith(".ts") && f !== "index.ts") + .map((f) => f.replace(".ts", "")); + + if (!validPlatforms.includes(platform)) { + throw new Error("Invalid platform"); + } + + let count: number | null = null; + let tiktokProfile = null; + + try { + const mod = await import(`@/lib/followers/${platform}`); + const fns = Object.entries(mod).filter( + ([, v]) => typeof v === "function" + ) as [string, Function][]; + + // Prefer function with "Count" or "Member" in name + const countFn = fns.find(([name]) => /count|member/i.test(name)); + const fn = countFn || fns[0]; + + if (fn) { + const result = await fn[1](); + if (typeof result === "number") { + count = result; + } else if (result === null) { + count = null; + } else if ( + result && + typeof result === "object" && + "followerCount" in result + ) { + count = result.followerCount; + tiktokProfile = result; + } + } + } catch (err) { + console.error(`Failed to fetch ${platform}:`, err); + throw new Error(`Failed to fetch ${platform}`); + } + + // Update Redis cache + try { + const { getRedisClient } = await import("@/lib/redis"); + const redis = await getRedisClient(); + + const cached = await redis.get("erika:follower_counts"); + const counts: Record = cached + ? JSON.parse(cached) + : {}; + counts[platform] = count; + await redis.set("erika:follower_counts", JSON.stringify(counts), { + ex: 3600, + }); + + if (tiktokProfile) { + await redis.set( + "erika:tiktok_profile", + JSON.stringify(tiktokProfile), + { ex: 3600 } + ); + } + } catch { + // Redis unavailable + } + + return { count }; +} diff --git a/app/admin/backend/client.tsx b/app/admin/backend/client.tsx new file mode 100644 index 0000000..ad5e42d --- /dev/null +++ b/app/admin/backend/client.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { RefreshCw } from "lucide-react"; +import { refetchPlatform } from "./actions"; +import { toast } from "sonner"; + +interface Platform { + name: string; + cachedCount: number | null; +} + +function formatCount(n: number | null): string { + if (n === null) return "No data"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toString(); +} + +export function BackendClient({ platforms }: { platforms: Platform[] }) { + const [data, setData] = useState(platforms); + const [loading, setLoading] = useState(null); + + const handleRefetch = async (platform: string) => { + setLoading(platform); + try { + const result = await refetchPlatform(platform); + setData((prev) => + prev.map((p) => + p.name === platform ? { ...p, cachedCount: result.count } : p + ) + ); + toast.success(`${platform} refetched successfully`); + } catch { + toast.error(`Failed to refetch ${platform}`); + } finally { + setLoading(null); + } + }; + + return ( +
+ {data.map((p) => ( + + + + {p.name} + + + {formatCount(p.cachedCount)} + + + + + + + ))} +
+ ); +} diff --git a/app/admin/backend/page.tsx b/app/admin/backend/page.tsx new file mode 100644 index 0000000..e984e36 --- /dev/null +++ b/app/admin/backend/page.tsx @@ -0,0 +1,46 @@ +import { AdminShell } from "@/components/admin-shell"; +import { readdirSync } from "fs"; +import { join } from "path"; +import { BackendClient } from "./client"; + +export const dynamic = "force-dynamic"; + +export default async function BackendPage() { + // Auto-discover platform files from lib/followers/ + const dir = join(process.cwd(), "lib", "followers"); + const platforms = readdirSync(dir) + .filter((f) => f.endsWith(".ts") && f !== "index.ts") + .map((f) => f.replace(".ts", "")); + + // Read current cached counts from Redis + let counts: Record = {}; + try { + const { getRedisClient } = await import("@/lib/redis"); + const redis = await getRedisClient(); + const cached = await redis.get("erika:follower_counts"); + if (cached) { + counts = JSON.parse(cached); + } + } catch { + // Redis unavailable + } + + const platformData = platforms.map((p) => ({ + name: p, + cachedCount: counts[p] ?? null, + })); + + return ( + +
+
+

Backend

+

+ Follower API connections and cache management +

+
+ +
+
+ ); +} diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index 725aab3..70da940 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -14,7 +14,7 @@ import { Separator } from "@/components/ui/separator"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; -import { updateForm } from "@/app/actions/form"; +import { updateForm } from "@/app/admin/form/actions"; import { createQuestion, updateQuestion, diff --git a/app/admin/form/[id]/extra/client.tsx b/app/admin/form/[id]/extra/client.tsx index 399b65d..f526e40 100644 --- a/app/admin/form/[id]/extra/client.tsx +++ b/app/admin/form/[id]/extra/client.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; -import { updateForm } from "@/app/actions/form"; +import { updateForm } from "@/app/admin/form/actions"; import { Loader2, Save } from "lucide-react"; interface FormExtraClientProps { diff --git a/app/admin/form/[id]/layout.tsx b/app/admin/form/[id]/layout.tsx index 164e9c9..8595305 100644 --- a/app/admin/form/[id]/layout.tsx +++ b/app/admin/form/[id]/layout.tsx @@ -1,6 +1,3 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { redirect } from "next/navigation"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AdminSidebar } from "./client"; import { Metadata } from "next"; @@ -19,16 +16,6 @@ export default async function AdminFormLayout( } ) { const params = await props.params; - const session = await getServerSession(authOptions); - const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const discordId = (session?.user as any)?.discordId; - - if (!discordId || !adminIds.includes(discordId)) { - redirect("/form"); - } return ( diff --git a/app/actions/form.ts b/app/admin/form/actions.ts similarity index 98% rename from app/actions/form.ts rename to app/admin/form/actions.ts index b696c54..616e7ac 100644 --- a/app/actions/form.ts +++ b/app/admin/form/actions.ts @@ -51,5 +51,4 @@ export async function deleteForm(id: string) { await db.delete(forms).where(eq(forms.id, id)); revalidatePath("/admin/form"); - redirect("/admin/form"); } diff --git a/app/admin/form/page.tsx b/app/admin/form/page.tsx index b7fa9d5..c7e0936 100644 --- a/app/admin/form/page.tsx +++ b/app/admin/form/page.tsx @@ -1,5 +1,4 @@ import { db } from "@/db"; -import { forms } from "@/db/schema"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { @@ -11,103 +10,91 @@ import { TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus } from "lucide-react"; import { redirect } from "next/navigation"; -import { deleteForm } from "@/app/actions/form"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { DeleteFormButton } from "@/components/delete-buttons"; +import { AdminShell } from "@/components/admin-shell"; export const dynamic = "force-dynamic"; export default async function AdminFormsPage() { - const session = await getServerSession(authOptions); - const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const discordId = (session?.user as any)?.discordId; - - if (!discordId || !adminIds.includes(discordId)) { - redirect("/"); - } const formsList = await db.query.forms.findMany({ orderBy: (f, { desc }) => [desc(f.createdAt)], }); return ( -
-
-
-

Forms

-

- Manage your custom forms and view submissions. -

+ +
+
+
+

Forms

+

+ Manage your custom forms and view submissions. +

+
+
{ + "use server"; + const { forms } = await import("@/db/schema"); + const [newForm] = await db.insert(forms).values({ + title: "New Form", + description: "", + }).returning(); + redirect(`/admin/form/${newForm.id}`); + }}> + +
-
{ - "use server"; - const [newForm] = await db.insert(forms).values({ - title: "New Form", - description: "", - }).returning(); - redirect(`/admin/form/${newForm.id}`); - }}> - -
-
-
- - - - Title - Status - Created - Actions - - - - {formsList.length === 0 ? ( +
+
+ - - No forms found. Create one to get started. - + Title + Status + Created + Actions - ) : ( - formsList.map((form) => ( - - {form.title} - - {form.isOpen ? ( - Open - ) : ( - Closed - )} - - - {form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'} - - - - -
- - +
+ + {formsList.length === 0 ? ( + + + No forms found. Create one to get started. - )) - )} - -
+ ) : ( + formsList.map((form) => ( + + {form.title} + + {form.isOpen ? ( + Open + ) : ( + Closed + )} + + + {form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'} + + + + + + + + )) + )} + + +
-
+ ); } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..b3bb9dd --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,22 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { redirect } from "next/navigation"; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const discordId = (session?.user as any)?.discordId; + + if (!discordId || !adminIds.includes(discordId)) { + redirect("/"); + } + + return <>{children}; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..966e28e --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,56 @@ +import { AdminShell } from "@/components/admin-shell"; +import Link from "next/link"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { ClipboardList, Server } from "lucide-react"; + +export default function AdminPage() { + return ( + +
+
+

Dashboard

+

Admin control panel

+
+ +
+ + + +
+ +
+ Forms + + Manage custom forms and view submissions + +
+
+
+
+ + + + + +
+ +
+ Backend + + Follower API settings and cache management + +
+
+
+
+ +
+
+
+ ); +} diff --git a/components/admin-shell.tsx b/components/admin-shell.tsx new file mode 100644 index 0000000..9f77d2c --- /dev/null +++ b/components/admin-shell.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { + SidebarProvider, + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarHeader, + SidebarInset, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { ClipboardList, Server, LayoutDashboard } from "lucide-react"; + +export function AdminShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + + return ( + +
+ + + + Admin + + + + + + Navigation + + + + + + + Dashboard + + + + + + + + + Form + + + + + + + + + Backend + + + + + + + + + + +
+ +
+ {children} +
+
+
+ ); +} diff --git a/components/delete-buttons.tsx b/components/delete-buttons.tsx index cc36a13..dc5ff48 100644 --- a/components/delete-buttons.tsx +++ b/components/delete-buttons.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Trash2, Loader2 } from "lucide-react"; -import { deleteForm } from "@/app/actions/form"; +import { deleteForm } from "@/app/admin/form/actions"; import { deleteSubmission, deleteAllSubmissions } from "@/app/actions/submissions"; import { toast } from "sonner"; import { useRouter } from "next/navigation";