Files
songs2vid/components/UploadForm.tsx
T
Atakan Doğan ÖzbanandCursor c8015937f9 Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 16:26:19 +02:00

852 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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<{
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<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 (!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<HTMLInputElement>) {
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<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,
},
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 (
<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">
{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" ? (
<>
{" · "}
<a href="/dashboard/settings" className="text-accent hover:underline">
Buy 115 credits
</a>
</>
) : null}
</>
)}
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
<QuotaErrorMessage message={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">
{canPerItemImages
? "Shared cover for all tracks. Optionally override per audio below (Pro)."
: "Free plan: one static cover for the whole batch."}
</p>
</div>
<div>
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
<input
type="file"
accept={
quota?.selfHosted || quota?.plan === "PREMIUM"
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
: "audio/mpeg,.mp3"
}
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 Pro art-track layouts.
</p>
<div className="rounded-lg border border-gray-700 bg-surface p-6">
<PlaylistSelect
value={playlistId}
onChange={handlePlaylistChange}
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
/>
</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>
<ProLockedField
label="Song title (on-video)"
locked={!isPro}
hint="Pro unlocks custom song title burned into art-track layouts."
>
<input
type="text"
value={item.metadata.songTitle ?? ""}
onChange={(e) => 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}
/>
</ProLockedField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ProLockedField
label="Artist (on-video)"
locked={!isPro}
hint="Pro unlocks custom artist line in art-track layouts."
>
<input
type="text"
value={item.metadata.artist ?? ""}
onChange={(e) => 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}
/>
</ProLockedField>
<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 })}
plan={quota?.plan}
/>
</Field>
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
{!canPerItemImages && (
<div className="mb-2 flex items-center gap-2">
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent">
Pro
</span>
<span className="text-xs text-gray-500">Unique image per track</span>
</div>
)}
<Field
label={
canPerItemImages
? "Cover for this track (optional)"
: "Cover for this track (Pro)"
}
>
<input
type="file"
accept="image/*"
disabled={!canPerItemImages}
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>
)}
{!canPerItemImages && (
<p className="mt-2 text-xs text-gray-500">
<UpgradeProButton label="Upgrade for bulk unique image matching" />
</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 })}
/>
{!canCustomWatermark && (
<Checkbox
label={`Show '${getVideoAttributionText()}' watermark (support open-source)`}
checked={jobWatermark.mode !== "none"}
onChange={(v) =>
applyWatermarkToAll({
...DEFAULT_WATERMARK,
mode: v ? "default" : "none",
})
}
/>
)}
</div>
</div>
))}
</section>
)}
<LayoutStudio
locked={!canArtTrackLayouts && !canCustomWatermark}
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}
/>
{quota && !quota.selfHosted && quota.plan === "FREE" && (
<>
<p className="text-sm text-gray-400">
Free plan: one static cover image for the batch · optional Songs2VID watermark
(bottom-right only).
</p>
<p className="text-sm text-gray-400">
<UpgradeProLink className="text-accent hover:underline" /> for custom branding, typography,
blurred art-track layouts, watermark positioning, unique image matching, 1080p, and
lossless audio.
</p>
</>
)}
<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 ProLockedField({
label,
locked,
hint,
children,
}: {
label: string;
locked: boolean;
hint: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="mb-1 flex items-center gap-2 text-sm text-gray-400">
{label}
{locked && (
<span
className="rounded border border-accent/50 bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent"
title={hint}
>
Pro
</span>
)}
</label>
<div className="relative">
{children}
{locked && (
<p className="mt-1 text-xs text-gray-500">{hint}</p>
)}
</div>
</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>
);
}