feat: implement admin video upload system with platform integration for YouTube and TikTok
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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<HTMLFormElement>(null);
|
||||
const uploadRequestRef = useRef<XMLHttpRequest | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [scheduledAt, setScheduledAt] = useState<Date>();
|
||||
const [video, setVideo] = useState<File | null>(null);
|
||||
const [stagedVideoId, setStagedVideoId] = useState<string | null>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={submit}
|
||||
className="grid items-start gap-4 md:grid-cols-2"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New upload</CardTitle>
|
||||
<CardDescription>One source file, two platform jobs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="video">Video file</FieldLabel>
|
||||
<Input
|
||||
id="video"
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
required
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) uploadVideo(file);
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{video
|
||||
? `${video.name} · ${(video.size / 1024 / 1024).toFixed(1)} MB`
|
||||
: "MP4, MOV, or WebM. No application size limit."}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{uploadStatus !== "idle" && (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<FieldLabel>Background upload</FieldLabel>
|
||||
<span className="text-muted-foreground">
|
||||
{uploadStatus === "failed"
|
||||
? "Failed"
|
||||
: `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} />
|
||||
<FieldDescription>
|
||||
{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."}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="thumbnail">YouTube thumbnail</FieldLabel>
|
||||
<Input
|
||||
id="thumbnail"
|
||||
name="thumbnail"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
/>
|
||||
<FieldDescription>
|
||||
TikTok uses the video frame selected below.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="title">Shared title / caption</FieldLabel>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="Give this video a title"
|
||||
required
|
||||
maxLength={2200}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="description">YouTube description</FieldLabel>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={7}
|
||||
placeholder="Tell viewers about this video"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="scheduledAt">Publish date</FieldLabel>
|
||||
<DateTimePicker
|
||||
id="scheduledAt"
|
||||
value={scheduledAt}
|
||||
onChange={setScheduledAt}
|
||||
placeholder="Publish immediately"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Leave empty to publish as soon as both platforms accept the
|
||||
upload.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>YouTube</CardTitle>
|
||||
<CardDescription>
|
||||
Platform-specific publishing settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>Visibility</FieldLabel>
|
||||
<Select
|
||||
value={youtube.privacyStatus}
|
||||
onValueChange={(value) =>
|
||||
setYoutube((state) => ({
|
||||
...state,
|
||||
privacyStatus: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="public">Public</SelectItem>
|
||||
<SelectItem value="unlisted">Unlisted</SelectItem>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="category">Category ID</FieldLabel>
|
||||
<Input
|
||||
id="category"
|
||||
value={youtube.categoryId}
|
||||
onChange={(event) =>
|
||||
setYoutube((state) => ({
|
||||
...state,
|
||||
categoryId: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tags">Tags</FieldLabel>
|
||||
<Input
|
||||
id="tags"
|
||||
value={youtube.tags}
|
||||
onChange={(event) =>
|
||||
setYoutube((state) => ({
|
||||
...state,
|
||||
tags: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="gaming, erika, shorts"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
checked={youtube.madeForKids}
|
||||
onCheckedChange={(checked) =>
|
||||
setYoutube((state) => ({
|
||||
...state,
|
||||
madeForKids: checked === true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Made for kids</FieldLabel>
|
||||
</Field>
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
checked={youtube.containsSyntheticMedia}
|
||||
onCheckedChange={(checked) =>
|
||||
setYoutube((state) => ({
|
||||
...state,
|
||||
containsSyntheticMedia: checked === true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Contains altered or synthetic media</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TikTok</CardTitle>
|
||||
<CardDescription>Direct Post controls.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>Privacy</FieldLabel>
|
||||
<Select
|
||||
value={tiktok.privacyLevel}
|
||||
onValueChange={(value) =>
|
||||
setTiktok((state) => ({
|
||||
...state,
|
||||
privacyLevel: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="PUBLIC_TO_EVERYONE">
|
||||
Everyone
|
||||
</SelectItem>
|
||||
<SelectItem value="FOLLOWER_OF_CREATOR">
|
||||
Followers
|
||||
</SelectItem>
|
||||
<SelectItem value="MUTUAL_FOLLOW_FRIENDS">
|
||||
Friends
|
||||
</SelectItem>
|
||||
<SelectItem value="SELF_ONLY">Only me</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cover">
|
||||
Cover frame (milliseconds)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="cover"
|
||||
type="number"
|
||||
min="0"
|
||||
value={tiktok.coverTimestampMs}
|
||||
onChange={(event) =>
|
||||
setTiktok((state) => ({
|
||||
...state,
|
||||
coverTimestampMs: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Engagement</FieldLegend>
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={!tiktok.disableComment}
|
||||
onCheckedChange={(checked) =>
|
||||
setTiktok((state) => ({
|
||||
...state,
|
||||
disableComment: !checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Allow comments</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={!tiktok.disableDuet}
|
||||
onCheckedChange={(checked) =>
|
||||
setTiktok((state) => ({
|
||||
...state,
|
||||
disableDuet: !checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Allow Duet</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={!tiktok.disableStitch}
|
||||
onCheckedChange={(checked) =>
|
||||
setTiktok((state) => ({
|
||||
...state,
|
||||
disableStitch: !checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Allow Stitch</FieldLabel>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
checked={tiktok.isAigc}
|
||||
onCheckedChange={(checked) =>
|
||||
setTiktok((state) => ({
|
||||
...state,
|
||||
isAigc: checked === true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<FieldLabel>AI-generated content</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={pending || uploadStatus !== "completed"}
|
||||
>
|
||||
{pending ? (
|
||||
<CalendarClock data-icon="inline-start" />
|
||||
) : (
|
||||
<Upload data-icon="inline-start" />
|
||||
)}
|
||||
{pending
|
||||
? "Sending…"
|
||||
: scheduledAt
|
||||
? "Schedule both uploads"
|
||||
: "Upload to both platforms"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, unlink } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VIDEO_DIR = process.env.VIDEO_UPLOAD_DIR || "/nfs/erika";
|
||||
const VIDEO_TYPES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-180) || "video";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let filePath: string | null = null;
|
||||
|
||||
try {
|
||||
const discordId = await requireAdmin();
|
||||
const encodedName = request.headers.get("x-file-name");
|
||||
const mimeType = request.headers.get("content-type") ?? "";
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
|
||||
if (!encodedName || !request.body) {
|
||||
return NextResponse.json(
|
||||
{ error: "No video provided." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!VIDEO_TYPES.has(mimeType)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Use an MP4, MOV, or WebM video." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const originalFilename = decodeURIComponent(encodedName);
|
||||
const stagedVideoId = randomUUID();
|
||||
const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`;
|
||||
filePath = `${stagingDirectory}/${stagedVideoId}-${safeFileName(originalFilename)}`;
|
||||
|
||||
await mkdir(stagingDirectory, { recursive: true });
|
||||
await pipeline(
|
||||
Readable.fromWeb(request.body as never),
|
||||
createWriteStream(filePath, { flags: "wx" }),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
stagedVideoId,
|
||||
originalFilename,
|
||||
mimeType,
|
||||
size: contentLength,
|
||||
});
|
||||
} catch (error) {
|
||||
if (filePath) await unlink(filePath).catch(() => undefined);
|
||||
const message = error instanceof Error ? error.message : "Upload failed.";
|
||||
const status =
|
||||
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { saveVideoUpload } from "@/lib/video-upload-storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const discordId = await requireAdmin();
|
||||
const id = await saveVideoUpload(await request.formData(), discordId);
|
||||
return NextResponse.json({ id });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed.";
|
||||
const status =
|
||||
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ function escapeHtml(value: unknown) {
|
||||
* Displays the tokens so you can copy them to your .env file.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
@@ -11,7 +11,7 @@ const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state";
|
||||
* Visit this URL once to start the OAuth flow and get your refresh token.
|
||||
*/
|
||||
export async function GET() {
|
||||
|
||||
await requireAdmin();
|
||||
|
||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||
const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`;
|
||||
@@ -27,7 +27,7 @@ export async function GET() {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_key: clientKey,
|
||||
scope: "user.info.basic,user.info.profile,user.info.stats,video.list",
|
||||
scope: "user.info.basic,user.info.profile,user.info.stats,video.list,video.publish",
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
state: csrfState,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
await requireAdmin();
|
||||
const params = new URL(request.url).searchParams;
|
||||
const cookieStore = await cookies();
|
||||
const expected = cookieStore.get("youtube-oauth-state")?.value;
|
||||
cookieStore.delete("youtube-oauth-state");
|
||||
if (!expected || expected !== params.get("state"))
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid OAuth state." },
|
||||
{ status: 400 },
|
||||
);
|
||||
const code = params.get("code");
|
||||
if (!code)
|
||||
return NextResponse.json(
|
||||
{ error: "No authorization code received." },
|
||||
{ status: 400 },
|
||||
);
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: process.env.YOUTUBE_CLIENT_ID ?? "",
|
||||
client_secret: process.env.YOUTUBE_CLIENT_SECRET ?? "",
|
||||
redirect_uri: `${process.env.BASE_URL}/api/auth/youtube/callback`,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
});
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok || !data.refresh_token)
|
||||
return NextResponse.json(
|
||||
{ error: "YouTube token exchange failed." },
|
||||
{ status: 400 },
|
||||
);
|
||||
const html = `<!doctype html><html><body style="font-family:system-ui;max-width:720px;margin:40px auto;padding:20px"><h1>YouTube OAuth success</h1><p>Copy these values into your server environment:</p><pre>YOUTUBE_REFRESH_TOKEN=${escapeHtml(data.refresh_token)}\nYOUTUBE_ACCESS_TOKEN=${escapeHtml(data.access_token)}</pre><p>Restart the app after saving them.</p></body></html>`;
|
||||
return new NextResponse(html, { headers: { "Content-Type": "text/html" } });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
|
||||
const COOKIE = "youtube-oauth-state";
|
||||
|
||||
export async function GET() {
|
||||
await requireAdmin();
|
||||
const clientId = process.env.YOUTUBE_CLIENT_ID;
|
||||
const redirectUri = `${process.env.BASE_URL}/api/auth/youtube/callback`;
|
||||
if (!clientId)
|
||||
return NextResponse.json(
|
||||
{ error: "YOUTUBE_CLIENT_ID is not configured." },
|
||||
{ status: 500 },
|
||||
);
|
||||
const state = randomBytes(32).toString("hex");
|
||||
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
url.search = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
scope: "https://www.googleapis.com/auth/youtube.upload",
|
||||
state,
|
||||
}).toString();
|
||||
const response = NextResponse.redirect(url);
|
||||
(await cookies()).set(COOKIE, state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 600,
|
||||
path: "/api/auth/youtube",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { processDueVideoUploads } from "@/lib/video-platforms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authorization = request.headers.get("authorization");
|
||||
if (
|
||||
!process.env.CRON_SECRET ||
|
||||
authorization !== `Bearer ${process.env.CRON_SECRET}`
|
||||
)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
await processDueVideoUploads();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user