Files
erika/app/(ui)/admin/upload/client.tsx
T
2026-08-27 14:11:20 +00:00

520 lines
14 KiB
TypeScript

"use client";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
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";
const YOUTUBE_CATEGORIES = [
["1", "Film & Animation"],
["2", "Autos & Vehicles"],
["10", "Music"],
["15", "Pets & Animals"],
["17", "Sports"],
["19", "Travel & Events"],
["20", "Gaming"],
["22", "People & Blogs"],
["23", "Comedy"],
["24", "Entertainment"],
["25", "News & Politics"],
["26", "Howto & Style"],
["27", "Education"],
["28", "Science & Technology"],
] as const;
export function UploadForm() {
const formRef = useRef<HTMLFormElement>(null);
const uploadRequestRef = useRef<XMLHttpRequest | null>(null);
const [, 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: "20",
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>
<Select
value={youtube.categoryId}
onValueChange={(value) =>
setYoutube((state) => ({
...state,
categoryId: value,
}))
}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{YOUTUBE_CATEGORIES.map(([id, label]) => (
<SelectItem key={id} value={id}>
{id} - {label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</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>
<CardFooter className="border-t text-sm text-muted-foreground">
YouTube uploads default to 20 - Gaming. The category dropdown
shows the meaning of every supported ID.
</CardFooter>
</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
>
<Upload data-icon="inline-start" />
Upload feature temporarily disabled
</Button>
</div>
</form>
);
}