Files
erika/app/admin/upload/page.tsx
T

230 lines
6.4 KiB
TypeScript

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>
);
}