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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user