"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 { getVideoAttributionText } from "@/lib/branding"; import { audioTagsToMetadata } from "@/lib/audio-tags"; import { resolveYouTubeTitle } from "@/lib/titles"; import { SONG_TITLE_MAX } from "@/lib/layout"; import type { ItemMetadata } from "@/lib/types"; import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout"; import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark"; import { CategorySelect } from "./CategorySelect"; import { LayoutStudio } from "./LayoutStudio"; import { PlaylistSelect } from "./PlaylistSelect"; import { PrivacyToggle } from "./PrivacyToggle"; import { ResolutionSelect } from "./ResolutionSelect"; import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink"; import { UpgradeProButton } from "./UpgradeProButton"; const UPLOAD_CONCURRENCY = 4; type AudioItem = { id: string; file: File; path: string | null; uploading: boolean; itemImagePath: string | null; itemImageName: string | null; itemImagePreview: string | null; 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, imagePath: null, artist: null, songTitle: null, watermark: { ...DEFAULT_WATERMARK }, layout: { ...DEFAULT_LAYOUT }, }; } export function UploadForm() { const router = useRouter(); const uploadSessionRef = useRef(crypto.randomUUID()); const [imageFile, setImageFile] = useState(null); const [imagePath, setImagePath] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = 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 [jobWatermark, setJobWatermark] = useState({ ...DEFAULT_WATERMARK }); const [jobLayout, setJobLayout] = useState({ ...DEFAULT_LAYOUT }); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); const [quota, setQuota] = useState<{ remaining: number; limit: number; plan: Plan; maxBatchSize: number; resetsIn: string; videoCredits: number; extraCredits: number; totalAvailable: number; used: number; selfHosted?: boolean; customWatermark?: boolean; perItemImages?: boolean; artTrackLayouts?: boolean; } | null>(null); const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM"); const canPerItemImages = Boolean(quota?.perItemImages || isPro); const canCustomWatermark = Boolean(quota?.customWatermark || isPro); const canArtTrackLayouts = Boolean(quota?.artTrackLayouts || isPro); 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 ?? data.extraCredits ?? 0, extraCredits: data.extraCredits ?? data.videoCredits ?? 0, totalAvailable: data.totalAvailable ?? data.remaining, used: data.used ?? 0, selfHosted: Boolean(data.selfHosted), customWatermark: Boolean(data.customWatermark), perItemImages: Boolean(data.perItemImages), artTrackLayouts: Boolean(data.artTrackLayouts), }); } }, []); useEffect(() => { loadQuota(); }, [loadQuota]); useEffect(() => { return () => { if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); audioItems.forEach((a) => { if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup on unmount only }, []); async function uploadFile( file: File, type: "image" | "audio" | "logo" | "font", ): 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 }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || "Upload failed"); } 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, itemImagePath: null, itemImageName: null, itemImagePreview: null, metadata: { ...defaultMetadata(autoTitle), playlistId: playlistId || null, watermark: { ...jobWatermark }, layout: { ...jobLayout }, }, }, ]); 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); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); setImagePreviewUrl(URL.createObjectURL(file)); 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); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); setImagePreviewUrl(null); } finally { setImageUploading(false); } } async function handleItemImageChange(itemId: string, file: File | null) { if (!canPerItemImages) { setError("Matching a unique image per audio requires Pro."); return; } if (!file) return; setError(null); const preview = URL.createObjectURL(file); setAudioItems((prev) => prev.map((item) => { if (item.id !== itemId) return item; if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); return { ...item, itemImageName: file.name, itemImagePreview: preview, itemImagePath: null, }; }), ); try { const uploaded = await uploadFile(file, "image"); setAudioItems((prev) => prev.map((item) => item.id === itemId ? { ...item, itemImagePath: uploaded.path, metadata: { ...item.metadata, imagePath: uploaded.path }, } : item, ), ); } catch (err) { setError(err instanceof Error ? err.message : "Per-item image upload failed"); setAudioItems((prev) => prev.map((item) => { if (item.id !== itemId) return item; if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview); return { ...item, itemImagePath: null, itemImageName: null, itemImagePreview: null, metadata: { ...item.metadata, imagePath: null }, }; }), ); } } async function handleLogoUpload(file: File) { const preview = URL.createObjectURL(file); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); setLogoPreviewUrl(preview); const uploaded = await uploadFile(file, "logo"); return uploaded.path; } async function handleFontUpload(file: File) { const uploaded = await uploadFile(file, "font"); return uploaded.path; } function applyWatermarkToAll(next: WatermarkSettings) { const normalized = { ...DEFAULT_WATERMARK, ...next }; setJobWatermark(normalized); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, includeWatermark: normalized.mode !== "none", watermark: { ...normalized }, }, })), ); } function applyLayoutToAll(next: LayoutSettings) { const normalized = { ...DEFAULT_LAYOUT, ...next }; setJobLayout(normalized); setAudioItems((prev) => prev.map((item) => ({ ...item, metadata: { ...item.metadata, layout: { ...normalized }, }, })), ); } 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) => Boolean( resolveYouTubeTitle( { title: a.metadata.title, songTitle: a.metadata.songTitle, artist: a.metadata.artist, }, isPro ? Plan.PREMIUM : Plan.FREE, ), ), ) && !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, imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null, includeWatermark: jobWatermark.mode !== "none", watermark: canCustomWatermark ? jobWatermark : { mode: jobWatermark.mode === "none" ? "none" : "default", position: "bottom-right", offsetX: 20, offsetY: 20, }, layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT }, }, })), }), }); const data = await res.json(); if (!res.ok) { if (data.code === "PREMIUM_REQUIRED") { throw new Error(data.error || "This feature requires Pro."); } 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.extraCredits > 0 ? ` · ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)` : ""}{" "} ·{" "} {quota.plan === "FREE" ? `resets in ${quota.resetsIn}` : `monthly quota resets on ${quota.resetsIn}`}{" "} · up to {quota.maxBatchSize} files per batch {quota.plan === "FREE" ? ( <> {" · "} Buy 1–15 credits ) : null} )}
)} {error && (
)}

Files

{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}

{canPerItemImages ? "Shared cover for all tracks. Optionally override per audio below (Pro)." : "Free plan: one static cover for the whole batch."}

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. Video title is used for YouTube; song title and artist appear in Pro art-track layouts.

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

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

{item.uploading && (

Uploading…

)}
updateItemMetadata(item.id, { title: e.target.value })} className="input-field" required placeholder="Title shown on YouTube" /> updateItemMetadata(item.id, { songTitle: e.target.value })} className="input-field disabled:cursor-not-allowed disabled:opacity-60" disabled={!isPro} placeholder={isPro ? "Shown in art-track layout" : "Pro feature"} maxLength={SONG_TITLE_MAX} />
updateItemMetadata(item.id, { artist: e.target.value })} className="input-field disabled:cursor-not-allowed disabled:opacity-60" disabled={!isPro} placeholder={isPro ? "Shown in art-track layout" : "Pro feature"} maxLength={80} /> updateItemMetadata(item.id, { categoryId: v })} />