From 7bed502f13fdca144e37d56df120859aa3a66f1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:14:57 +0000 Subject: [PATCH 1/4] fix(web): theme-aware manage page, proper i18n keys, remove dead code - Rewrite note manage page with CSS variable theming (was hardcoded slate/Tailwind colors that ignored the light/dark theme toggle) - Fix password visibility toggle using wrong i18n keys (theme_light/theme_dark -> show_password/hide_password) - Localize the password generate button and Markdown toolbar titles (were hardcoded English strings) - Add the new keys to all 10 locales (key parity preserved) - Remove duplicate maxTotalSize derived value (identical to maxFileSize) - Remove unused SkeletonLoader component https://claude.ai/code/session_011hi4i5yS2jZwA74WwtZnw4 --- .../src/lib/components/MarkdownEditor.svelte | 43 +++++-- .../src/lib/components/SkeletonLoader.svelte | 14 --- apps/web/src/routes/+page.svelte | 13 +- .../src/routes/note/[id]/manage/+page.svelte | 118 ++++++++++++++---- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ja.json | 11 +- messages/ko.json | 11 +- messages/pt.json | 11 +- messages/ru.json | 11 +- messages/zh.json | 11 +- packages/shared/src/test-vectors/vectors.json | 2 +- 15 files changed, 237 insertions(+), 63 deletions(-) delete mode 100644 apps/web/src/lib/components/SkeletonLoader.svelte diff --git a/apps/web/src/lib/components/MarkdownEditor.svelte b/apps/web/src/lib/components/MarkdownEditor.svelte index 6fcc7f7..6da2b74 100644 --- a/apps/web/src/lib/components/MarkdownEditor.svelte +++ b/apps/web/src/lib/components/MarkdownEditor.svelte @@ -170,22 +170,51 @@ const toolbarBtnStyle = {#if activeTab === "write"}
- - - - - -
diff --git a/apps/web/src/lib/components/SkeletonLoader.svelte b/apps/web/src/lib/components/SkeletonLoader.svelte deleted file mode 100644 index 36d8611..0000000 --- a/apps/web/src/lib/components/SkeletonLoader.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -
-
-
-
-
-
diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 4847dbd..a80b8a9 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -47,7 +47,6 @@ let fileInputEl: HTMLInputElement | undefined = $state(); const config = $derived(getConfig()); const maxFileSize = $derived(config.maxChunkedFileSize || config.maxFileSize); -const maxTotalSize = $derived(config.maxChunkedFileSize || config.maxFileSize); const maxFilesPerNote = $derived(config.maxFilesPerNote); let fileError = $state(""); @@ -161,8 +160,8 @@ function addFiles(newFiles: File[]) { const remaining = maxFilesPerNote - files.length; const candidates = [...files, ...newFiles.slice(0, remaining)]; const totalSize = candidates.reduce((sum, f) => sum + f.size, 0); - if (totalSize > maxTotalSize) { - fileError = t("error_total_too_large", { size: formatSize(maxTotalSize) }); + if (totalSize > maxFileSize) { + fileError = t("error_total_too_large", { size: formatSize(maxFileSize) }); return; } @@ -852,7 +851,7 @@ const readsText = $derived( > {t("files_limit", { count: maxFilesPerNote, - size: formatSize(maxTotalSize), + size: formatSize(maxFileSize), })} @@ -964,7 +963,8 @@ const readsText = $derived( class="inline-flex items-center justify-center rounded-md border-0 bg-transparent" style:color="var(--muted)" style:padding="6px 8px" - title="Generate" + title={t("generate")} + aria-label={t("generate")} > @@ -974,7 +974,8 @@ const readsText = $derived( class="inline-flex items-center justify-center rounded-md border-0 bg-transparent" style:color="var(--muted)" style:padding="6px 8px" - title={showPassword ? t("theme_light") : t("theme_dark")} + title={showPassword ? t("hide_password") : t("show_password")} + aria-label={showPassword ? t("hide_password") : t("show_password")} > diff --git a/apps/web/src/routes/note/[id]/manage/+page.svelte b/apps/web/src/routes/note/[id]/manage/+page.svelte index 3e6d221..972677a 100644 --- a/apps/web/src/routes/note/[id]/manage/+page.svelte +++ b/apps/web/src/routes/note/[id]/manage/+page.svelte @@ -1,5 +1,6 @@ + +
  • + {#if previewUrl} + {#if category === "image"} + {name} + {:else if category === "video"} + + {:else if category === "audio"} + + {:else if category === "pdf"} + + {/if} + {/if} +
    + +
    +

    + {name} +

    +

    + {type} · {formatSize(size)} +

    +
    + +
    +
  • diff --git a/apps/web/src/lib/components/FileDropZone.svelte b/apps/web/src/lib/components/FileDropZone.svelte new file mode 100644 index 0000000..6ab406e --- /dev/null +++ b/apps/web/src/lib/components/FileDropZone.svelte @@ -0,0 +1,165 @@ + + +
    + + + {t("files_label")} + +
    fileInputEl?.click()} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInputEl?.click(); + } + }} + ondragover={(e) => { + e.preventDefault(); + isDragging = true; + }} + ondragleave={() => { + isDragging = false; + }} + ondrop={handleDrop} + class="w-full cursor-pointer rounded-xl text-left transition-all" + style:background={isDragging ? "var(--accent-soft)" : "var(--bg-2)"} + style:border={`1px dashed ${isDragging ? "var(--accent)" : "var(--line-2)"}`} + style:padding="20px" + aria-label={t("files_drop")} + > + + {#if files.length === 0} +
    +
    +
    + {t("files_drop_1")} + {t("files_drop_2")} +
    +
    + {t("files_limit", { + count: maxFilesPerNote, + size: formatSize(maxFileSize), + })} +
    +
    + +
    + {:else} +
    + {#each files as f, i (f.name + i)} +
    + + + {f.name} + + + {formatSize(f.size)} + + +
    + {/each} +
    + {t("files_add_more")} ({files.length}/{maxFilesPerNote}) +
    +
    + {/if} +
    + {#if fileError} +

    {fileError}

    + {/if} +
    diff --git a/apps/web/src/lib/components/SecuritySettings.svelte b/apps/web/src/lib/components/SecuritySettings.svelte new file mode 100644 index 0000000..53b022c --- /dev/null +++ b/apps/web/src/lib/components/SecuritySettings.svelte @@ -0,0 +1,187 @@ + + +
    +
    + + {t("security_settings")} +
    + +
    +
    + +
    + +
    + + +
    +
    + {#if password} +
    +
    +
    +
    + {pwStrength.labelKey ? t(pwStrength.labelKey) : ""} +
    + {/if} + + {t("password_add_hint")} + +
    + +
    +
    + + +
    + +
    + + +
    +
    +
    +
    diff --git a/apps/web/src/lib/components/SuccessView.svelte b/apps/web/src/lib/components/SuccessView.svelte new file mode 100644 index 0000000..6a2a26c --- /dev/null +++ b/apps/web/src/lib/components/SuccessView.svelte @@ -0,0 +1,409 @@ + + +
    + +
    + + + {t("ok_eyebrow")} + +

    + {t("ok_hero_1")} + {t("ok_hero_unique")}.
    + {t("ok_hero_2")} + {t("ok_hero_3")} +

    +

    + {password ? t("ok_hero_sub_pw") : t("ok_hero_sub_nopw")} +

    +
    + + {#if clipboardError} + + {/if} + + +
    +
    + + + {t("ok_legend_server")} + + + + {t("ok_legend_key")} + +
    +
    +
    + {shareUrlParts.protocol}{shareUrlParts.host}{shareUrlParts.fragment} +
    + +
    +
    + + + + + {t("ok_email")} + + + + + {t("ok_preview")} + +
    +
    + + {#if showQR && qrCodeUrl} +
    + {t("qr_alt")} +

    + {t("ok_qr_hint")} +

    +
    + {/if} + + {#if password} +
    + + + +
    +
    {t("ok_pw_title")}
    +
    + {t("ok_pw_hint")} +
    +
    + +
    + {/if} + + +
    +
    + + {t("ok_facts_title")} +
    +
      +
    • + + + + {t("ok_facts_expiry")} + {t(expiryLabelKey)} +
    • +
    • + + + + {t("ok_facts_reads")} + {readsText} +
    • + {#if fileCount > 0} +
    • + + + + {t("ok_facts_files")} + {t("files_count", { count: fileCount })} +
    • + {/if} +
    +
    + + {#if manageUrl} +
    + + {t("delete_label")} + +
    +
    + + +
    +

    + {t("delete_warning")} +

    +
    +
    + {/if} + + +
    diff --git a/apps/web/src/lib/utils/__tests__/clipboard.test.ts b/apps/web/src/lib/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000..248402b --- /dev/null +++ b/apps/web/src/lib/utils/__tests__/clipboard.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { copyWithFeedback } from "../clipboard.js"; + +describe("copyWithFeedback", () => { + const writeText = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + writeText.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("copies text and toggles the copied flag", async () => { + writeText.mockResolvedValue(undefined); + const setCopied = vi.fn(); + + const ok = await copyWithFeedback("secret-url", setCopied); + + expect(ok).toBe(true); + expect(writeText).toHaveBeenCalledWith("secret-url"); + expect(setCopied).toHaveBeenCalledWith(true); + + vi.advanceTimersByTime(2000); + expect(setCopied).toHaveBeenCalledWith(false); + }); + + it("respects a custom reset delay", async () => { + writeText.mockResolvedValue(undefined); + const setCopied = vi.fn(); + + await copyWithFeedback("text", setCopied, 5000); + expect(setCopied).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2000); + expect(setCopied).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(3000); + expect(setCopied).toHaveBeenCalledTimes(2); + expect(setCopied).toHaveBeenLastCalledWith(false); + }); + + it("returns false when the clipboard API rejects", async () => { + writeText.mockRejectedValue(new Error("denied")); + const setCopied = vi.fn(); + + const ok = await copyWithFeedback("text", setCopied); + + expect(ok).toBe(false); + expect(setCopied).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/utils/__tests__/fileType.test.ts b/apps/web/src/lib/utils/__tests__/fileType.test.ts new file mode 100644 index 0000000..d395341 --- /dev/null +++ b/apps/web/src/lib/utils/__tests__/fileType.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { getFileCategory, isPreviewable } from "../fileType.js"; + +describe("getFileCategory", () => { + it("categorizes image MIME types", () => { + expect(getFileCategory("image/png")).toBe("image"); + expect(getFileCategory("image/jpeg")).toBe("image"); + expect(getFileCategory("image/svg+xml")).toBe("image"); + }); + + it("categorizes video MIME types", () => { + expect(getFileCategory("video/mp4")).toBe("video"); + expect(getFileCategory("video/webm")).toBe("video"); + }); + + it("categorizes audio MIME types", () => { + expect(getFileCategory("audio/mpeg")).toBe("audio"); + expect(getFileCategory("audio/ogg")).toBe("audio"); + }); + + it("categorizes PDF", () => { + expect(getFileCategory("application/pdf")).toBe("pdf"); + }); + + it("categorizes everything else as other", () => { + expect(getFileCategory("application/zip")).toBe("other"); + expect(getFileCategory("text/plain")).toBe("other"); + expect(getFileCategory("application/octet-stream")).toBe("other"); + expect(getFileCategory("")).toBe("other"); + }); +}); + +describe("isPreviewable", () => { + it("returns true for previewable types", () => { + expect(isPreviewable("image/png")).toBe(true); + expect(isPreviewable("video/mp4")).toBe(true); + expect(isPreviewable("audio/mpeg")).toBe(true); + expect(isPreviewable("application/pdf")).toBe(true); + }); + + it("returns false for non-previewable types", () => { + expect(isPreviewable("application/zip")).toBe(false); + expect(isPreviewable("text/plain")).toBe(false); + expect(isPreviewable("")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/utils/__tests__/password.test.ts b/apps/web/src/lib/utils/__tests__/password.test.ts new file mode 100644 index 0000000..af45d95 --- /dev/null +++ b/apps/web/src/lib/utils/__tests__/password.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { generatePassword, getPasswordStrength } from "../password.js"; + +describe("getPasswordStrength", () => { + it("returns a neutral result for an empty password", () => { + const result = getPasswordStrength(""); + expect(result.score).toBe(0); + expect(result.labelKey).toBeNull(); + expect(result.color).toBe("var(--muted-2)"); + }); + + it("scores a very weak password", () => { + // Short, lowercase only: 0 criteria met + const result = getPasswordStrength("abc"); + expect(result.score).toBe(0); + expect(result.labelKey).toBeNull(); + }); + + it("scores a weak password", () => { + // >= 8 chars only + const result = getPasswordStrength("abcdefgh"); + expect(result.score).toBe(1); + expect(result.labelKey).toBe("str_vweak"); + }); + + it("scores a medium password", () => { + // >= 8 chars, mixed case, digits + const result = getPasswordStrength("Abcdef12"); + expect(result.score).toBe(3); + expect(result.labelKey).toBe("str_ok"); + }); + + it("scores a strong password", () => { + // >= 14 chars, mixed case, digits + const result = getPasswordStrength("Abcdefghijkl12"); + expect(result.score).toBe(4); + expect(result.labelKey).toBe("str_strong"); + }); + + it("scores an excellent password with all criteria", () => { + // >= 14 chars, mixed case, digits, symbols + const result = getPasswordStrength("Abcdefghijk12!@"); + expect(result.score).toBe(5); + expect(result.labelKey).toBe("str_exc"); + }); + + it("assigns a color for every non-empty password", () => { + expect(getPasswordStrength("abc").color).toBe("#ef4444"); + expect(getPasswordStrength("Abcdefghijk12!@").color).toBe("#10b981"); + }); +}); + +describe("generatePassword", () => { + it("generates a password of the default length", () => { + expect(generatePassword()).toHaveLength(20); + }); + + it("generates a password of a custom length", () => { + expect(generatePassword(32)).toHaveLength(32); + expect(generatePassword(8)).toHaveLength(8); + }); + + it("only uses display-safe characters", () => { + const password = generatePassword(100); + expect(password).toMatch(/^[A-HJ-NP-Za-hj-km-z2-9!@#$%]+$/); + // Ambiguous characters are excluded + expect(password).not.toMatch(/[0OIl1]/); + }); + + it("generates different passwords on each call", () => { + expect(generatePassword()).not.toBe(generatePassword()); + }); + + it("rates its own output as excellent", () => { + const result = getPasswordStrength(generatePassword()); + expect(result.score).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/apps/web/src/lib/utils/clipboard.ts b/apps/web/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..5ab7eae --- /dev/null +++ b/apps/web/src/lib/utils/clipboard.ts @@ -0,0 +1,23 @@ +/** + * Copy text to the clipboard and report a transient "copied" state. + * + * Calls `setCopied(true)` on success, then `setCopied(false)` after `resetMs`. + * Returns false when the Clipboard API is unavailable or rejects, so callers + * can surface an error message. + */ +export async function copyWithFeedback( + text: string, + setCopied: (copied: boolean) => void, + resetMs = 2000, +): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + return false; + } + setCopied(true); + setTimeout(() => { + setCopied(false); + }, resetMs); + return true; +} diff --git a/apps/web/src/lib/utils/fileType.ts b/apps/web/src/lib/utils/fileType.ts new file mode 100644 index 0000000..bdcafeb --- /dev/null +++ b/apps/web/src/lib/utils/fileType.ts @@ -0,0 +1,15 @@ +export type FileCategory = "image" | "video" | "audio" | "pdf" | "other"; + +/** Categorize a MIME type for preview rendering. */ +export function getFileCategory(mimeType: string): FileCategory { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + if (mimeType === "application/pdf") return "pdf"; + return "other"; +} + +/** Whether the browser can render an inline preview for this MIME type. */ +export function isPreviewable(mimeType: string): boolean { + return getFileCategory(mimeType) !== "other"; +} diff --git a/apps/web/src/lib/utils/password.ts b/apps/web/src/lib/utils/password.ts new file mode 100644 index 0000000..a2d006b --- /dev/null +++ b/apps/web/src/lib/utils/password.ts @@ -0,0 +1,50 @@ +export type StrengthLabelKey = "str_vweak" | "str_weak" | "str_ok" | "str_strong" | "str_exc"; + +export interface PasswordStrength { + readonly score: number; + readonly labelKey: StrengthLabelKey | null; + readonly color: string; +} + +const STRENGTH_KEYS: readonly StrengthLabelKey[] = [ + "str_vweak", + "str_weak", + "str_ok", + "str_strong", + "str_exc", +]; +const STRENGTH_COLORS: readonly string[] = ["#ef4444", "#f97316", "#eab308", "#84cc16", "#10b981"]; + +/** Score a password from 0 to 5 based on length and character variety. */ +export function getPasswordStrength(password: string): PasswordStrength { + if (!password) return { score: 0, labelKey: null, color: "var(--muted-2)" }; + + let score = 0; + if (password.length >= 8) score++; + if (password.length >= 14) score++; + if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++; + if (/\d/.test(password)) score++; + if (/[^\w\s]/.test(password)) score++; + + const idx = Math.min(score - 1, 4); + return { + score, + labelKey: idx >= 0 ? (STRENGTH_KEYS[idx] ?? null) : null, + color: idx >= 0 ? (STRENGTH_COLORS[idx] ?? "#ef4444") : "#ef4444", + }; +} + +// Ambiguous characters (0/O, 1/l/I) are excluded so generated passwords +// can be read aloud or retyped without confusion. +const PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%"; + +/** Generate a random password from display-safe characters. */ +export function generatePassword(length = 20): string { + const values = new Uint32Array(length); + crypto.getRandomValues(values); + let password = ""; + for (let i = 0; i < length; i++) { + password += PASSWORD_CHARS[(values[i] ?? 0) % PASSWORD_CHARS.length]; + } + return password; +} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index a80b8a9..3700fd0 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,18 +1,20 @@ @@ -311,364 +177,16 @@ const readsText = $derived( {#if shareUrl} -
    - -
    - - - {t("ok_eyebrow")} - -

    - {t("ok_hero_1")} - {t("ok_hero_unique")}.
    - {t("ok_hero_2")} - {t("ok_hero_3")} -

    -

    - {password ? t("ok_hero_sub_pw") : t("ok_hero_sub_nopw")} -

    -
    - - -
    -
    - - - {t("ok_legend_server")} - - - - {t("ok_legend_key")} - -
    -
    -
    - {#if shareUrlParts} - {shareUrlParts.protocol}{shareUrlParts.host}{shareUrlParts.fragment} - {/if} -
    - -
    -
    - - - - - {t("ok_email")} - - - - - {t("ok_preview")} - -
    -
    - - {#if showQR && qrCodeUrl} -
    - {t("qr_alt")} -

    - {t("ok_qr_hint")} -

    -
    - {/if} - - {#if password} -
    - - - -
    -
    {t("ok_pw_title")}
    -
    - {t("ok_pw_hint")} -
    -
    - -
    - {/if} - - -
    -
    - - {t("ok_facts_title")} -
    -
      -
    • - - - - {t("ok_facts_expiry")} - {t(expiryLabelKey)} -
    • -
    • - - - - {t("ok_facts_reads")} - {readsText} -
    • - {#if files.length > 0} -
    • - - - - {t("ok_facts_files")} - {t("files_count", { count: files.length })} -
    • - {/if} -
    -
    - - {#if manageUrl} -
    - - {t("delete_label")} - -
    -
    - - -
    -

    - {t("delete_warning")} -

    -
    -
    - {/if} - - -
    + {:else}
    { @@ -795,276 +313,11 @@ const readsText = $derived( {#if contentMode !== "secret"} -
    - - -
    - {/each} -
    - {t("files_add_more")} ({files.length}/{maxFilesPerNote}) -
    - - {/if} - - {#if fileError} -

    {fileError}

    - {/if} - + {/if} -
    -
    - - {t("security_settings")} -
    - -
    -
    - -
    - -
    - - -
    -
    - {#if password} -
    -
    -
    -
    - {pwStrength.label} -
    - {/if} - - {t("password_add_hint")} - -
    - -
    -
    - - -
    - -
    - - -
    -
    -
    -
    + {#if isSubmitting}
    diff --git a/apps/web/src/routes/note/[id]/+page.svelte b/apps/web/src/routes/note/[id]/+page.svelte index bddaa30..31bc9b1 100644 --- a/apps/web/src/routes/note/[id]/+page.svelte +++ b/apps/web/src/routes/note/[id]/+page.svelte @@ -4,13 +4,15 @@ import type { NotePayload } from "@secret/shared"; import { onMount } from "svelte"; import { fade, fly } from "svelte/transition"; import { page } from "$app/state"; +import FileCard from "$lib/components/FileCard.svelte"; import Icon from "$lib/components/Icon.svelte"; import StepProgress from "$lib/components/StepProgress.svelte"; import { getClient } from "$lib/client"; import { getConfig } from "$lib/config.svelte"; import { formatDateTime, t } from "$lib/i18n/index.svelte"; import { setStep } from "$lib/steps.svelte"; -import { formatSize } from "$lib/utils/format"; +import { copyWithFeedback } from "$lib/utils/clipboard"; +import { isPreviewable } from "$lib/utils/fileType"; interface NoteInfo { hasPassword: boolean; @@ -85,15 +87,9 @@ const showPwInput = $derived( const showPrimaryCta = $derived(status.state === "ready" && (!isBurn || burnAccepted)); async function copyText(text: string) { - try { - await navigator.clipboard.writeText(text); - copied = true; - setTimeout(() => { - copied = false; - }, 2000); - } catch { - /* clipboard API unavailable */ - } + await copyWithFeedback(text, (v) => { + copied = v; + }); } onMount(() => { @@ -177,37 +173,6 @@ async function handleDecrypt() { } } } - -function downloadFile(name: string, type: string, d: Uint8Array) { - const blob = new Blob([d] as BlobPart[], { type }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = name; - a.click(); - URL.revokeObjectURL(url); -} - -function isPreviewable(type: string): boolean { - return ( - type.startsWith("image/") || - type.startsWith("video/") || - type.startsWith("audio/") || - type === "application/pdf" - ); -} -function isImage(type: string): boolean { - return type.startsWith("image/"); -} -function isVideo(type: string): boolean { - return type.startsWith("video/"); -} -function isAudio(type: string): boolean { - return type.startsWith("audio/"); -} -function isPdf(type: string): boolean { - return type === "application/pdf"; -} @@ -467,8 +432,7 @@ function isPdf(type: string): boolean {
      {#each status.payload.files as file, i (file.name + i)} -
    • - {#if isImage(file.type) && status.previewUrls[i]} - {file.name} - {:else if isVideo(file.type) && status.previewUrls[i]} - - {:else if isAudio(file.type) && status.previewUrls[i]} - - {:else if isPdf(file.type) && status.previewUrls[i]} - - {/if} -
      - -
      -

      - {file.name} -

      -

      - {file.type} · {formatSize(file.size)} -

      -
      - -
      -
    • + )} + previewUrl={status.previewUrls[i] ?? ""} + index={i} + /> {/each}