refactor: consolidate admin authorization into layout and introduce AdminShell component

This commit is contained in:
2026-06-30 22:56:25 +07:00 Unverified
parent 848f93641f
commit e2f480af62
12 changed files with 444 additions and 99 deletions
+78
View File
@@ -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 };
}
+81
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}