refactor: consolidate admin authorization into layout and introduce AdminShell component
This commit is contained in:
@@ -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<string, number | null> = 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 };
|
||||
}
|
||||
@@ -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<Platform[]>(platforms);
|
||||
const [loading, setLoading] = useState<string | null>(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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{data.map((p) => (
|
||||
<Card key={p.name}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium capitalize">
|
||||
{p.name}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={p.cachedCount !== null ? "default" : "secondary"}
|
||||
>
|
||||
{formatCount(p.cachedCount)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleRefetch(p.name)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3 h-3 ${loading === p.name ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refetch
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number | null> = {};
|
||||
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 (
|
||||
<AdminShell>
|
||||
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Backend</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Follower API connections and cache management
|
||||
</p>
|
||||
</div>
|
||||
<BackendClient platforms={platformData} />
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<SidebarProvider>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+69
-82
@@ -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 (
|
||||
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-6 pb-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage your custom forms and view submissions.
|
||||
</p>
|
||||
<AdminShell>
|
||||
<div className="space-y-6 p-4 md:p-6 pb-12 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage your custom forms and view submissions.
|
||||
</p>
|
||||
</div>
|
||||
<form action={async () => {
|
||||
"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}`);
|
||||
}}>
|
||||
<Button type="submit">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Form
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<form action={async () => {
|
||||
"use server";
|
||||
const [newForm] = await db.insert(forms).values({
|
||||
title: "New Form",
|
||||
description: "",
|
||||
}).returning();
|
||||
redirect(`/admin/form/${newForm.id}`);
|
||||
}}>
|
||||
<Button type="submit">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Form
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formsList.length === 0 ? (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
|
||||
No forms found. Create one to get started.
|
||||
</TableCell>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
formsList.map((form) => (
|
||||
<TableRow key={form.id}>
|
||||
<TableCell className="font-medium">{form.title}</TableCell>
|
||||
<TableCell>
|
||||
{form.isOpen ? (
|
||||
<Badge variant="default">Open</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Closed</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button variant="outline" asChild size="sm">
|
||||
<Link href={`/admin/form/${form.id}`}>Edit</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild size="sm">
|
||||
<Link href={`/admin/form/${form.id}/result`}>Results</Link>
|
||||
</Button>
|
||||
<form className="inline-block" action={deleteForm.bind(null, form.id)}>
|
||||
<Button variant="outline" size="sm" type="submit" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formsList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
|
||||
No forms found. Create one to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
formsList.map((form) => (
|
||||
<TableRow key={form.id}>
|
||||
<TableCell className="font-medium">{form.title}</TableCell>
|
||||
<TableCell>
|
||||
{form.isOpen ? (
|
||||
<Badge variant="default">Open</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Closed</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{form.createdAt ? new Date(form.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button variant="outline" asChild size="sm">
|
||||
<Link href={`/admin/form/${form.id}`}>Edit</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild size="sm">
|
||||
<Link href={`/admin/form/${form.id}/result`}>Results</Link>
|
||||
</Button>
|
||||
<DeleteFormButton formId={form.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
<AdminShell>
|
||||
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Admin control panel</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Link href="/admin/form">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle>Forms</CardTitle>
|
||||
<CardDescription>
|
||||
Manage custom forms and view submissions
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/backend">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle>Backend</CardTitle>
|
||||
<CardDescription>
|
||||
Follower API settings and cache management
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SidebarProvider>
|
||||
<div className="flex w-full min-h-screen bg-background text-foreground">
|
||||
<Sidebar variant="sidebar" collapsible="icon">
|
||||
<SidebarHeader className="border-b border-border py-4 px-4">
|
||||
<span className="font-bold tracking-wider text-primary text-sm uppercase">
|
||||
Admin
|
||||
</span>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === "/admin"}
|
||||
>
|
||||
<Link href="/admin">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === "/admin/form"}
|
||||
>
|
||||
<Link href="/admin/form">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
<span>Form</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname.startsWith("/admin/backend")}
|
||||
>
|
||||
<Link href="/admin/backend">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>Backend</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<div className="md:hidden p-2">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user