"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Plan, Privacy } from "@prisma/client"; import { filenameWithoutExtension } from "@/lib/constants"; import { audioTagsToMetadata } from "@/lib/audio-tags"; import type { ItemMetadata } from "@/lib/types"; import { CategorySelect } from "./CategorySelect"; import { PlaylistSelect } from "./PlaylistSelect"; import { PrivacyToggle } from "./PrivacyToggle"; import { ResolutionSelect } from "./ResolutionSelect"; import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink"; const UPLOAD_CONCURRENCY = 4; type AudioItem = { id: string; file: File; path: string | null; uploading: boolean; metadata: ItemMetadata; }; function defaultMetadata(title = ""): ItemMetadata { return { title, description: "", tags: "", privacy: "PUBLIC" as Privacy, categoryId: "10", resolution: "1280x720", notifySubscribers: true, madeForKids: false, embeddable: true, creativeCommons: false, includeWatermark: true, playlistId: null, }; } export function UploadForm() { const router = useRouter(); const uploadSessionRef = useRef(crypto.randomUUID()); const [imageFile, setImageFile] = useState(null); const [imagePath, setImagePath] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [audioItems, setAudioItems] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [playlistId, setPlaylistId] = useState(""); const [quota, setQuota] = useState<{ remaining: number; limit: number; plan: Plan; maxBatchSize: number; resetsIn: string; videoCredits: number; totalAvailable: number; used: number; selfHosted?: boolean; } | null>(null); const loadQuota = useCallback(async () => { const res = await fetch("/api/quota"); if (res.ok) { const data = await res.json(); setQuota({ remaining: data.remaining, limit: data.limit, plan: data.plan, maxBatchSize: data.maxBatchSize, resetsIn: data.resetsIn, videoCredits: data.videoCredits ?? 0, totalAvailable: data.totalAvailable ?? data.remaining, used: data.used ?? 0, selfHosted: Boolean(data.selfHosted), }); } }, []); useEffect(() => { loadQuota(); }, [loadQuota]); async function uploadFile( file: File, type: "image" | "audio", ): Promise<{ path: string; audioTags?: Parameters[0] | null }> { const formData = new FormData(); formData.append("file", file); formData.append("type", type); formData.append("session", uploadSessionRef.current); const res = await fetch("/api/upload", { method: "POST", body: formData }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Upload failed"); } const data = await res.json(); return { path: data.path, audioTags: data.audioTags }; } async function uploadFilesWithConcurrency(files: File[]) { const pending = files.map((file) => { const id = crypto.randomUUID(); const autoTitle = filenameWithoutExtension(file.name); setAudioItems((prev) => [ ...prev, { id, file, path: null, uploading: true, metadata: { ...defaultMetadata(autoTitle), playlistId: playlistId || null, }, }, ]); return { id, file, autoTitle }; }); for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) { const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY); const results = await Promise.allSettled( chunk.map(async ({ id, file, autoTitle }) => { const upload = await uploadFile(file, "audio"); const tagMetadata = upload.audioTags ? audioTagsToMetadata(upload.audioTags, autoTitle) : null; setAudioItems((prev) => prev.map((item) => item.id === id ? { ...item, path: upload.path, uploading: false, metadata: tagMetadata ? { ...item.metadata, ...tagMetadata } : item.metadata, } : item, ), ); }), ); for (let j = 0; j < results.length; j++) { if (results[j].status === "rejected") { const { id } = chunk[j]; const reason = results[j] as PromiseRejectedResult; setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed"); setAudioItems((prev) => prev.filter((item) => item.id !== id)); } } } } async function handleImageChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setError(null); setImageFile(file); setImageUploading(true); try { const path = await uploadFile(file, "image"); setImagePath(path.path); } catch (err) { setError(err instanceof Error ? err.message : "Image upload failed"); setImageFile(null); setImagePath(null); } finally { setImageUploading(false); } } async function handleAudioChange(e: React.ChangeEvent) { const files = Array.from(e.target.files || []); if (!files.length) return; setError(null); const maxBatch = quota?.maxBatchSize ?? 3; if (audioItems.length + files.length > maxBatch) { setError(`Your plan allows up to ${maxBatch} audio files per batch.`); e.target.value = ""; return; } await uploadFilesWithConcurrency(files); e.target.value = ""; } function updateItemMetadata(id: string, updates: Partial) { setAudioItems((prev) => prev.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, ...updates } } : item, ), ); } function handlePlaylistChange(nextPlaylistId: string) { setPlaylistId(nextPlaylistId); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, playlistId: nextPlaylistId || null, }, })), ); } function removeAudioItem(id: string) { setAudioItems((prev) => prev.filter((item) => item.id !== id)); } const readyAudios = audioItems.filter((a) => a.path && !a.uploading); const canSubmit = imagePath && !imageUploading && readyAudios.length > 0 && readyAudios.every((a) => a.metadata.title.trim()) && !submitting; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!canSubmit || !imagePath) return; setSubmitting(true); setError(null); try { const res = await fetch("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imagePath, items: readyAudios.map((item) => ({ audioPath: item.path!, audioFilename: item.file.name, metadata: item.metadata, })), }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to create job"); router.push(`/jobs/${data.jobId}`); } catch (err) { setError(err instanceof Error ? err.message : "Submission failed"); setSubmitting(false); } } return (
{quota && (
{quota.selfHosted ? ( <> Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch ) : ( <> {quota.remaining} of {quota.limit} plan videos remaining {quota.plan === "FREE" && quota.videoCredits > 0 ? ` · ${quota.videoCredits} pay-as-you-go credits` : ""}{" "} ·{" "} {quota.plan === "FREE" ? `resets in ${quota.resetsIn}` : `monthly quota resets on ${quota.resetsIn}`}{" "} · up to {quota.maxBatchSize} files per batch )}
)} {error && (
)}

Files

{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
0} /> {audioItems.length === 0 ? "No audio files selected" : `${readyAudios.length} of ${audioItems.length} ready`}
{audioItems.length > 0 && (

Video details (per audio)

Each audio file gets its own metadata. Title is auto-filled from the filename.

{audioItems.map((item, index) => (

Video {index + 1}: {item.file.name}

{item.uploading && (

Uploading…

)}
updateItemMetadata(item.id, { title: e.target.value })} className="input-field" required /> updateItemMetadata(item.id, { categoryId: v })} />