diff --git a/scripts/i18n-coverage.mjs b/scripts/i18n-coverage.mjs index fc289f4..efd496d 100755 --- a/scripts/i18n-coverage.mjs +++ b/scripts/i18n-coverage.mjs @@ -20,6 +20,19 @@ const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hi as dividers. Counting these as untranslated would put a floor under the number that no amount of work could reach. */ const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u; +/* + * Text that is deliberately not translated is not "remaining work". Counting + * it put a floor under the number that no amount of effort could reach -- the + * report sat at 21 with only 6 real items left, which makes the number + * something to argue with rather than act on. Same rule the codemod uses. + */ +const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]); +const optedOut = (node, src) => { + const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null; + return Boolean(opening?.attributes.properties.some((a) => + ts.isJsxAttribute(a) && a.name.getText(src) === "translate" && + a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no")); +}; const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__")); const rows = []; @@ -31,11 +44,15 @@ for (const file of files) { let left = 0; const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length; const visit = (node) => { + if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return; if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++; if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) { const i = node.initializer; const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null); - if (lit && lit.text.trim().length > 1) left++; + // The same prose test the text nodes get. Without it, placeholders that + // are format examples -- "123456" for a one-time code, "+1 555 0100" for + // a phone -- counted as untranslated work forever. + if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++; } ts.forEachChild(node, visit); }; diff --git a/scripts/i18n-extract.mjs b/scripts/i18n-extract.mjs index 5985ff4..9960137 100644 --- a/scripts/i18n-extract.mjs +++ b/scripts/i18n-extract.mjs @@ -76,8 +76,21 @@ for (const file of files) { const raw = c.text; const body = raw.trim(); if (body.length < 2 || NOT_PROSE.test(body)) continue; - // Split around an interpolation: the fragments are not sentences. - if (meaningful.length > 1) { + /* + * "Split around an interpolation" is the dangerous case, and it is + * narrower than "has siblings". ` New rule` is a phrase next + * to an icon: wrapping it alone is correct, and refusing it left a + * third of the remaining work to be done by hand for no reason. + * `Your script “{name}” was written by hand` is the real thing -- + * a sibling that renders text, so the fragments are not sentences. + */ + const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => { + let jsx = false; + const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); }; + w(k.expression); + return jsx; + })()); + if (textSibling) { const { line } = src.getLineAndCharacterOfPosition(c.getStart(src)); skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) }); continue; diff --git a/web/src/lib/__tests__/i18n.test.ts b/web/src/lib/__tests__/i18n.test.tsx similarity index 65% rename from web/src/lib/__tests__/i18n.test.ts rename to web/src/lib/__tests__/i18n.test.tsx index 7a68b5d..24fe203 100644 --- a/web/src/lib/__tests__/i18n.test.ts +++ b/web/src/lib/__tests__/i18n.test.tsx @@ -1,8 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; -import { currentLanguage, interpolate, plural, setCatalog, t, type Catalog } from "@/lib/i18n"; +import { renderToStaticMarkup } from "react-dom/server"; +import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n"; const de: Catalog = { - strings: { "Archive": "Archivieren", "Move {n} to {folder}": "{n} nach {folder} verschieben" }, + strings: { + "Archive": "Archivieren", + "Move {n} to {folder}": "{n} nach {folder} verschieben", + // German puts the parts in a different order, which is the whole reason + // the element is a named hole rather than a split sentence. + "Open {scheme} links here": "{scheme}-Links hier öffnen", + }, plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } }, }; /* Russian is the reason plural() does not take (one, other): it needs three @@ -77,3 +84,28 @@ describe("plural", () => { .toBe("2 messages in Inbox"); }); }); + +describe("tNode", () => { + const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}); + + it("keeps an element inside the sentence", () => { + expect(render(tNode("Open {scheme} links here", { scheme: mailto: }))) + .toBe("Open mailto: links here"); + }); + + it("lets a translator move the element", () => { + // Splitting the sentence into two t() calls could not do this: the + // fragments would render in the English order whatever the catalogue said. + setCatalog("de", de); + expect(render(tNode("Open {scheme} links here", { scheme: mailto: }))) + .toBe("mailto:-Links hier öffnen"); + }); + + it("leaves a placeholder alone when nothing is supplied for it", () => { + expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here"); + }); + + it("takes plain variables alongside elements", () => { + expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x"); + }); +}); diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index c33a899..51af7b9 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { createElement, Fragment, useSyncExternalStore, type ReactNode } from "react"; import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages"; /** @@ -83,6 +83,42 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string { return interpolate(entry[category] ?? entry.other, { n, ...vars }); } +/** + * A translated sentence with elements inside it. + * + * Some sentences have a `` or a `` in the middle of them, and the + * two obvious approaches are both wrong. Splitting the sentence into two `t()` + * calls hands a translator "This browser cannot register apps for" and "links, + * in particular…", which are not sentences and cannot be reordered into a + * language that puts the verb somewhere else. Dropping the element and + * interpolating plain text keeps the sentence whole but loses the monospace + * that told the reader it was a literal. + * + * So the sentence stays whole and the elements are placeholders in it: + * + * tNode("Open {scheme} links in ihasmail.", { scheme: mailto: }) + * + * A translator sees one sentence with a named hole and can put the hole + * wherever their language wants it. + */ +export function tNode(source: string, parts: Record, vars?: Vars): ReactNode { + const translated = interpolate(current.strings[source] ?? source, vars); + const out: ReactNode[] = []; + let last = 0; + const re = /\{(\w+)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(translated))) { + if (!Object.prototype.hasOwnProperty.call(parts, m[1]!)) continue; + if (m.index > last) out.push(translated.slice(last, m.index)); + // Keyed, because this is an array and React asks; the index is stable for + // a given rendering of a given sentence. + out.push(createElement(Fragment, { key: `${m[1]}-${m.index}` }, parts[m[1]!])); + last = m.index + m[0].length; + } + if (last < translated.length) out.push(translated.slice(last)); + return out; +} + /** The language in force, for anything that needs the tag itself. */ export function currentLanguage(): string { return currentTag; diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 42c1243..3a0eb7b 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -168,19 +168,23 @@ export function AppShell({ children }: { children: ReactNode }) { @@ -209,7 +213,7 @@ function QuotaBar() {
- {formatSize(q.used)} of {formatSize(q.hardLimit)} + {t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
diff --git a/web/src/views/SearchBar.tsx b/web/src/views/SearchBar.tsx index 4440f62..ac77141 100644 --- a/web/src/views/SearchBar.tsx +++ b/web/src/views/SearchBar.tsx @@ -85,8 +85,8 @@ export function SearchBar() {
- - + +
diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index 356f38c..85ce741 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -116,7 +116,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: {canEdit && ( <> - Category + {t("Category")} {categories.map((c) => ( {c.name}} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} /> ))} diff --git a/web/src/views/calendar/CalendarDialog.tsx b/web/src/views/calendar/CalendarDialog.tsx index f22c83e..5a7e4f1 100644 --- a/web/src/views/calendar/CalendarDialog.tsx +++ b/web/src/views/calendar/CalendarDialog.tsx @@ -37,7 +37,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial setDescription(e.target.value)} />
diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 914a580..d48a50e 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -130,7 +130,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: ))}
- {!isMobile && } + {!isMobile && } {cal.error &&
{cal.error}
} {effectiveView === "month" && go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />} @@ -169,7 +169,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}> { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()} {shown.map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)} - {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more} + {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}}
); })} diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index 5288b28..387b316 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -285,7 +285,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle )}
- + {!allDay && ( { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}> - + - + @@ -342,7 +342,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
setVurl(e.target.value)} placeholder={translate("https://meet.example.com/…")} />
- +
@@ -351,7 +351,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {Object.keys(fb).length > 0 && (
-
Availability on {formatNumericDate(start)}
+
{translate("Availability on {date}", { date: formatNumericDate(start) })}
{attendees.filter((a) => fb[a.email]).map((a) => (
{a.name ?? a.email} @@ -383,7 +383,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
))} - +
@@ -400,7 +400,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {categories.map((c) => )} -
{color && }
+
{color && }
)} diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 970ad3f..9d23d05 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -99,9 +99,9 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns {myKeys.length > 0 && !isOrganizer && (
{t("Going?")} - - - + + +
)} diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 3c777fa..0aa84b9 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -246,7 +246,7 @@ export function Composer({ draft }: { draft: Draft }) {
diff --git a/web/src/views/compose/FilePicker.tsx b/web/src/views/compose/FilePicker.tsx index 2a754a1..663ce7f 100644 --- a/web/src/views/compose/FilePicker.tsx +++ b/web/src/views/compose/FilePicker.tsx @@ -73,7 +73,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile {files.sharedAccounts.length > 0 && (
{files.sharedAccounts.map((a) => (
{!ownBooks.length && !subscribed.length && ( -

No address books yet.

+

{t("No address books yet.")}

)} ); diff --git a/web/src/views/compose/SchedulePicker.tsx b/web/src/views/compose/SchedulePicker.tsx index 6a7f1f4..8f88f6d 100644 --- a/web/src/views/compose/SchedulePicker.tsx +++ b/web/src/views/compose/SchedulePicker.tsx @@ -58,8 +58,7 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {

{error}

) : (

- The message waits on the server, so it goes out whether or not ihasmail is open. - {maxMs > 0 && ` This server holds a message for up to ${describeSpan(maxMs)}.`} + {`${t("The message waits on the server, so it goes out whether or not ihasmail is open.")}${maxMs > 0 ? ` ${t("This server holds a message for up to {span}.", { span: describeSpan(maxMs) })}` : ""}`}

)} diff --git a/web/src/views/contacts/ContactEditor.tsx b/web/src/views/contacts/ContactEditor.tsx index 90c621a..43e0080 100644 --- a/web/src/views/contacts/ContactEditor.tsx +++ b/web/src/views/contacts/ContactEditor.tsx @@ -163,7 +163,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) {photoSrc ? : } { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} /> - {photoSrc && } + {photoSrc && }
@@ -234,7 +234,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
))} - +
@@ -247,7 +247,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
))} - +
@@ -269,7 +269,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
))} - +
diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx index fd9c3ca..f922962 100644 --- a/web/src/views/contacts/ContactsSidebar.tsx +++ b/web/src/views/contacts/ContactsSidebar.tsx @@ -170,10 +170,10 @@ export function ContactsSidebar() { {/* Import and export lived in the pane this replaced. */}
- +
diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index 377fb64..62731b9 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -166,8 +166,8 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
{narrow && } - - + +
@@ -223,13 +223,13 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
)} {c.kind === "group" && ( -

Members ({Object.keys(c.members ?? {}).length})

+

{translate("Members ({count})", { count: Object.keys(c.members ?? {}).length })}

{members.map((m) => )} - {members.length > 0 && } + {members.length > 0 && }
)} {c.keywords && Object.keys(c.keywords).length > 0 &&
{Object.keys(c.keywords).map((k) => {k})}
} - {c.updated &&

Updated {formatDate(new Date(c.updated))}

} + {c.updated &&

{translate("Updated {date}", { date: formatDate(new Date(c.updated)) })}

}
); } diff --git a/web/src/views/files/FilesView.tsx b/web/src/views/files/FilesView.tsx index 45bc36a..8a1273a 100644 --- a/web/src/views/files/FilesView.tsx +++ b/web/src/views/files/FilesView.tsx @@ -120,9 +120,9 @@ export function FilesView({ nodeId }: { nodeId?: string }) { ))}
- + { const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} /> - + {files.uploads.length > 0 && (
diff --git a/web/src/views/mail/FilterFromMessage.tsx b/web/src/views/mail/FilterFromMessage.tsx index df654ac..de92438 100644 --- a/web/src/views/mail/FilterFromMessage.tsx +++ b/web/src/views/mail/FilterFromMessage.tsx @@ -45,9 +45,9 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: looking for a problem they do not have. */} {damage ? ( -

Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.

+

{t("Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.", { damage })}

) : loaded ? ( -

Your active Sieve script was written by hand, so rules can't be added automatically. Open {t("Settings → Filters & rules")} to edit the script or switch to managed rules.

+

{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} {t("Settings → Filters & rules")} {t("to edit the script or switch to managed rules.")}

) : (

{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}

)} diff --git a/web/src/views/mail/InviteCard.tsx b/web/src/views/mail/InviteCard.tsx index b49bdc9..16c3ebc 100644 --- a/web/src/views/mail/InviteCard.tsx +++ b/web/src/views/mail/InviteCard.tsx @@ -91,7 +91,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart

{ev.title || "(untitled event)"}

{inst &&
{`${formatTimeRange(inst.start, inst.end, inst.allDay)}${ev.timeZone ? ` (${ev.timeZone})` : ""}`}
} {location &&
{location}
} - {organizer &&
Organizer: {organizer.name || participantEmail(organizer)}
} + {organizer &&
{t("Organizer: {name}", { name: organizer.name || participantEmail(organizer) })}
} {attendees.length > 0 &&
{`${attendees.length} attendee${attendees.length === 1 ? "" : "s"}`}
} {method === "REPLY" && (
@@ -109,7 +109,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart ) : ( - !existing && + !existing && )} {existing && inst && }
diff --git a/web/src/views/mail/LabelPicker.tsx b/web/src/views/mail/LabelPicker.tsx index a738686..af23d74 100644 --- a/web/src/views/mail/LabelPicker.tsx +++ b/web/src/views/mail/LabelPicker.tsx @@ -75,7 +75,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an {q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) && ( )} {!labels.length && !q &&
{t("Type a name to create your first label.")}
} diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 62a43de..d00d75f 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -16,6 +16,7 @@ import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { isUnknownMailbox } from "@/lib/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; +import { t as translate } from "@/lib/i18n"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); @@ -353,7 +354,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}
-
Select a conversation to read it here · Press ? for shortcuts
+
{translate("Select a conversation to read it here · Press")} ? {translate("for shortcuts")}
)}
diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 9c38b18..a0edbd2 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -384,7 +384,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: reason this entry survives at all. */} {shared && } label={t("Stop sharing")} onClick={onShare} />} - Colour + {t("Colour")}
{CALENDAR_COLORS.map((c) => ( diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index cbe52bf..511d91a 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -150,13 +150,14 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
from && addrMenu.open(ev, from)}> {displayName(from)} - {expanded && from && <{from.email}>} + {/* An address, not a sentence. */} + {expanded && from && <{from.email}>} {isHighPriority && {translate("Important")}} - {authFailed && Unverified} + {authFailed && {translate("Unverified")}}
{expanded ? (
- to {summarizeRecipients(e)} + {translate("to {recipients}", { recipients: summarizeRecipients(e) })} @@ -222,9 +223,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
- The sender asked for a read receipt. + + {translate("The sender asked for a read receipt.")} {receipt.redirected && ( - <> It would go to {receipt.to!.email}, which is not where the message came from. + <> {translate("It would go to")} {receipt.to!.email}{translate(", which is not where the message came from.")} )} - {from && } + {from && }
)} {icsPart && } @@ -543,15 +545,15 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB })} {attachments.length > 1 && ( )}
- setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && Download}> + setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && {translate("Download")}}> {preview?.type.startsWith("image/") && {preview.name} {preview?.type === "application/pdf" &&