songs2vid/components/MediaDropzone.tsx
Atakan Doğan Özban 18c32fb569 fix: mobile dashboard dropzone and dead-click UX for ad traffic
- 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
2026-08-20 04:27:03 +02:00

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 &amp; 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&nbsp;MB. Pro: WAV/FLAC ≤500&nbsp;MB.
</p>
</div>
</FileHitArea>
</div>
</div>
</div>
);
}