520 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|