Files
songs2vid/components/UploadForm.tsx
T
Atakan Doğan Özban d9cdaff521 Strip Songs2VID to a payment-free self-hosted OSS core.
Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
2026-08-03 06:52:00 +02:00

693 lines
22 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Privacy } from "@prisma/client";
import { filenameWithoutExtension } from "@/lib/constants";
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";
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<string>(crypto.randomUUID());
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePath, setImagePath] = useState<string | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [imageUploading, setImageUploading] = useState(false);
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [playlistId, setPlaylistId] = useState("");
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{
maxBatchSize: number;
used: number;
} | null>(null);
const loadQuota = useCallback(async () => {
const res = await fetch("/api/quota");
if (res.ok) {
const data = await res.json();
setQuota({
maxBatchSize: data.maxBatchSize,
used: data.used ?? 0,
});
}
}, []);
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<typeof audioTagsToMetadata>[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<HTMLInputElement>) {
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 (!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<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setError(null);
const maxBatch = quota?.maxBatchSize ?? 100;
if (audioItems.length + files.length > maxBatch) {
setError(`You can upload up to ${maxBatch} audio files per batch.`);
e.target.value = "";
return;
}
await uploadFilesWithConcurrency(files);
e.target.value = "";
}
function updateItemMetadata(id: string, updates: Partial<ItemMetadata>) {
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,
},
),
),
) &&
!submitting;
async function submitJob() {
if (!canSubmit || !imagePath) return;
setSubmitting(true);
setError(null);
try {
const effectiveWatermark: WatermarkSettings = jobWatermark;
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: item.itemImagePath || item.metadata.imagePath || null,
includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark,
layout: jobLayout,
},
})),
}),
});
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);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit || !imagePath) return;
await submitJob();
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{quota && (
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
Self-hosted · {quota.used} videos created · up to {quota.maxBatchSize} files per batch
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<section className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4">
<h2 className="text-lg font-medium text-white">Files</h2>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm text-gray-400">Image</label>
<input
type="file"
accept="image/*"
onChange={handleImageChange}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
<div className="mt-2 flex items-center gap-2 text-sm">
<StatusDot ok={!!imagePath && !imageUploading} />
<span className="text-gray-400">
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
</span>
</div>
<p className="mt-2 text-xs text-gray-500">
Shared cover for all tracks. You can override it per audio below.
</p>
</div>
<div>
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
<input
type="file"
accept="audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
multiple
onChange={handleAudioChange}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
<div className="mt-2 flex items-center gap-2 text-sm">
<StatusDot ok={readyAudios.length > 0} />
<span className="text-gray-400">
{audioItems.length === 0
? "No audio files selected"
: `${readyAudios.length} of ${audioItems.length} ready`}
</span>
</div>
</div>
</div>
</section>
{audioItems.length > 0 && (
<section className="space-y-4">
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
<p className="text-sm text-gray-400">
Each audio file gets its own metadata. Video title is used for YouTube; song title and
artist appear in art-track layouts.
</p>
<div className="rounded-lg border border-gray-700 bg-surface p-6">
<PlaylistSelect
value={playlistId}
onChange={handlePlaylistChange}
enabled
/>
</div>
{audioItems.map((item, index) => (
<div
key={item.id}
className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">
Video {index + 1}: {item.file.name}
</h3>
{item.uploading && (
<p className="text-xs text-yellow-400">Uploading</p>
)}
</div>
<button
type="button"
onClick={() => removeAudioItem(item.id)}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Video title (YouTube)">
<input
type="text"
value={item.metadata.title}
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
className="input-field"
required
placeholder="Title shown on YouTube"
/>
</Field>
<Field label="Song title (on-video)">
<input
type="text"
value={item.metadata.songTitle ?? ""}
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
className="input-field"
placeholder="Shown in art-track layout"
maxLength={SONG_TITLE_MAX}
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Artist (on-video)">
<input
type="text"
value={item.metadata.artist ?? ""}
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
className="input-field"
placeholder="Shown in art-track layout"
maxLength={80}
/>
</Field>
<Field label="Category">
<CategorySelect
value={item.metadata.categoryId}
onChange={(v) => updateItemMetadata(item.id, { categoryId: v })}
/>
</Field>
</div>
<Field label="Description">
<textarea
value={item.metadata.description}
onChange={(e) => updateItemMetadata(item.id, { description: e.target.value })}
rows={3}
className="input-field resize-y"
/>
</Field>
<Field label="Tags">
<input
type="text"
value={item.metadata.tags}
onChange={(e) => updateItemMetadata(item.id, { tags: e.target.value })}
placeholder='Separate with spaces or commas. Use "quoted phrases" for multi-word tags.'
className="input-field"
/>
</Field>
<Field label="Privacy">
<PrivacyToggle
value={item.metadata.privacy}
onChange={(v) => updateItemMetadata(item.id, { privacy: v })}
/>
</Field>
<Field label="Video size">
<ResolutionSelect
value={item.metadata.resolution}
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
/>
</Field>
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
<Field label="Cover for this track (optional)">
<input
type="file"
accept="image/*"
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
void handleItemImageChange(item.id, f);
e.target.value = "";
}}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white disabled:opacity-50"
/>
</Field>
{item.itemImageName && (
<p className="mt-1 text-xs text-gray-500">
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}…`}
</p>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Checkbox
label="Notify subscribers about this upload"
checked={item.metadata.notifySubscribers}
onChange={(v) => updateItemMetadata(item.id, { notifySubscribers: v })}
/>
<Checkbox
label="Made For Kids?"
checked={item.metadata.madeForKids}
onChange={(v) => updateItemMetadata(item.id, { madeForKids: v })}
/>
<Checkbox
label="Embeddable?"
checked={item.metadata.embeddable}
onChange={(v) => updateItemMetadata(item.id, { embeddable: v })}
/>
<Checkbox
label="Creative Commons?"
checked={item.metadata.creativeCommons}
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
/>
</div>
</div>
))}
</section>
)}
<LayoutStudio
locked={false}
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
songTitle={
audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title ||
"Track title"
}
artist={audioItems[0]?.metadata.artist || ""}
encodeWidth={parseInt(readyAudios[0]?.metadata.resolution?.split("x")[0] || "1280", 10)}
layout={jobLayout}
onLayoutChange={applyLayoutToAll}
watermark={jobWatermark}
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
logoPreviewUrl={logoPreviewUrl}
/>
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded bg-accent px-6 py-3 font-medium text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting
? "Creating videos…"
: `Create ${readyAudios.length || ""} Video${readyAudios.length !== 1 ? "s" : ""}`}
</button>
</form>
);
}
function StatusDot({ ok }: { ok: boolean }) {
return (
<span
className={`inline-block h-3 w-3 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`}
/>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-sm text-gray-400">{label}</label>
{children}
</div>
);
}
function Checkbox({
label,
checked,
onChange,
disabled,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<label className={`flex items-center gap-2 text-sm text-gray-300 ${disabled ? "opacity-60" : ""}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="rounded border-gray-600 bg-surface-light text-accent focus:ring-accent"
/>
{label}
</label>
);
}