refactor(upload): split admin sections into routes

This commit is contained in:
2026-08-02 16:58:13 +07:00 Unverified
parent d513b47d09
commit e8e52daf95
7 changed files with 266 additions and 231 deletions
+5 -5
View File
@@ -1,19 +1,19 @@
"use server";
import { requireAdmin } from "@/lib/auth";
import { publishVideoJob } from "@/lib/video-platforms";
import { requireAdmin } from "@/lib/auth/auth";
import { publishVideoJob } from "@/lib/video/video-platforms";
import { revalidatePath } from "next/cache";
export async function createVideoUpload(formData: FormData) {
const discordId = await requireAdmin();
const { saveVideoUpload } = await import("@/lib/video-upload-storage");
const { saveVideoUpload } = await import("@/lib/video/video-upload-storage");
const id = await saveVideoUpload(formData, discordId);
revalidatePath("/admin/upload");
revalidatePath("/admin/upload/history");
return { id };
}
export async function retryVideoJob(jobId: string) {
await requireAdmin();
await publishVideoJob(jobId).catch(() => undefined);
revalidatePath("/admin/upload");
revalidatePath("/admin/upload/history");
}
+96
View File
@@ -0,0 +1,96 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { db } from "@/db";
import { videoUploads } from "@/db/schema";
import { desc } from "drizzle-orm";
import { retryVideoJob } from "../actions";
export const dynamic = "force-dynamic";
function statusVariant(status: string) {
return status === "completed"
? "default"
: status === "failed"
? "destructive"
: "secondary";
}
export default async function AdminUploadHistoryPage() {
const uploads = await db.query.videoUploads.findMany({
orderBy: [desc(videoUploads.createdAt)],
limit: 20,
});
const allJobs = uploads.length
? await db.query.videoUploadJobs.findMany({
where: (table, { inArray }) =>
inArray(
table.uploadId,
uploads.map((upload) => upload.id),
),
})
: [];
const jobsByUpload = new Map<string, typeof allJobs>();
for (const job of allJobs)
jobsByUpload.set(job.uploadId, [
...(jobsByUpload.get(job.uploadId) ?? []),
job,
]);
return (
<Card>
<CardHeader>
<CardTitle>Upload history</CardTitle>
<CardDescription>Scheduled and recent publishing jobs.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{uploads.length === 0 ? (
<p className="text-sm text-muted-foreground">No uploads yet.</p>
) : (
uploads.map((upload) => (
<div
key={upload.id}
className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<p className="truncate font-medium">{upload.title}</p>
<p className="text-sm text-muted-foreground">
{upload.scheduledAt
? `Scheduled ${upload.scheduledAt.toLocaleString("en-GB", { timeZone: "Asia/Bangkok" })}`
: upload.createdAt.toLocaleString("en-GB", {
timeZone: "Asia/Bangkok",
})}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(upload.status)}>
{upload.status}
</Badge>
{(jobsByUpload.get(upload.id) ?? []).map((job) => (
<span key={job.id} className="flex items-center gap-1">
<Badge variant={statusVariant(job.status)}>
{job.platform}: {job.status}
</Badge>
{job.status === "failed" && (
<form action={retryVideoJob.bind(null, job.id)}>
<Button type="submit" size="sm" variant="outline">
Retry
</Button>
</form>
)}
</span>
))}
</div>
</div>
))
)}
</CardContent>
</Card>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { AdminShellServer } from "@/components/admin-shell-server";
import { UploadNavigation } from "./upload-navigation";
export default function AdminUploadLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AdminShellServer>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 pb-12 md:p-6">
<div>
<p className="text-sm font-medium text-primary">Creator studio</p>
<h1 className="text-3xl font-bold tracking-tight">
Upload everywhere
</h1>
<p className="mt-1 text-muted-foreground">
Prepare one video, then publish it to YouTube and TikTok together.
</p>
</div>
<UploadNavigation />
{children}
</main>
</AdminShellServer>
);
}
+2 -226
View File
@@ -1,229 +1,5 @@
import { AdminShellServer } from "@/components/admin-shell-server";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getLatestTiktokVideos,
getLatestYoutubeVideos,
} from "@/lib/video-platforms";
import { db } from "@/db";
import { videoUploads } from "@/db/schema";
import { desc } from "drizzle-orm";
import {
Clapperboard,
ExternalLink,
History,
PlaySquare,
UploadCloud,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { UploadForm } from "./client";
import { retryVideoJob } from "./actions";
export const dynamic = "force-dynamic";
function statusVariant(status: string) {
return status === "completed"
? "default"
: status === "failed"
? "destructive"
: "secondary";
}
export default async function AdminUploadPage() {
const [youtube, tiktok, uploads] = await Promise.all([
getLatestYoutubeVideos().catch(() => []),
getLatestTiktokVideos().catch(() => []),
db.query.videoUploads.findMany({
orderBy: [desc(videoUploads.createdAt)],
limit: 20,
}),
]);
const allJobs = uploads.length
? await db.query.videoUploadJobs.findMany({
where: (table, { inArray }) =>
inArray(
table.uploadId,
uploads.map((upload) => upload.id),
),
})
: [];
const jobsByUpload = new Map<string, typeof allJobs>();
for (const job of allJobs)
jobsByUpload.set(job.uploadId, [
...(jobsByUpload.get(job.uploadId) ?? []),
job,
]);
return (
<AdminShellServer>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 pb-12 md:p-6">
<div>
<p className="text-sm font-medium text-primary">Creator studio</p>
<h1 className="text-3xl font-bold tracking-tight">
Upload everywhere
</h1>
<p className="mt-1 text-muted-foreground">
Prepare one video, then publish it to YouTube and TikTok together.
</p>
</div>
<Tabs defaultValue="upload" className="w-full">
<TabsList className="grid w-full grid-cols-3 md:w-fit">
<TabsTrigger value="upload">
<UploadCloud data-icon="inline-start" />
Upload
</TabsTrigger>
<TabsTrigger value="recent">
<Clapperboard data-icon="inline-start" />
Recent videos
</TabsTrigger>
<TabsTrigger value="history">
<History data-icon="inline-start" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="upload">
<UploadForm />
</TabsContent>
<TabsContent value="recent">
<section className="grid gap-4 xl:grid-cols-2">
<LatestCard title="Latest YouTube videos" videos={youtube} />
<LatestCard title="Latest TikTok videos" videos={tiktok} />
</section>
</TabsContent>
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>Upload history</CardTitle>
<CardDescription>
Scheduled and recent publishing jobs.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{uploads.length === 0 ? (
<p className="text-sm text-muted-foreground">
No uploads yet.
</p>
) : (
uploads.map((upload) => (
<div
key={upload.id}
className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<p className="truncate font-medium">{upload.title}</p>
<p className="text-sm text-muted-foreground">
{upload.scheduledAt
? `Scheduled ${upload.scheduledAt.toLocaleString("en-GB", { timeZone: "Asia/Bangkok" })}`
: upload.createdAt.toLocaleString("en-GB", {
timeZone: "Asia/Bangkok",
})}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(upload.status)}>
{upload.status}
</Badge>
{(jobsByUpload.get(upload.id) ?? []).map((job) => (
<span
key={job.id}
className="flex items-center gap-1"
>
<Badge variant={statusVariant(job.status)}>
{job.platform}: {job.status}
</Badge>
{job.status === "failed" && (
<form action={retryVideoJob.bind(null, job.id)}>
<Button
type="submit"
size="sm"
variant="outline"
>
Retry
</Button>
</form>
)}
</span>
))}
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</AdminShellServer>
);
}
function LatestCard({
title,
videos,
}: {
title: string;
videos: Array<{
id: string;
title: string;
thumbnailUrl: string;
url: string;
platform: "youtube" | "tiktok";
}>;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>Most recent 10 published videos.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
{videos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Connect the platform API to load videos.
</p>
) : (
videos.map((video) => (
<Link
key={video.id}
href={video.url}
target="_blank"
className="group flex gap-3 rounded-lg border p-2 transition-colors hover:border-primary/60"
>
<div className="relative aspect-square w-28 shrink-0 overflow-hidden rounded-md bg-muted">
{video.thumbnailUrl ? (
<Image
src={video.thumbnailUrl}
alt=""
fill
sizes="112px"
unoptimized={video.platform === "tiktok"}
referrerPolicy="no-referrer"
className="object-cover"
/>
) : (
<PlaySquare className="absolute inset-0 m-auto text-muted-foreground" />
)}
</div>
<span className="min-w-0 text-sm font-medium leading-snug group-hover:underline">
{video.title}
</span>
<ExternalLink className="mt-0.5 shrink-0 text-muted-foreground" />
</Link>
))
)}
</CardContent>
</Card>
);
export default function AdminUploadPage() {
return <UploadForm />;
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { Button } from "@/components/ui/button";
import { Clapperboard, History, UploadCloud } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
const items = [
{
href: "/admin/upload",
label: "Upload",
icon: UploadCloud,
},
{
href: "/admin/upload/video",
label: "Recent videos",
icon: Clapperboard,
},
{
href: "/admin/upload/history",
label: "History",
icon: History,
},
] as const;
export function UploadNavigation() {
const pathname = usePathname();
return (
<nav
aria-label="Upload sections"
className="grid w-full grid-cols-3 gap-2 md:w-fit"
>
{items.map(({ href, label, icon: Icon }) => {
const active = pathname === href;
return (
<Button
key={href}
asChild
variant={active ? "default" : "outline"}
>
<Link href={href} aria-current={active ? "page" : undefined}>
<Icon data-icon="inline-start" />
{label}
</Link>
</Button>
);
})}
</nav>
);
}
+64
View File
@@ -0,0 +1,64 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { LatestVideo } from "@/lib/video/video-platforms";
import { ExternalLink, PlaySquare } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
export function LatestCard({
title,
videos,
}: {
title: string;
videos: LatestVideo[];
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>Most recent 10 published videos.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
{videos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Connect the platform API to load videos.
</p>
) : (
videos.map((video) => (
<Link
key={video.id}
href={video.url}
target="_blank"
className="group flex gap-3 rounded-lg border p-2 transition-colors hover:border-primary/60"
>
<div className="relative aspect-square w-28 shrink-0 overflow-hidden rounded-md bg-muted">
{video.thumbnailUrl ? (
<Image
src={video.thumbnailUrl}
alt=""
fill
sizes="112px"
unoptimized
referrerPolicy="no-referrer"
className="object-cover"
/>
) : (
<PlaySquare className="absolute inset-0 m-auto text-muted-foreground" />
)}
</div>
<span className="min-w-0 text-sm font-medium leading-snug group-hover:underline">
{video.title}
</span>
<ExternalLink className="mt-0.5 shrink-0 text-muted-foreground" />
</Link>
))
)}
</CardContent>
</Card>
);
}
+21
View File
@@ -0,0 +1,21 @@
import {
getLatestTiktokVideos,
getLatestYoutubeVideos,
} from "@/lib/video/video-platforms";
import { LatestCard } from "./latest-card";
export const dynamic = "force-dynamic";
export default async function AdminUploadVideoPage() {
const [youtube, tiktok] = await Promise.all([
getLatestYoutubeVideos().catch(() => []),
getLatestTiktokVideos().catch(() => []),
]);
return (
<section className="grid gap-4 xl:grid-cols-2">
<LatestCard title="Latest YouTube videos" videos={youtube} />
<LatestCard title="Latest TikTok videos" videos={tiktok} />
</section>
);
}