diff --git a/docs/image-metadata.md b/docs/image-metadata.md index b7fe4c2..363b878 100644 --- a/docs/image-metadata.md +++ b/docs/image-metadata.md @@ -149,9 +149,9 @@ Each caller handles the refusal in its own way. An upload through `/api/upload` returns 422 and asks you to re-export the file. A fursuit import counts that photo as failed and logs the reason. Avatar re-hosting logs a warning and keeps the source URL as a hotlink. A sticker import reports the failure for that -sticker. Uploading stickers by hand in the pack form counts the refused files -among the failures and names the refusal in a second message with its own -count, because the operator picked those files and can re-export them. A provider migration lists the object as failed and asks you to replace +sticker. Uploading stickers by hand in the pack form names each refused file +and the fix on its own line under the upload zone, because the operator picked +those files and can re-export them. A provider migration lists the object as failed and asks you to replace it with a fresh copy. The sticker re-key reports it the same way a migration does, because the operator has to replace the file either way. diff --git a/messages/en.json b/messages/en.json index a6e9bc1..9d79272 100644 --- a/messages/en.json +++ b/messages/en.json @@ -410,11 +410,15 @@ "admin_upload_uploaded": "Uploaded", "admin_upload_replace_hint": "Click or drag to replace", "admin_upload_retry_hint": "Click or drag to try again", - "admin_upload_dropzone": "Drag & drop images here, or click to browse", - "admin_upload_dropzone_multi": "Drag & drop up to {max} images here, click to browse, or paste from clipboard", + "admin_upload_dropzone_multi": "Choose up to {max} images, drag them here, or paste from the clipboard", "admin_upload_images_added": "{count} image(s) added", - "admin_upload_over_limit": "{count} image(s) skipped — max {max}", + "admin_upload_over_limit": "{count} file(s) skipped — max {max}", + "admin_upload_images_rejected": "{count} file(s) couldn't be added", + "admin_upload_images_added_and_rejected": "{added} image(s) added. {rejected} file(s) couldn't be added", + "admin_upload_batch_done": "Upload finished.", + "admin_upload_batch_issues": "Upload finished with errors. Each file that failed shows the reason.", "admin_upload_error_too_large": "This file is over {max}. Try a smaller image.", + "admin_upload_error_bad_type": "That file type isn't supported. Use PNG, JPEG, GIF, WebP, or AVIF.", "admin_field_variant_of": "Variant of", "admin_field_variant_label": "Variant label", "admin_variant_none": "Not a variant", @@ -436,7 +440,7 @@ "admin_variant_group_title": "Group as variants", "admin_variant_group_pick_parent": "Pick the parent — the other images become its variants.", "admin_variant_group_confirm": "Group", - "admin_upload_formats": "Supports PNG, JPG, GIF, WEBP up to 64MB", + "admin_upload_formats": "Supports PNG, JPEG, GIF, WebP, and AVIF up to 64MB", "admin_upload_image_details": "Image Details", "admin_upload_title_placeholder": "Enter artwork title...", "admin_upload_select_existing": "Select Existing", @@ -855,8 +859,26 @@ "admin_pack_save_failed": "Could not save pack", "admin_pack_manual_intro": "Upload sticker images (PNG or WebP) and fill in the pack details. Each sticker can have its own artist and emoji. Files are uploaded to storage before saving.", "admin_pack_edit_heading": "Edit pack: {name}", - "admin_pack_upload_partial": "{ok} of {total} uploaded, {failed} failed", - "admin_pack_upload_unscrubbable": "Couldn't strip hidden metadata from {refused} of the files you picked. Export fresh copies from an image editor and upload those.", + "admin_pack_upload_issues": "Sticker upload finished with errors. Each file that failed shows the reason.", + "admin_pack_error_bad_type": "That file type isn't supported. Use PNG or WebP.", + "admin_pack_error_too_large": "This file is over {max}. Try a smaller image.", + "admin_pack_error_failed": "Upload failed. Check your connection and try again.", + "admin_pack_error_unscrubbable": "Couldn't strip this file's hidden metadata. Export a fresh copy from an image editor and upload that.", + "admin_pack_upload_done": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural" + ], + "selectors": [ + "countPlural" + ], + "match": { + "countPlural=one": "{count} sticker added", + "countPlural=other": "{count} stickers added" + } + } + ], "admin_pack_details": "Pack details", "admin_pack_name": "Pack name", "admin_pack_name_placeholder": "My sticker pack", @@ -873,7 +895,7 @@ "admin_pack_publish_off": "Off — saved as draft", "admin_pack_default_artist": "Default artist", "admin_pack_edit_hint": "Note: saving replaces all sticker rows. Drag the handle to reorder; order is saved top-to-bottom.", - "admin_pack_dropzone": "Drag & drop PNGs or WebP here, or click to browse", + "admin_pack_dropzone": "Choose PNG or WebP images, or drag them here", "admin_pack_bulk_tip": "Tip: click a sticker to select (shift-click for a range), then bulk-set the artist or NSFW.", "admin_pack_select_all": "Select all {count}", "admin_pack_selected": "{count} selected", @@ -1548,7 +1570,7 @@ "admin_vr_field_role_label": "Role name", "admin_vr_role_label_placeholder": "e.g. Blendshapes", "admin_vr_field_model": "Model file", - "admin_vr_dropzone": "Choose a .vrm or .fbx file to upload (max {max})", + "admin_vr_dropzone": "Choose a .vrm or .fbx file, or drag it here (max {max})", "admin_vr_model_hint": "VRM 0.x and 1.0 both work in the 3D viewer. You can still offer FBX files as a download, but the viewer won't display them.", "admin_vr_upload_progress": "{loaded} of {total} uploaded", "admin_vr_upload_replace": "Replace", @@ -1563,12 +1585,12 @@ "admin_vr_permission_placeholder": "e.g. Telegram DM 2026-08-01 — required to offer downloads", "admin_vr_section_media": "Showcase media", "admin_vr_media_hint": "Screenshots and short clips shown under the poster on the public page. Clips can't carry captions, so don't put anything important in speech or audio. They start muted, but visitors can turn the sound on, so you need consent from anyone recorded in the clip.", - "admin_vr_media_dropzone": "Add screenshots or short .webm clips (max {max} each)", + "admin_vr_media_dropzone": "Choose screenshots or short .webm clips, or drag them here (max {max} each)", "admin_vr_media_kind_image": "Image", "admin_vr_media_kind_video": "Clip", "admin_vr_media_remove": "Remove media {position}", "admin_vr_media_upload_done": "Media upload finished.", - "admin_vr_media_upload_issues": "Media upload finished with errors — see the messages below the dropzone.", + "admin_vr_media_upload_issues": "Media upload finished with errors. Each file that failed shows the reason.", "admin_vr_media_error_too_large": "This file is over {max}. Try a smaller image, or a shorter clip at a lower resolution.", "admin_vr_media_error_bad_type": "That file type isn't supported. Use JPEG, PNG, GIF, WebP, AVIF, or a .webm clip.", "admin_vr_media_error_failed": "Upload failed. Check your connection and try again.", diff --git a/messages/ja.json b/messages/ja.json index 82a7a11..aee3c37 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -354,11 +354,15 @@ "admin_upload_uploaded": "アップロード完了", "admin_upload_replace_hint": "クリックまたはドラッグで差し替え", "admin_upload_retry_hint": "クリックまたはドラッグで再試行", - "admin_upload_dropzone": "ここに画像をドラッグ&ドロップするか、クリックして選択してください", - "admin_upload_dropzone_multi": "ここに最大{max}枚の画像をドラッグ&ドロップするか、クリックして選択、またはクリップボードから貼り付けてください", + "admin_upload_dropzone_multi": "最大{max}枚の画像を選択するか、ここにドラッグするか、クリップボードから貼り付けてください", "admin_upload_images_added": "{count}枚の画像を追加しました", - "admin_upload_over_limit": "{count}枚をスキップしました(最大{max}枚)", + "admin_upload_over_limit": "{count}件のファイルをスキップしました(最大{max}枚)", + "admin_upload_images_rejected": "{count}件のファイルを追加できませんでした", + "admin_upload_images_added_and_rejected": "{added}枚の画像を追加しました。{rejected}件のファイルを追加できませんでした", + "admin_upload_batch_done": "アップロードが完了しました。", + "admin_upload_batch_issues": "アップロードは完了しましたが、エラーがありました。失敗したファイルにはそれぞれ理由が表示されています。", "admin_upload_error_too_large": "このファイルは{max}を超えています。もっと小さい画像をお試しください。", + "admin_upload_error_bad_type": "対応していないファイル形式です。PNG・JPEG・GIF・WebP・AVIFをご利用ください。", "admin_field_variant_of": "バリエーション元", "admin_field_variant_label": "バリエーションラベル", "admin_variant_none": "バリエーションではない", @@ -380,7 +384,7 @@ "admin_variant_group_title": "バリエーションとしてグループ化", "admin_variant_group_pick_parent": "親を選択してください — 他の画像はそのバリエーションになります。", "admin_variant_group_confirm": "グループ化", - "admin_upload_formats": "PNG、JPG、GIF、WEBP(最大64MB)に対応しています", + "admin_upload_formats": "PNG・JPEG・GIF・WebP・AVIF(最大64MB)に対応しています", "admin_upload_image_details": "画像の詳細", "admin_upload_title_placeholder": "作品のタイトルを入力...", "admin_upload_select_existing": "既存から選択", @@ -645,8 +649,12 @@ "admin_pack_save_failed": "セットを保存できませんでした", "admin_pack_manual_intro": "ステッカー画像(PNGまたはWebP)をアップロードし、セットの詳細を入力してください。各ステッカーには個別のアーティストと絵文字を設定できます。ファイルは保存前にストレージへアップロードされます。", "admin_pack_edit_heading": "セットを編集: {name}", - "admin_pack_upload_partial": "{total}件中{ok}件をアップロードしました({failed}件は失敗)", - "admin_pack_upload_unscrubbable": "選択したファイルのうち{refused}件から埋め込まれたメタデータを削除できませんでした。画像編集ソフトで書き出し直したファイルをアップロードしてください。", + "admin_pack_upload_issues": "ステッカーのアップロードは完了しましたが、エラーがありました。失敗したファイルにはそれぞれ理由が表示されています。", + "admin_pack_error_bad_type": "対応していないファイル形式です。PNGまたはWebPをご利用ください。", + "admin_pack_error_too_large": "このファイルは{max}を超えています。もっと小さい画像をお試しください。", + "admin_pack_error_failed": "アップロードに失敗しました。接続を確認してもう一度お試しください。", + "admin_pack_error_unscrubbable": "このファイルに埋め込まれたメタデータを削除できませんでした。画像編集ソフトで書き出し直したファイルをアップロードしてください。", + "admin_pack_upload_done": "ステッカーを{count}枚追加しました", "admin_pack_details": "セットの詳細", "admin_pack_name": "セット名", "admin_pack_name_placeholder": "マイステッカーセット", @@ -663,7 +671,7 @@ "admin_pack_publish_off": "オフ — 下書きとして保存されます", "admin_pack_default_artist": "デフォルトのアーティスト", "admin_pack_edit_hint": "注意: 保存するとすべてのステッカー行が置き換えられます。ハンドルをドラッグして並べ替えられます。順序は上から下の順で保存されます。", - "admin_pack_dropzone": "PNGまたはWebPをここにドラッグ&ドロップするか、クリックして選択してください", + "admin_pack_dropzone": "PNGまたはWebPの画像を選択するか、ここにドラッグしてください", "admin_pack_bulk_tip": "ヒント: ステッカーをクリックで選択(Shift+クリックで範囲選択)し、アーティストやNSFWをまとめて設定できます。", "admin_pack_select_all": "{count}件すべて選択", "admin_pack_selected": "{count}件選択中", @@ -1240,7 +1248,7 @@ "admin_vr_field_role_label": "役割名", "admin_vr_role_label_placeholder": "例: ブレンドシェイプ", "admin_vr_field_model": "モデルファイル", - "admin_vr_dropzone": ".vrmまたは.fbxファイルを選択してアップロード(最大{max})", + "admin_vr_dropzone": ".vrmまたは.fbxファイルを選択するか、ここにドラッグしてください(最大{max})", "admin_vr_model_hint": "3DビューアはVRM 0.xと1.0の両方に対応しています。FBXファイルはダウンロード用に提供できますが、ビューアには表示されません。", "admin_vr_upload_progress": "{total}中{loaded}をアップロード済み", "admin_vr_upload_replace": "差し替え", @@ -1255,12 +1263,12 @@ "admin_vr_permission_placeholder": "例: Telegram DM 2026-08-01 — ダウンロード提供に必須", "admin_vr_section_media": "ショーケースメディア", "admin_vr_media_hint": "公開ページでポスターの下に表示されるスクリーンショットや短いクリップです。クリップには字幕を付けられないため、重要な内容を音声に頼らないでください。再生はミュートで始まりますが、訪問者は音声をオンにできます — クリップに記録されている人がいる場合は、その人の同意が必要です。", - "admin_vr_media_dropzone": "スクリーンショットや短い.webmクリップを追加(各最大{max})", + "admin_vr_media_dropzone": "スクリーンショットや短い.webmクリップを選択するか、ここにドラッグしてください(各最大{max})", "admin_vr_media_kind_image": "画像", "admin_vr_media_kind_video": "クリップ", "admin_vr_media_remove": "メディア{position}を削除", "admin_vr_media_upload_done": "メディアのアップロードが完了しました。", - "admin_vr_media_upload_issues": "メディアのアップロードが完了しましたが、エラーがあります — ドロップゾーンの下のメッセージを確認してください。", + "admin_vr_media_upload_issues": "メディアのアップロードは完了しましたが、エラーがありました。失敗したファイルにはそれぞれ理由が表示されています。", "admin_vr_media_error_too_large": "このファイルは{max}を超えています。画像を小さくするか、クリップを短く低解像度にしてみてください。", "admin_vr_media_error_bad_type": "対応していないファイル形式です。JPEG・PNG・GIF・WebP・AVIF、または.webmクリップをご利用ください。", "admin_vr_media_error_failed": "アップロードに失敗しました。接続を確認してもう一度お試しください。", diff --git a/playwright.config.ts b/playwright.config.ts index 1b95c74..0543908 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -153,7 +153,7 @@ export default defineConfig({ { name: 'upload', testMatch: UPLOAD_SPEC, - // The five upload specs share one seeded dev server and an admin + // The upload tests share one seeded dev server and an admin // session flow that flakes under parallel load — run them serially. workers: 1, use: { ...devices['Desktop Chrome'], baseURL: `http://localhost:${UPLOAD_PORT}` } diff --git a/src/lib/components/StickerPackForm.svelte b/src/lib/components/StickerPackForm.svelte index 607459f..48b2db4 100644 --- a/src/lib/components/StickerPackForm.svelte +++ b/src/lib/components/StickerPackForm.svelte @@ -4,6 +4,11 @@ import { X, Plus, ArrowLeft, Check, Loader2, GripVertical, UserPlus } from 'lucide-svelte'; import { toast } from '$lib/toast.svelte'; import { DragReorder } from '$lib/drag-reorder.svelte'; + import { dropFiles, partitionByAccept, swallowStrayFileDrop } from '$lib/drop-files'; + import { MAX_BUFFER_BYTES, STICKER_ACCEPT } from '$lib/config'; + // The shared size formatter; it lives in $lib/vr only because that page needed + // it first, and it carries no VR-specific behaviour. + import { formatBytes } from '$lib/vr'; import * as m from '$lib/paraglide/messages'; import StickerMedia from '$lib/components/StickerMedia.svelte'; import NewArtistDialog from '$lib/components/NewArtistDialog.svelte'; @@ -104,10 +109,32 @@ ); let uploading = $state(false); + // Upload start/done announcements for the stickers section's live region: + // the zone label and the new rows show both, but only visually. + let stickerStatus = $state(''); + // Bumped on every status write so {#key} replaces the node inside the live + // region: two single-file drops both say "1 sticker added", and re-assigning + // the text it already holds changes no DOM, so nothing is announced. + let stickerStatusUid = $state(0); + function setStickerStatus(text: string) { + stickerStatus = text; + stickerStatusUid++; + } + // Per-file failure reporting, the same shape the VR media zone uses: a + // multi-file pick partially succeeds often, and a toast both disappears and + // collapses the batch into one line that reads as if everything failed. uid + // keys the {#each}: two same-named files failing the same way are distinct + // rows, so a name+reason key would collide. + let stickerErrorUid = 0; + let stickerErrors = $state< + { uid: number; name: string; reason: 'too-large' | 'bad-type' | 'unscrubbable' | 'failed' }[] + >([]); let published = $state(pack?.published ?? false); let saving = $state(false); - async function uploadFile(file: File): Promise { + // Returns the stored URL, or the response status so the caller can name the + // reason in the error banner instead of a bare "failed". + async function uploadFile(file: File): Promise<{ url: string } | { status: number }> { const fd = new FormData(); fd.append('file', file); // Keep sticker/cover uploads in the stickers/ partition instead of leaking @@ -116,65 +143,111 @@ // until the form is saved — so stickers/ is the correct target for now. fd.append('folder', 'stickers'); const res = await fetch('/api/upload', { method: 'POST', body: fd }); - // 422 is the one failure the operator can act on: the file's metadata - // could not be stripped, and a re-export fixes it (SONA-170). - if (res.status === 422) return 'refused'; - if (!res.ok) return null; - const { url } = (await res.json()) as { url: string }; - return url; + if (!res.ok) return { status: res.status }; + // A 2xx whose body carries no usable url is a failure too: storing it + // would add a row pointing at nothing. Status 0 (no status of its own) + // makes the caller name it a plain "failed". + // The cast validates nothing, so check the type as well as emptiness. + const { url } = (await res.json()) as { url?: unknown }; + return typeof url === 'string' && url ? { url } : { status: 0 }; } - async function uploadStickers(e: Event) { + function uploadStickers(e: Event) { const input = e.currentTarget as HTMLInputElement; const files = [...(input.files ?? [])]; // Clear the picker so choosing the same path again (the re-export a // refusal asks for) fires a change event instead of being dropped. input.value = ''; if (!files.length) return; + // `accept` is a filter the OS dialog can override ("All files"), so a + // picked JPEG can arrive here; partition it the same way a drop is. + const { accepted, rejected } = partitionByAccept(files, STICKER_ACCEPT); + addStickerFiles(accepted, rejected); + } + + // Shared by the file input and the drop attachment; both partition their + // files first, so `rejected` is reported without being uploaded. + async function addStickerFiles(files: File[], rejected: File[] = []) { + // A fresh batch replaces the previous batch's messages: leaving them would + // name files the operator has already dealt with. + stickerErrors = rejected.map((file) => ({ + uid: stickerErrorUid++, + name: file.name, + reason: 'bad-type' as const + })); + if (!files.length) { + // Nothing to upload, but the status region must not keep the last + // batch's text beside a fresh bad-type banner. + if (rejected.length) setStickerStatus(m.admin_pack_upload_issues()); + return; + } uploading = true; + setStickerStatus(m.admin_upload_uploading()); let ok = 0; - let failed = 0; - let refused = 0; try { for (const file of files) { - let url: string | null = null; + // Client-side cap, ahead of the POST: /api/upload would answer 413 + // only after the whole body went up the wire, so an oversized file + // costs the operator the upload before it can be named. + if (file.size > MAX_BUFFER_BYTES) { + stickerErrors = [ + ...stickerErrors, + { uid: stickerErrorUid++, name: file.name, reason: 'too-large' } + ]; + continue; + } + let result: { url: string } | { status: number }; try { - url = await uploadFile(file); + result = await uploadFile(file); } catch { - url = null; + // A thrown fetch (offline, aborted) carries no status of its own. + result = { status: 0 }; } - if (url === 'refused') { - refused++; - failed++; - } else if (url) { + if ('url' in result) { ok++; stickerEntries.push({ uid: nextUid++, - imageUrl: url, + imageUrl: result.url, artistId: '', emojis: '', nsfw: false, - format: file.name.endsWith('.png') ? 'png' : 'webp' + // From the MIME type, not the name: a file may have no extension + // or an uppercase one. Both entry points partition against + // STICKER_ACCEPT, which lists MIME types only, so anything that + // reaches here declared one of the two. + format: file.type.toLowerCase() === 'image/png' ? 'png' : 'webp' }); } else { - failed++; + // Keep the successful uploads; the banner names what didn't land. + stickerErrors = [ + ...stickerErrors, + { + uid: stickerErrorUid++, + name: file.name, + reason: + result.status === 413 + ? 'too-large' + : result.status === 415 + ? 'bad-type' + : result.status === 422 + ? 'unscrubbable' + : 'failed' + } + ]; } } stickerEntries = [...stickerEntries]; } finally { uploading = false; + // Alongside clearing `uploading`, so a throw can't strand the region on + // "Uploading…". The banner names the failed files, so the region only + // has to say the batch finished badly — the same split the VR media + // zone announces. Every file lands as a row or an error line, so a + // batch with no errors always added at least one. + setStickerStatus( + stickerErrors.length > 0 ? m.admin_pack_upload_issues() : m.admin_pack_upload_done({ count: ok }) + ); } - // Keep the successful uploads; surface the failures without discarding them. - // The count is shown whenever the batch was mixed: some file failed for a - // reason other than a refusal, or some file got through alongside a - // refusal. When every file was refused, the count would only repeat the - // number the refusal message carries, so the refusal is shown alone. - if (failed > 0 && (failed > refused || ok > 0)) { - toast.error(m.admin_pack_upload_partial({ ok, total: files.length, failed })); - } - // A refused file has a fix the operator can apply, so say so rather than - // leave it inside the failure count. - if (refused > 0) toast.error(m.admin_pack_upload_unscrubbable({ refused })); } function removeSticker(i: number) { @@ -241,6 +314,10 @@ }); + + + {m.admin_pack_back()} @@ -485,7 +600,13 @@ .page-header h1 { font-size: 22px; margin: 0 0 4px; } .intro { font-size: 13px; color: var(--muted-foreground); max-width: 70ch; margin: 0; } .banner { padding: 12px 16px; border-radius: var(--radius-s); font-size: 13px; margin-bottom: 16px; } - .banner.err { background: rgba(248,113,113,0.12); color: #f87171; } + .banner.err { background: color-mix(in srgb, var(--destructive) 12%, transparent); color: var(--foreground); } + /* Inside a section the flex gap already spaces siblings; the banner's own + margin would double it. The form-level banner above the form keeps its + margin. */ + .section > .banner { margin-bottom: 0; } + .banner-line { margin: 0; overflow-wrap: anywhere; } + .banner-line + .banner-line { margin-top: 6px; } .form { display: flex; flex-direction: column; gap: 32px; max-width: 700px; } .section { display: flex; flex-direction: column; gap: 16px; } h2 { font-size: 16px; font-weight: 600; margin: 0 0 4px; padding-bottom: 8px; border-bottom: 1px solid var(--border); } @@ -514,11 +635,26 @@ .upload-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; border: 2px dashed var(--border); border-radius: var(--radius-s); - color: var(--muted-foreground); cursor: pointer; font-size: 13px; transition: border-color 0.15s; - min-height: 80px; + color: var(--muted-foreground); cursor: pointer; font-size: 13px; + transition: border-color 0.15s, background-color 0.15s, opacity 0.15s; min-height: 80px; } .upload-zone.multi { width: 100%; } .upload-zone:hover { border-color: var(--primary); } + .sr-file { position: absolute; opacity: 0; width: 0; height: 0; } + /* The hidden file input stays keyboard-focusable — surface its focus on the + zone, which is the only thing visible. */ + .upload-zone:has(.sr-file:focus-visible) { outline: 2px solid var(--ring); outline-offset: 2px; } + /* Highlight while a file is dragged over the zone (SONA-216) — same treatment + as the upload page's dropzone. :global because the drop attachment sets the + class imperatively, so Svelte can't see it in the markup. */ + .upload-zone:global(.drag-over) { border-color: var(--primary); background-color: color-mix(in srgb, var(--primary) 5%, transparent); } + /* No pointer-events: none — the drop attachment has to receive dragover/drop + to preventDefault, or a drop while uploading navigates away from the form. + The nested input's own disabled attribute keeps clicks inert. */ + .upload-zone.disabled { opacity: 0.55; cursor: not-allowed; } + /* Keeping pointer events also keeps :hover alive, so hold the resting border + while the zone is busy rather than inviting a click it won't take. */ + .upload-zone.disabled:hover { border-color: var(--border); } .remove-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: none; color: var(--muted-foreground); border: 1px solid var(--border); diff --git a/src/lib/components/VrAvatarForm.svelte b/src/lib/components/VrAvatarForm.svelte index 739668d..caf8b5d 100644 --- a/src/lib/components/VrAvatarForm.svelte +++ b/src/lib/components/VrAvatarForm.svelte @@ -8,8 +8,9 @@ import { cdnImage, rawFallback, THUMB_WIDTH } from '$lib'; import { toast } from '$lib/toast.svelte'; import { DragReorder } from '$lib/drag-reorder.svelte'; + import { dropFiles, partitionByAccept, swallowStrayFileDrop } from '$lib/drop-files'; import { probeDimensions } from '$lib/probe-dimensions'; - import { MAX_BUFFER_BYTES } from '$lib/config'; + import { MAX_BUFFER_BYTES, VR_MEDIA_ACCEPT } from '$lib/config'; import { MAX_VR_MODEL_BYTES, creditRoleLabel, formatBytes, modelFileError, modelFormatLabel, namePlaceholderCharacter, platformLabel } from '$lib/vr'; import * as m from '$lib/paraglide/messages'; @@ -195,6 +196,14 @@ // Upload start/done announcements for the media section's live region // (R2-A10) — visually the dropzone label + rows already show both. let mediaStatus = $state(''); + // Bumped on every status write so {#key} replaces the node inside the live + // region: two single-file drops both say the same thing, and re-assigning the + // text it already holds changes no DOM, so nothing is announced. + let mediaStatusUid = $state(0); + function setMediaStatus(text: string) { + mediaStatus = text; + mediaStatusUid++; + } const mediaReorder = new DragReorder({ count: () => mediaEntries.length, @@ -211,21 +220,36 @@ mediaEntries = mediaEntries.filter((_, idx) => idx !== i); } - const MEDIA_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp,image/avif,video/webm'; - // /api/upload's buffered cap — the shared constant, not a hardcoded twin. - const MAX_MEDIA_BYTES = MAX_BUFFER_BYTES; - - async function onMediaPicked(e: Event) { + function onMediaPicked(e: Event) { const input = e.currentTarget as HTMLInputElement; const files = [...(input.files ?? [])]; input.value = ''; if (!files.length) return; - mediaErrors = []; + // `accept` is a filter the OS dialog can override ("All files"), so a + // picked .txt would otherwise upload its whole body just to collect a 415. + const { accepted, rejected } = partitionByAccept(files, VR_MEDIA_ACCEPT); + uploadMedia(accepted, rejected); + } + + // Shared by the file input and the drop attachment; both partition their + // files first, so `rejected` is reported without being uploaded. + async function uploadMedia(files: File[], rejected: File[] = []) { + mediaErrors = rejected.map((file) => ({ + uid: mediaErrorUid++, + name: file.name, + reason: 'bad-type' as const + })); + if (!files.length) { + // Nothing to upload, but the status region must not keep last batch's + // text beside a fresh bad-type banner. + if (rejected.length) setMediaStatus(m.admin_vr_media_upload_issues()); + return; + } mediaUploading = true; - mediaStatus = m.admin_upload_uploading(); + setMediaStatus(m.admin_upload_uploading()); try { for (const file of files) { - if (file.size > MAX_MEDIA_BYTES) { + if (file.size > MAX_BUFFER_BYTES) { mediaErrors = [...mediaErrors, { uid: mediaErrorUid++, name: file.name, reason: 'too-large' }]; continue; } @@ -262,7 +286,15 @@ // Per-file guard: a malformed response body or probe failure records // that file's error and lets the rest of the batch continue. try { - const { url } = (await res.json()) as { url: string }; + const { url } = (await res.json()) as { url?: unknown }; + // A 2xx whose body carries no usable url is a failure too (same + // check as the sticker form): a row pointing at nothing renders a + // broken tile and is rejected at save time with no file named. + // The cast validates nothing, so check the type as well. + if (typeof url !== 'string' || !url) { + mediaErrors = [...mediaErrors, { uid: mediaErrorUid++, name: file.name, reason: 'failed' }]; + continue; + } const { width, height } = await probeDimensions(file); mediaEntries = [ ...mediaEntries, @@ -280,8 +312,9 @@ } } finally { mediaUploading = false; - mediaStatus = - mediaErrors.length > 0 ? m.admin_vr_media_upload_issues() : m.admin_vr_media_upload_done(); + setMediaStatus( + mediaErrors.length > 0 ? m.admin_vr_media_upload_issues() : m.admin_vr_media_upload_done() + ); } } @@ -316,10 +349,21 @@ let modelSizeBytes = $state(avatar?.modelSizeBytes ?? null); let modelFilename = $state(avatar?.modelUrl ? (avatar.modelUrl.split('/').pop() ?? '') : ''); + // Shared by the file inputs and the drop attachment, so a drop accepts + // exactly what the picker offers. + const MODEL_ACCEPT = '.vrm,.fbx'; + let uploading = $state(false); let uploadLoaded = $state(0); let uploadTotal = $state(0); let uploadError = $state<'too-large' | 'bad-type' | 'failed' | null>(null); + // Bumped on every error so {#key} remounts the role="alert" banner: setting + // uploadError to the value it already holds would otherwise announce nothing. + let uploadErrorUid = $state(0); + function setUploadError(kind: 'too-large' | 'bad-type' | 'failed') { + uploadError = kind; + uploadErrorUid++; + } let errorFileSize = $state(0); // Live-region text for the model upload, throttled to 10% steps so a screen @@ -340,13 +384,27 @@ const file = input.files?.[0]; input.value = ''; if (!file) return; + uploadModel(file); + } + + // Drops route here too. A drop of several files takes the first one: the + // avatar has a single model. + function onModelDropped(files: File[], rejected: File[]) { + if (files.length) uploadModel(files[0]); + // Wrong extension: the same banner modelFileError would raise, no request. + else if (rejected.length) setUploadError('bad-type'); + } + + function uploadModel(file: File) { + // Clearing skips the uid bump on purpose: an empty banner needs no + // announcement, and the next error goes through setUploadError. uploadError = null; // Client-side mirror of the server guards ($lib/vr modelFileError), for // instant feedback — the endpoint re-checks all of it. const fileError = modelFileError(file); if (fileError) { if (fileError === 'too-large') errorFileSize = file.size; - uploadError = fileError; + setUploadError(fileError); return; } uploading = true; @@ -375,16 +433,16 @@ } if (xhr.status === 413) { errorFileSize = file.size; - uploadError = 'too-large'; + setUploadError('too-large'); } else if (xhr.status === 415) { - uploadError = 'bad-type'; + setUploadError('bad-type'); } else { - uploadError = 'failed'; + setUploadError('failed'); } }; xhr.onerror = () => { uploading = false; - uploadError = 'failed'; + setUploadError('failed'); }; xhr.send(file); } @@ -413,6 +471,10 @@ ); + + + {m.admin_vr_back()}
-
{#if modelUrl !== (avatar?.modelUrl ?? '')} @@ -603,22 +669,28 @@ {/if} {:else} -