mirror of
https://git.atakanozban.com/Songs2VID/songs2vid.git
synced 2026-10-04 02:38:28 +02:00
- Split mobile file picker into separate cover/audio native inputs (iOS-safe) - Overlay file input on desktop instead of programmatic click() - Safe createClientId() fallback when crypto.randomUUID is missing - Pro-locked layout controls show feedback instead of silent disabled taps - Render button explains what is missing when tapped before ready
202 lines
6.3 KiB
TypeScript
202 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
|
|
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]);
|
|
const AUDIO_EXTS = new Set([".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg"]);
|
|
|
|
export function splitMediaFiles(files: FileList | File[]): {
|
|
images: File[];
|
|
audios: File[];
|
|
ignored: string[];
|
|
} {
|
|
const images: File[] = [];
|
|
const audios: File[] = [];
|
|
const ignored: string[] = [];
|
|
|
|
for (const file of Array.from(files)) {
|
|
const name = file.name.toLowerCase();
|
|
const dot = name.lastIndexOf(".");
|
|
const ext = dot >= 0 ? name.slice(dot) : "";
|
|
const mime = file.type.toLowerCase();
|
|
|
|
if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) {
|
|
images.push(file);
|
|
} else if (AUDIO_EXTS.has(ext) || mime.startsWith("audio/")) {
|
|
audios.push(file);
|
|
} else {
|
|
ignored.push(file.name);
|
|
}
|
|
}
|
|
|
|
return { images, audios, ignored };
|
|
}
|
|
|
|
type Props = {
|
|
disabled?: boolean;
|
|
acceptAudio: string;
|
|
onFiles: (payload: { images: File[]; audios: File[] }) => void;
|
|
};
|
|
|
|
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,.jpg,.jpeg,.png,.webp,.gif";
|
|
|
|
function FileHitArea({
|
|
disabled,
|
|
accept,
|
|
multiple,
|
|
onFiles,
|
|
children,
|
|
className,
|
|
}: {
|
|
disabled?: boolean;
|
|
accept: string;
|
|
multiple?: boolean;
|
|
onFiles: (list: FileList | null) => void;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
return (
|
|
<div className={`relative ${className ?? ""}`}>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple={multiple}
|
|
accept={accept}
|
|
disabled={disabled}
|
|
/* Direct tap on the native input — required on iOS; hidden/sr-only + label often no-ops. */
|
|
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
style={{ fontSize: 16 }}
|
|
onChange={(e) => {
|
|
onFiles(e.target.files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<div className="pointer-events-none">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mobile: two full-size native file inputs (cover vs audio). Mixed accept + hidden
|
|
* inputs commonly fail to open the picker in iOS Safari / in-app browsers.
|
|
* Desktop: one drop zone with overlay input + drag-and-drop.
|
|
*/
|
|
export function MediaDropzone({ disabled, acceptAudio, onFiles }: Props) {
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const handleFiles = useCallback(
|
|
(list: FileList | File[] | null) => {
|
|
if (!list || disabled) return;
|
|
const { images, audios, ignored } = splitMediaFiles(list);
|
|
if (images.length || audios.length) {
|
|
onFiles({ images, audios });
|
|
return;
|
|
}
|
|
if (ignored.length) {
|
|
onFiles({ images: [], audios: [] });
|
|
}
|
|
},
|
|
[disabled, onFiles],
|
|
);
|
|
|
|
const dropClass = disabled
|
|
? "cursor-not-allowed border-gray-800 bg-surface-light/20 opacity-60"
|
|
: dragging
|
|
? "border-accent bg-accent/10"
|
|
: "border-gray-600 bg-surface-light/30 hover:border-accent/60 hover:bg-accent/[0.06]";
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="grid gap-3 sm:hidden">
|
|
<FileHitArea
|
|
disabled={disabled}
|
|
accept={IMAGE_ACCEPT}
|
|
onFiles={(list) => handleFiles(list)}
|
|
>
|
|
<div className={`rounded-xl border-2 border-dashed px-5 py-8 text-center ${dropClass}`}>
|
|
<p className="text-base font-medium text-gray-200">Tap to add cover image</p>
|
|
<p className="mt-1 text-xs text-gray-500">JPG, PNG, WebP, or GIF</p>
|
|
</div>
|
|
</FileHitArea>
|
|
<FileHitArea
|
|
disabled={disabled}
|
|
accept={acceptAudio}
|
|
multiple
|
|
onFiles={(list) => handleFiles(list)}
|
|
>
|
|
<div className={`rounded-xl border-2 border-dashed px-5 py-8 text-center ${dropClass}`}>
|
|
<p className="text-base font-medium text-gray-200">Tap to add audio</p>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Free: MP3. Pro: WAV / FLAC / AAC / OGG
|
|
</p>
|
|
</div>
|
|
</FileHitArea>
|
|
</div>
|
|
|
|
<div className="hidden sm:block">
|
|
<div
|
|
onDragEnter={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!disabled) setDragging(true);
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!disabled) setDragging(true);
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragging(false);
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragging(false);
|
|
handleFiles(e.dataTransfer.files);
|
|
}}
|
|
>
|
|
<FileHitArea
|
|
disabled={disabled}
|
|
accept={`${IMAGE_ACCEPT},${acceptAudio}`}
|
|
multiple
|
|
onFiles={(list) => handleFiles(list)}
|
|
>
|
|
<div className={`rounded-xl border-2 border-dashed px-6 py-10 text-center ${dropClass}`}>
|
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-gray-700 bg-surface text-accent">
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className="h-7 w-7"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
aria-hidden
|
|
>
|
|
<path
|
|
d="M12 16V4m0 0 4 4m-4-4-4 4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<path
|
|
d="M4 14v2.5A3.5 3.5 0 0 0 7.5 20h9a3.5 3.5 0 0 0 3.5-3.5V14"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p className="mx-auto max-w-md text-base font-medium leading-relaxed text-gray-200">
|
|
Drag & drop audio and a cover image, or tap to browse
|
|
</p>
|
|
<p className="mt-3 text-xs text-gray-500">
|
|
One cover and one or more tracks. Free: MP3 ≤50 MB. Pro: WAV/FLAC ≤500 MB.
|
|
</p>
|
|
</div>
|
|
</FileHitArea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|