refactor(upload): split admin sections into routes
This commit is contained in:
@@ -1,19 +1,19 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { requireAdmin } from "@/lib/auth";
|
import { requireAdmin } from "@/lib/auth/auth";
|
||||||
import { publishVideoJob } from "@/lib/video-platforms";
|
import { publishVideoJob } from "@/lib/video/video-platforms";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
export async function createVideoUpload(formData: FormData) {
|
export async function createVideoUpload(formData: FormData) {
|
||||||
const discordId = await requireAdmin();
|
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);
|
const id = await saveVideoUpload(formData, discordId);
|
||||||
revalidatePath("/admin/upload");
|
revalidatePath("/admin/upload/history");
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryVideoJob(jobId: string) {
|
export async function retryVideoJob(jobId: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
await publishVideoJob(jobId).catch(() => undefined);
|
await publishVideoJob(jobId).catch(() => undefined);
|
||||||
revalidatePath("/admin/upload");
|
revalidatePath("/admin/upload/history");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 { UploadForm } from "./client";
|
||||||
import { retryVideoJob } from "./actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default function AdminUploadPage() {
|
||||||
|
return <UploadForm />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user