diff --git a/app/admin/upload/actions.ts b/app/admin/upload/actions.ts index 149eeea..868c8ee 100644 --- a/app/admin/upload/actions.ts +++ b/app/admin/upload/actions.ts @@ -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"); } diff --git a/app/admin/upload/history/page.tsx b/app/admin/upload/history/page.tsx new file mode 100644 index 0000000..72840b4 --- /dev/null +++ b/app/admin/upload/history/page.tsx @@ -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(); + for (const job of allJobs) + jobsByUpload.set(job.uploadId, [ + ...(jobsByUpload.get(job.uploadId) ?? []), + job, + ]); + + return ( + + + Upload history + Scheduled and recent publishing jobs. + + + {uploads.length === 0 ? ( +

No uploads yet.

+ ) : ( + uploads.map((upload) => ( +
+
+

{upload.title}

+

+ {upload.scheduledAt + ? `Scheduled ${upload.scheduledAt.toLocaleString("en-GB", { timeZone: "Asia/Bangkok" })}` + : upload.createdAt.toLocaleString("en-GB", { + timeZone: "Asia/Bangkok", + })} +

+
+
+ + {upload.status} + + {(jobsByUpload.get(upload.id) ?? []).map((job) => ( + + + {job.platform}: {job.status} + + {job.status === "failed" && ( +
+ +
+ )} +
+ ))} +
+
+ )) + )} +
+
+ ); +} diff --git a/app/admin/upload/layout.tsx b/app/admin/upload/layout.tsx new file mode 100644 index 0000000..483d0b1 --- /dev/null +++ b/app/admin/upload/layout.tsx @@ -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 ( + +
+
+

Creator studio

+

+ Upload everywhere +

+

+ Prepare one video, then publish it to YouTube and TikTok together. +

+
+ + {children} +
+
+ ); +} diff --git a/app/admin/upload/page.tsx b/app/admin/upload/page.tsx index cbbd89f..030bb50 100644 --- a/app/admin/upload/page.tsx +++ b/app/admin/upload/page.tsx @@ -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(); - for (const job of allJobs) - jobsByUpload.set(job.uploadId, [ - ...(jobsByUpload.get(job.uploadId) ?? []), - job, - ]); - - return ( - -
-
-

Creator studio

-

- Upload everywhere -

-

- Prepare one video, then publish it to YouTube and TikTok together. -

-
- - - - - Upload - - - - Recent videos - - - - History - - - - - - - - -
- - -
-
- - - - - Upload history - - Scheduled and recent publishing jobs. - - - - {uploads.length === 0 ? ( -

- No uploads yet. -

- ) : ( - uploads.map((upload) => ( -
-
-

{upload.title}

-

- {upload.scheduledAt - ? `Scheduled ${upload.scheduledAt.toLocaleString("en-GB", { timeZone: "Asia/Bangkok" })}` - : upload.createdAt.toLocaleString("en-GB", { - timeZone: "Asia/Bangkok", - })} -

-
-
- - {upload.status} - - {(jobsByUpload.get(upload.id) ?? []).map((job) => ( - - - {job.platform}: {job.status} - - {job.status === "failed" && ( -
- -
- )} -
- ))} -
-
- )) - )} -
-
-
-
-
-
- ); -} - -function LatestCard({ - title, - videos, -}: { - title: string; - videos: Array<{ - id: string; - title: string; - thumbnailUrl: string; - url: string; - platform: "youtube" | "tiktok"; - }>; -}) { - return ( - - - {title} - Most recent 10 published videos. - - - {videos.length === 0 ? ( -

- Connect the platform API to load videos. -

- ) : ( - videos.map((video) => ( - -
- {video.thumbnailUrl ? ( - - ) : ( - - )} -
- - {video.title} - - - - )) - )} -
-
- ); +export default function AdminUploadPage() { + return ; } diff --git a/app/admin/upload/upload-navigation.tsx b/app/admin/upload/upload-navigation.tsx new file mode 100644 index 0000000..b0e9304 --- /dev/null +++ b/app/admin/upload/upload-navigation.tsx @@ -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 ( + + ); +} diff --git a/app/admin/upload/video/latest-card.tsx b/app/admin/upload/video/latest-card.tsx new file mode 100644 index 0000000..66e55e7 --- /dev/null +++ b/app/admin/upload/video/latest-card.tsx @@ -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 ( + + + {title} + Most recent 10 published videos. + + + {videos.length === 0 ? ( +

+ Connect the platform API to load videos. +

+ ) : ( + videos.map((video) => ( + +
+ {video.thumbnailUrl ? ( + + ) : ( + + )} +
+ + {video.title} + + + + )) + )} +
+
+ ); +} diff --git a/app/admin/upload/video/page.tsx b/app/admin/upload/video/page.tsx new file mode 100644 index 0000000..31c1018 --- /dev/null +++ b/app/admin/upload/video/page.tsx @@ -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 ( +
+ + +
+ ); +}