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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateForm } from "@/app/actions/form";
|
import { updateForm } from "@/app/admin/form/actions";
|
||||||
import {
|
import {
|
||||||
createQuestion,
|
createQuestion,
|
||||||
updateQuestion,
|
updateQuestion,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateForm } from "@/app/actions/form";
|
import { updateForm } from "@/app/admin/form/actions";
|
||||||
import { Loader2, Save } from "lucide-react";
|
import { Loader2, Save } from "lucide-react";
|
||||||
|
|
||||||
interface FormExtraClientProps {
|
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 { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { AdminSidebar } from "./client";
|
import { AdminSidebar } from "./client";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
@@ -19,16 +16,6 @@ export default async function AdminFormLayout(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const params = await props.params;
|
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 (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
|||||||
@@ -51,5 +51,4 @@ export async function deleteForm(id: string) {
|
|||||||
|
|
||||||
await db.delete(forms).where(eq(forms.id, id));
|
await db.delete(forms).where(eq(forms.id, id));
|
||||||
revalidatePath("/admin/form");
|
revalidatePath("/admin/form");
|
||||||
redirect("/admin/form");
|
|
||||||
}
|
}
|
||||||
+69
-82
@@ -1,5 +1,4 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { forms } from "@/db/schema";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -11,103 +10,91 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { deleteForm } from "@/app/actions/form";
|
import { DeleteFormButton } from "@/components/delete-buttons";
|
||||||
import { getServerSession } from "next-auth";
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminFormsPage() {
|
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({
|
const formsList = await db.query.forms.findMany({
|
||||||
orderBy: (f, { desc }) => [desc(f.createdAt)],
|
orderBy: (f, { desc }) => [desc(f.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-6 pb-12">
|
<AdminShell>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-6 p-4 md:p-6 pb-12 w-full">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
|
<div>
|
||||||
<p className="text-muted-foreground mt-1">
|
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
|
||||||
Manage your custom forms and view submissions.
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
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>
|
</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">
|
<div className="border rounded-md">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Created</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{formsList.length === 0 ? (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
|
<TableHead>Title</TableHead>
|
||||||
No forms found. Create one to get started.
|
<TableHead>Status</TableHead>
|
||||||
</TableCell>
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
</TableHeader>
|
||||||
formsList.map((form) => (
|
<TableBody>
|
||||||
<TableRow key={form.id}>
|
{formsList.length === 0 ? (
|
||||||
<TableCell className="font-medium">{form.title}</TableCell>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
|
||||||
{form.isOpen ? (
|
No forms found. Create one to get started.
|
||||||
<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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
) : (
|
||||||
)}
|
formsList.map((form) => (
|
||||||
</TableBody>
|
<TableRow key={form.id}>
|
||||||
</Table>
|
<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>
|
||||||
</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 { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2, Loader2 } from "lucide-react";
|
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 { deleteSubmission, deleteAllSubmissions } from "@/app/actions/submissions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
Reference in New Issue
Block a user