From 698b12039aec404a596337df0f32d174787b07c7 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 2 Aug 2026 02:07:35 +0700 Subject: [PATCH] feat: implement admin video upload system with platform integration for YouTube and TikTok --- .gitignore | 9 - README.md | 2 + app/admin/upload/actions.ts | 19 + app/admin/upload/client.tsx | 493 +++++++++++++++++++++++++ app/admin/upload/page.tsx | 229 ++++++++++++ app/api/admin/video-file/route.ts | 64 ++++ app/api/admin/video-upload/route.ts | 18 + app/api/auth/tiktok/callback/route.ts | 1 + app/api/auth/tiktok/route.ts | 4 +- app/api/auth/youtube/callback/route.ts | 50 +++ app/api/auth/youtube/route.ts | 37 ++ app/api/cron/video-uploads/route.ts | 15 + components/admin-shell.tsx | 14 +- db/schema.ts | 1 + db/schema/video.ts | 117 ++++++ docker-compose.yml | 2 + docs/youtube-api-setup-th.md | 137 +++++++ drizzle/meta/_journal.json | 9 +- lib/video-platforms.ts | 389 +++++++++++++++++++ lib/video-upload-storage.ts | 125 +++++++ nginx.conf | 3 +- skill/erika/references/project.md | 3 + 22 files changed, 1727 insertions(+), 14 deletions(-) create mode 100644 app/admin/upload/actions.ts create mode 100644 app/admin/upload/client.tsx create mode 100644 app/admin/upload/page.tsx create mode 100644 app/api/admin/video-file/route.ts create mode 100644 app/api/admin/video-upload/route.ts create mode 100644 app/api/auth/youtube/callback/route.ts create mode 100644 app/api/auth/youtube/route.ts create mode 100644 app/api/cron/video-uploads/route.ts create mode 100644 db/schema/video.ts create mode 100644 docs/youtube-api-setup-th.md create mode 100644 lib/video-platforms.ts create mode 100644 lib/video-upload-storage.ts diff --git a/.gitignore b/.gitignore index af29cf4..3bef6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -42,13 +42,4 @@ next-env.d.ts public/form drizzle/* -!drizzle/0010_rainy_speedball.sql -!drizzle/0012_clammy_the_stranger.sql -!drizzle/0013_regular_unus.sql -!drizzle/0014_lethal_pepper_potts.sql -!drizzle/0015_form_closes_at.sql -!drizzle/0016_wide_talos.sql -!drizzle/meta/ drizzle/meta/* -!drizzle/meta/0015_snapshot.json -!drizzle/meta/0016_snapshot.json diff --git a/README.md b/README.md index 3992ead..66a9a00 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ bun run secrets:scan - `/leaderboard/vc` — voice activity leaderboard - `/sse/[topic]` — authenticated realtime updates - `/api/upload` — authenticated image uploads stored in PostgreSQL +- `/admin/upload` — admin video publishing workspace for YouTube and TikTok Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser. @@ -78,4 +79,5 @@ The production image is built in separate dependency, build, and runner stages. - Form access checks allowed and denied Discord roles. - Uploaded image contents are checked against their declared image type before storage. - TikTok OAuth is a private setup utility and requires an admin session plus verified OAuth state. +- Video files are stored under `/nfs/erika`; configure `VIDEO_UPLOAD_DIR` only if the mounted path differs. - Rotate credentials if an environment file, build cache, logs, or deployment host may have been exposed. diff --git a/app/admin/upload/actions.ts b/app/admin/upload/actions.ts new file mode 100644 index 0000000..149eeea --- /dev/null +++ b/app/admin/upload/actions.ts @@ -0,0 +1,19 @@ +"use server"; + +import { requireAdmin } from "@/lib/auth"; +import { publishVideoJob } from "@/lib/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 id = await saveVideoUpload(formData, discordId); + revalidatePath("/admin/upload"); + return { id }; +} + +export async function retryVideoJob(jobId: string) { + await requireAdmin(); + await publishVideoJob(jobId).catch(() => undefined); + revalidatePath("/admin/upload"); +} diff --git a/app/admin/upload/client.tsx b/app/admin/upload/client.tsx new file mode 100644 index 0000000..4440b05 --- /dev/null +++ b/app/admin/upload/client.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { CalendarClock, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DateTimePicker } from "@/components/date-time-picker"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; + +export function UploadForm() { + const formRef = useRef(null); + const uploadRequestRef = useRef(null); + const [pending, startTransition] = useTransition(); + const [scheduledAt, setScheduledAt] = useState(); + const [video, setVideo] = useState(null); + const [stagedVideoId, setStagedVideoId] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStatus, setUploadStatus] = useState< + "idle" | "uploading" | "completed" | "failed" + >("idle"); + const [youtube, setYoutube] = useState({ + privacyStatus: "public", + categoryId: "22", + tags: "", + madeForKids: false, + containsSyntheticMedia: false, + }); + const [tiktok, setTiktok] = useState({ + privacyLevel: "PUBLIC_TO_EVERYONE", + disableComment: false, + disableDuet: false, + disableStitch: false, + coverTimestampMs: 0, + isAigc: false, + }); + + function uploadVideo(file: File) { + uploadRequestRef.current?.abort(); + setVideo(file); + setStagedVideoId(null); + setUploadProgress(0); + setUploadStatus("uploading"); + + const request = new XMLHttpRequest(); + uploadRequestRef.current = request; + request.open("POST", "/api/admin/video-file"); + request.setRequestHeader("Content-Type", file.type); + request.setRequestHeader("X-File-Name", encodeURIComponent(file.name)); + request.upload.onprogress = (event) => { + if (event.lengthComputable) { + setUploadProgress(Math.round((event.loaded / event.total) * 100)); + } + }; + request.onload = () => { + try { + const data = JSON.parse(request.responseText) as { + stagedVideoId?: string; + error?: string; + }; + if ( + request.status < 200 || + request.status >= 300 || + !data.stagedVideoId + ) { + throw new Error(data.error || "Video upload failed."); + } + setStagedVideoId(data.stagedVideoId); + setUploadProgress(100); + setUploadStatus("completed"); + toast.success("Video uploaded. You can finish the metadata now."); + } catch (error) { + setUploadStatus("failed"); + toast.error( + error instanceof Error ? error.message : "Video upload failed.", + ); + } + }; + request.onerror = () => { + setUploadStatus("failed"); + toast.error( + "Video upload failed because the connection was interrupted.", + ); + }; + request.send(file); + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + + const form = new FormData(event.currentTarget); + form.delete("video"); + form.set("stagedVideoId", stagedVideoId ?? ""); + form.set("videoName", video?.name ?? ""); + form.set("videoMimeType", video?.type ?? ""); + form.set("scheduledAt", scheduledAt?.toISOString() ?? ""); + form.set( + "youtubeSettings", + JSON.stringify({ + ...youtube, + tags: youtube.tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + }), + ); + form.set( + "tiktokSettings", + JSON.stringify({ + ...tiktok, + coverTimestampMs: Number(tiktok.coverTimestampMs) || 0, + }), + ); + + startTransition(async () => { + try { + const response = await fetch("/api/admin/video-upload", { + method: "POST", + body: form, + }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Upload failed."); + } + + toast.success(scheduledAt ? "Upload scheduled." : "Upload started."); + formRef.current?.reset(); + setVideo(null); + setStagedVideoId(null); + setUploadProgress(0); + setUploadStatus("idle"); + setScheduledAt(undefined); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Upload failed."); + } + }); + } + + return ( +
+ + + New upload + One source file, two platform jobs. + + + + + Video file + { + const file = event.target.files?.[0]; + if (file) uploadVideo(file); + }} + /> + + {video + ? `${video.name} · ${(video.size / 1024 / 1024).toFixed(1)} MB` + : "MP4, MOV, or WebM. No application size limit."} + + + + {uploadStatus !== "idle" && ( + +
+ Background upload + + {uploadStatus === "failed" + ? "Failed" + : `${uploadProgress}%`} + +
+ + + {uploadStatus === "completed" + ? "Upload complete. The file is ready to publish." + : uploadStatus === "failed" + ? "Choose the file again to retry." + : "You can continue filling in metadata while this runs."} + +
+ )} + + + YouTube thumbnail + + + TikTok uses the video frame selected below. + + + + + Shared title / caption + + + + + YouTube description +