From 18c32fb569719d83d625a70fc112525dedfdb09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Do=C4=9Fan=20=C3=96zban?= Date: Thu, 20 Aug 2026 04:27:03 +0200 Subject: [PATCH] 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 --- components/LayoutStudio.tsx | 460 +++++++++++++++++++++++++++++------ components/MediaDropzone.tsx | 201 +++++++++++++++ components/UploadForm.tsx | 68 +++++- lib/client-id.ts | 7 + 4 files changed, 653 insertions(+), 83 deletions(-) create mode 100644 components/MediaDropzone.tsx create mode 100644 lib/client-id.ts diff --git a/components/LayoutStudio.tsx b/components/LayoutStudio.tsx index 6379b0b..78d507e 100644 --- a/components/LayoutStudio.tsx +++ b/components/LayoutStudio.tsx @@ -10,6 +10,7 @@ import { import { getVideoAttributionText } from "@/lib/branding"; import { CURATED_FONTS, + SYSTEM_FONT, type CuratedFontKey, type WatermarkFontKey, } from "@/lib/fonts"; @@ -19,15 +20,18 @@ import { BLUR_OPACITY_DEFAULT, BLUR_OPACITY_MAX, BLUR_OPACITY_MIN, + COMPOSITION_FAMILIES, DEFAULT_LAYOUT, LAYOUT_TEMPLATE_LABELS, - LAYOUT_TEMPLATES, TEXT_OFFSET_MAX, TEXT_OFFSET_MIN, TEXT_PADDING_MAX, TEXT_PADDING_MIN, TITLE_ARTIST_GAP_MAX, TITLE_ARTIST_GAP_MIN, + compositionFamilyForTemplate, + isLowerCornerTemplate, + lowerCornerCoverSide, type LayoutSettings, type LayoutTemplate, } from "@/lib/layout"; @@ -55,6 +59,8 @@ import { UpgradeProButton } from "./UpgradeProButton"; type Props = { locked: boolean; previewImageUrl: string | null; + /** Optional custom blur-fill for lower-corner layouts. */ + previewBackgroundUrl?: string | null; /** On-video song title (art-track layouts). */ songTitle: string; artist: string; @@ -66,7 +72,12 @@ type Props = { onWatermarkChange: (next: WatermarkSettings) => void; onUploadLogo: (file: File) => Promise; onUploadFont: (file: File) => Promise; + onUploadBackground?: (file: File) => Promise; + onClearBackground?: () => void; logoPreviewUrl?: string | null; + hasCustomBackground?: boolean; + /** Called when a Free user taps a Pro-only control (avoids silent dead clicks). */ + onProRequired?: () => void; }; const WM_POSITION_LABELS: Record = { @@ -122,26 +133,29 @@ function watermarkOverlayStyle( function MiniThumb({ template, + label, active, onClick, - disabled, + proLocked = false, }: { template: LayoutTemplate | null; + label: string; active: boolean; onClick: () => void; - disabled: boolean; + proLocked?: boolean; }) { const isClassic = template === null; return ( ); } @@ -192,8 +206,18 @@ function MiniThumb({ function previewCoverStyle( template: LayoutTemplate, padPct: number, + textPadding: number, + encodeWidth: number, ): CSSProperties { const p = `${padPct}%`; + // Equal pixel inset on both axes (not % - % of width ≠ % of height on 16:9). + const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth); + // Assume 16:9 when only width is known; side is usually width-driven for lower corner. + const encodeHeight = Math.round((encodeWidth * 9) / 16); + const cornerSidePx = scaleFontToPreview( + lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding), + encodeWidth, + ); switch (template) { case "COVER_LEFT_TEXT_RIGHT": return { @@ -235,6 +259,24 @@ function previewCoverStyle( maxHeight: "38%", objectFit: "contain", }; + case "LOWER_LEFT_COVER_TEXT": + return { + position: "absolute", + left: cornerEdgePx, + bottom: cornerEdgePx, + width: cornerSidePx, + height: cornerSidePx, + objectFit: "contain", + }; + case "LOWER_RIGHT_COVER_TEXT": + return { + position: "absolute", + right: cornerEdgePx, + bottom: cornerEdgePx, + width: cornerSidePx, + height: cornerSidePx, + objectFit: "contain", + }; } } @@ -244,11 +286,20 @@ function previewTextStyle( textOffsetX: number, textOffsetY: number, titleArtistGap: number, + textPadding: number, + encodeWidth: number, ): CSSProperties { const p = `${padPct}%`; - const shift = { - transform: undefined as string | undefined, - }; + const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth); + const coverTextGapPx = scaleFontToPreview( + Math.max(10, Math.round(textPadding * 0.55)), + encodeWidth, + ); + const encodeHeight = Math.round((encodeWidth * 9) / 16); + const cornerSidePx = scaleFontToPreview( + lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding), + encodeWidth, + ); const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` }; @@ -295,13 +346,38 @@ function previewTextStyle( textAlign: "center", alignItems: "center", }; + case "LOWER_LEFT_COVER_TEXT": + return { + ...baseGap, + position: "absolute", + left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX, + right: p, + bottom: cornerEdgePx, + height: cornerSidePx, + justifyContent: "center", + textAlign: "left", + transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined, + }; + case "LOWER_RIGHT_COVER_TEXT": + return { + ...baseGap, + position: "absolute", + left: p, + right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX, + bottom: cornerEdgePx, + height: cornerSidePx, + justifyContent: "center", + textAlign: "right", + alignItems: "flex-end", + transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined, + }; } - void shift; } export function LayoutStudio({ locked, previewImageUrl, + previewBackgroundUrl = null, songTitle, artist, encodeWidth = 1280, @@ -311,24 +387,32 @@ export function LayoutStudio({ onWatermarkChange, onUploadLogo, onUploadFont, + onUploadBackground, + onClearBackground, logoPreviewUrl, + hasCustomBackground = false, + onProRequired, }: Props) { const [logoUploading, setLogoUploading] = useState(false); const [logoError, setLogoError] = useState(null); const [fontUploading, setFontUploading] = useState(false); const [fontError, setFontError] = useState(null); const [customFontObjectUrl, setCustomFontObjectUrl] = useState(null); + const [bgUploading, setBgUploading] = useState(false); + const [bgError, setBgError] = useState(null); // Always coalesce HMR / older session state may omit newly added fields const L: LayoutSettings = { ...DEFAULT_LAYOUT, ...layout, + blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill, blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount, blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT, textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding, titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap, textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX, textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY, + titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold, }; const W: WatermarkSettings = { ...DEFAULT_WATERMARK, @@ -387,13 +471,34 @@ export function LayoutStudio({ el.id = styleId; document.head.appendChild(el); } - el.textContent = CURATED_FONTS.map( - (f) => `@font-face { - font-family: 'S2VIDPreview-${f.key}'; - src: url('${curatedFontApiUrl(f.key)}') format('truetype'); + el.textContent = [ + `@font-face { + font-family: '${SYSTEM_FONT.previewFamily}'; + src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype'); + font-weight: 400; font-display: swap; }`, - ).join("\n"); + `@font-face { + font-family: '${SYSTEM_FONT.previewFamily}'; + src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype'); + font-weight: 600; + font-display: swap; +}`, + `@font-face { + font-family: '${SYSTEM_FONT.previewFamily}'; + src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype'); + font-weight: 700; + font-display: swap; +}`, + ...CURATED_FONTS.map( + (f) => `@font-face { + font-family: 'S2VIDPreview-${f.key}'; + src: url('${curatedFontApiUrl(f.key)}') format('truetype'); + font-weight: 400; + font-display: swap; +}`, + ), + ].join("\n"); }, []); useEffect(() => { @@ -420,12 +525,29 @@ export function LayoutStudio({ }, [customFontObjectUrl]); function patchLayout(partial: Partial) { - if (locked) return; - onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial }); + if (!locked) { + onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial }); + return; + } + // Free / locked: classic letterbox blur only (no art-track template). + if (partial.template !== undefined && partial.template !== null) { + onProRequired?.(); + return; + } + const safe: Partial = {}; + if (partial.template === null) safe.template = null; + if (partial.blurFill !== undefined) safe.blurFill = partial.blurFill; + if (partial.blurAmount !== undefined) safe.blurAmount = partial.blurAmount; + if (partial.blurOpacity !== undefined) safe.blurOpacity = partial.blurOpacity; + if (Object.keys(safe).length === 0) return; + onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...safe, template: null }); } function patchWm(partial: Partial) { - if (locked) return; + if (locked) { + onProRequired?.(); + return; + } onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...partial }); } @@ -464,6 +586,21 @@ export function LayoutStudio({ } } + async function handleBackground(e: ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file || locked || !onUploadBackground) return; + setBgError(null); + setBgUploading(true); + try { + await onUploadBackground(file); + } catch (err) { + setBgError(err instanceof Error ? err.message : "Background upload failed"); + } finally { + setBgUploading(false); + } + } + function setFontKey(key: WatermarkFontKey) { if (key === "custom") { patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null }); @@ -473,24 +610,23 @@ export function LayoutStudio({ } const artTrack = Boolean(L.template); + const lowerCorner = isLowerCornerTemplate(L.template); + const blurPreviewUrl = previewBackgroundUrl || previewImageUrl; return (
{locked && ( -
- - Pro - -

- Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features. -

+
+ Pro unlocks art-track + layouts, custom backgrounds, typography, and watermark studio.{" "} + + Classic blurred letterbox (blur amount / opacity) works on Free. + {" "}
)} @@ -504,8 +640,8 @@ export function LayoutStudio({ {/* Single live preview: art-track + watermark */}
{previewImageUrl ? ( <> @@ -514,7 +650,7 @@ export function LayoutStudio({
{/* eslint-disable-next-line @next/next/no-img-element */}

) : ( -

- {/* eslint-disable-next-line @next/next/no-img-element */} - +
+ {L.blurFill && ( + // eslint-disable-next-line @next/next/no-img-element + 0 ? `blur(${blurPx}px)` : undefined, + transform: "scale(1.15)", + opacity: L.blurOpacity / 100, + }} + /> + )} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
)} @@ -605,8 +766,40 @@ export function LayoutStudio({ )} ) : ( -
- Upload a cover image to preview +
+
+
+ {Array.from({ length: 48 }).map((_, i) => { + const h = 18 + ((i * 17) % 55); + return ( + + ); + })} +
+
+
+ + Cover + +
+

Preview template

+

+ Drop a cover image to see your layout live +

+
)}
@@ -614,22 +807,125 @@ export function LayoutStudio({ {/* Art-track templates */}

Composition

- patchLayout({ template: null })} - /> - {LAYOUT_TEMPLATES.map((t) => ( - patchLayout({ template: t })} - /> - ))} + {COMPOSITION_FAMILIES.map((family) => { + const active = + family.id === "classic" + ? L.template === null + : family.variants.includes(L.template as LayoutTemplate); + return ( + { + if (family.id === "classic") { + patchLayout({ template: null }); + return; + } + if (locked) { + onProRequired?.(); + return; + } + if ( + L.template && + family.variants.includes(L.template as LayoutTemplate) + ) { + return; + } + patchLayout({ template: family.defaultTemplate, blurFill: false }); + }} + /> + ); + })}
+ {(() => { + const family = compositionFamilyForTemplate(L.template); + if (family.variants.length < 2) return null; + return ( +
+ {family.variants.map((variant) => ( + + ))} +
+ ); + })()} + + {!artTrack && ( + + )} + + {lowerCorner && ( +
+

Background image

+

+ Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square. + Leave empty to blur the cover itself. +

+
+ + {hasCustomBackground && ( + + )} +
+ {bgError &&

{bgError}

} +
+ )}
+
{/* Typography (matches FFmpeg art-track + watermark text fonts) */} @@ -719,7 +1029,7 @@ export function LayoutStudio({ onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)} className="input-field mt-1" > - + {CURATED_FONTS.map((f) => (
@@ -815,7 +1125,7 @@ export function LayoutStudio({ 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" /> {logoUploading && ( -

Uploading logo…

+

Uploading logo...

)} {logoError &&

{logoError}

}
diff --git a/components/MediaDropzone.tsx b/components/MediaDropzone.tsx new file mode 100644 index 0000000..1963f66 --- /dev/null +++ b/components/MediaDropzone.tsx @@ -0,0 +1,201 @@ +"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(null); + + return ( +
+ { + onFiles(e.target.files); + e.target.value = ""; + }} + /> +
{children}
+
+ ); +} + +/** + * 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 ( +
+
+ handleFiles(list)} + > +
+

Tap to add cover image

+

JPG, PNG, WebP, or GIF

+
+
+ handleFiles(list)} + > +
+

Tap to add audio

+

+ Free: MP3. Pro: WAV / FLAC / AAC / OGG +

+
+
+
+ +
+
{ + 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); + }} + > + handleFiles(list)} + > +
+
+ + + + +
+

+ Drag & drop audio and a cover image, or tap to browse +

+

+ One cover and one or more tracks. Free: MP3 ≤50 MB. Pro: WAV/FLAC ≤500 MB. +

+
+
+
+
+
+ ); +} diff --git a/components/UploadForm.tsx b/components/UploadForm.tsx index 4844c21..d0be5cb 100644 --- a/components/UploadForm.tsx +++ b/components/UploadForm.tsx @@ -38,6 +38,7 @@ import { import type { SharePlatform } from "@/lib/share-verify"; import { VERIFY_FAIL_MESSAGE } from "@/lib/share-verify"; import type { JobDeliveryMode } from "@/lib/types"; +import { createClientId } from "@/lib/client-id"; const UPLOAD_CONCURRENCY = 4; const SHARE_UNLOCK_TOKEN_KEY = "s2vid_share_download_unlock_token_v1"; @@ -142,7 +143,7 @@ function defaultMetadata(title = ""): ItemMetadata { export function UploadForm() { const router = useRouter(); - const uploadSessionRef = useRef(crypto.randomUUID()); + const uploadSessionRef = useRef(createClientId()); const [imageFile, setImageFile] = useState(null); const [imagePath, setImagePath] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); @@ -265,7 +266,7 @@ export function UploadForm() { async function uploadFilesWithConcurrency(files: File[]) { const pending = files.map((file) => { - const id = crypto.randomUUID(); + const id = createClientId(); const autoTitle = filenameWithoutExtension(file.name); setAudioItems((prev) => [ @@ -349,6 +350,10 @@ export function UploadForm() { async function ingestMediaFiles(images: File[], audios: File[]) { setError(null); + if (!images.length && !audios.length) { + setError("Choose a JPG/PNG cover or an MP3. Videos and other file types are not supported."); + return; + } if (images.length > 1) { setError("Only one cover image is used per batch - using the first image."); } @@ -403,7 +408,7 @@ export function UploadForm() { }); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); - const id = crypto.randomUUID(); + const id = createClientId(); setAudioItems([ { id, @@ -628,6 +633,44 @@ export function UploadForm() { ) && !submitting; + function submitBlockReason(): string | null { + if (submitting) return null; + if (!imagePath && !imageUploading) return "Add a cover image to continue."; + if (imageUploading) return "Cover image is still uploading…"; + if (readyAudios.length === 0) { + return audioItems.some((a) => a.uploading) + ? "Wait for audio uploads to finish." + : "Add at least one audio file."; + } + if ( + !readyAudios.every((a) => + Boolean( + resolveYouTubeTitle( + { + title: a.metadata.title, + songTitle: a.metadata.songTitle, + artist: a.metadata.artist, + }, + isPro ? Plan.PRODUCER_PRO : Plan.FREE, + ), + ), + ) + ) { + return "Enter a video title for each track."; + } + return null; + } + + function handleRenderPress(e: React.SyntheticEvent) { + e.preventDefault(); + if (!canSubmit) { + const reason = submitBlockReason(); + if (reason) setError(reason); + return; + } + void handleSubmit(e as React.FormEvent); + } + function buildJobBody(delivery: JobDeliveryMode, unlockTokenOverride?: string | null) { if (!imagePath) return null; const forcedWatermark = !isPro && !quota?.selfHosted; @@ -918,7 +961,7 @@ export function UploadForm() { } return ( -
+ + setError( + "Art-track layouts and custom watermarks require Pro. Classic blurred letterbox works on Free — or try the demo track above.", + ) + } /> {audioItems.length > 0 && ( @@ -1299,8 +1347,10 @@ export function UploadForm() { {!hasSelectedMedia && (