diff --git a/frontend/viewer/AGENTS.md b/frontend/viewer/AGENTS.md index aa0c7caa24..1e1547b39b 100644 --- a/frontend/viewer/AGENTS.md +++ b/frontend/viewer/AGENTS.md @@ -93,6 +93,13 @@ Add new language: Edit `lingui.config.ts`, then run extract. npx shadcn-svelte@next add context-menu ``` +## Error Handling + +Unexpected errors should reach the global error handler (`src/lib/errors/global-errors.ts`): let them +throw (or rethrow) rather than catching and rendering raw messages inline. It shows a persistent toast +with a copy-error button and logs to .NET. Inline UI error states are for *expected*, actionable +failures (offline, not-found, retry) with plain, translated messages. + ## Key Concepts - **MiniLcm**: Lightweight dictionary API (entries, senses, definitions) diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte index daa4b2d22e..e114d20d4e 100644 --- a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte @@ -9,17 +9,19 @@ import type {Snippet} from 'svelte'; import type {ContextMenuTriggerProps, DropdownMenuTriggerProps} from 'bits-ui'; import type {DrawerTriggerProps} from 'vaul-svelte'; + import type {VariantProps} from 'tailwind-variants'; import {cn} from '$lib/utils'; type Props = { children?: Snippet; + size?: VariantProps['size']; } & ContextMenuTriggerProps & DropdownMenuTriggerProps & DrawerTriggerProps; - let {children, class: className, ref = $bindable(null), ...rest}: Props = $props(); + let {children, class: className, size = 'icon', ref = $bindable(null), ...rest}: Props = $props(); - const triggerVariant = buttonVariants({variant: 'ghost', size: 'icon'}); + const triggerVariant = $derived(buttonVariants({variant: 'ghost', size})); const state = useResponsiveMenuTrigger(); diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte index 8b3f67361e..21de07c505 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte @@ -6,6 +6,7 @@ import {Button} from '$lib/components/ui/button'; import PictureImage from './PictureImage.svelte'; import {ACCEPTED_PICTURE_TYPES} from './picture-formats'; + import {downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {AppNotification} from '$lib/notifications/notifications'; @@ -63,25 +64,15 @@ } } - // Downloads the currently-shown image, saved under the filename the media server reports for it. let downloading = $state(false); async function downloadPicture() { if (downloading) return; downloading = true; try { - const file = await api.getFileStream(mediaUri, true); - if (!file.stream) { - AppNotification.display(file.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); - return; + const result = await downloadPictureFile(api, mediaUri); + if (!result.success) { + AppNotification.display(result.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); } - const blob = await new Response(await file.stream.stream()).blob(); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = file.fileName ?? 'picture'; - anchor.click(); - // Release the object URL on the next tick, once the browser has captured the blob. - setTimeout(() => URL.revokeObjectURL(url), 0); } finally { downloading = false; } diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte new file mode 100644 index 0000000000..20dc30b51c --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte @@ -0,0 +1,36 @@ + + + + + + {$t`Edit`} + {$t`Download`} + {$t`Delete`} + + diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 7520cee2ea..b6c0c60ac7 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -1,122 +1,174 @@ {#snippet imageContent()} - {#if state.status === 'loaded'} - - {caption - {:else if state.status === 'loading'} + {#if loadState.status === 'loaded'} + {caption + {:else if loadState.status === 'not-downloaded'} +
+ + {loadLabel} +
+ {:else if loadState.status === 'loading'}
{:else} -
+
- {state.message} + {errorText(loadState)} + {retryLabel}
{/if} {/snippet} -
- -
- {#if editable} - - {:else} +{#snippet pictureArea()} + {#if clickable} +
+ + {:else} + {@render imageContent()} + {/if} +{/snippet} + +
+ {#if size === 'full'} + {@render pictureArea()} + {:else} + +
+ {#if showMenu} + (menuOpen = o)} + onEdit={() => onEdit?.()} + onDownload={() => onDownload?.()} + onDelete={() => onDelete?.()} + > + {@render pictureArea()} + +
+ (menuOpen = o)} + onEdit={() => onEdit?.()} + onDownload={() => onDownload?.()} + onDelete={() => onDelete?.()} + /> +
+ {:else} + {@render pictureArea()} + {/if} +
+ {/if} {#if showCaption && caption} -
+
{caption}
{/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte new file mode 100644 index 0000000000..72b582aca5 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -0,0 +1,165 @@ + + + + + + + {$t`Picture`} + {#if hasMultiple && currentIndex >= 0} + {currentIndex + 1} / {pictures.length} + {/if} + + + +
+ {#if current} + current && onEdit(current)} + onDownload={() => current && onDownload(current)} + onDelete={() => current && onDelete(current)} + /> + {/if} + + {#snippet child({props})} + + {/snippet} + +
+ + {#if current} +
+ + {#if hasMultiple} +
+ + {#if captions.length > 0} +
+ +
+ {/if} + {/if} +
+
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index 7a24935b7a..cfbc5315b6 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -4,7 +4,9 @@ import {Button} from '$lib/components/ui/button'; import PictureImage from './PictureImage.svelte'; import EditPictureDialog from './EditPictureDialog.svelte'; + import PictureViewerDialog from './PictureViewerDialog.svelte'; import {ACCEPTED_PICTURE_TYPES, isLosslessImage} from './picture-formats'; + import {downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {useDialogsService} from '$lib/services/dialogs-service'; @@ -17,27 +19,37 @@ senseId: string; readonly?: boolean; }; - const {pictures, entryId, senseId, readonly = false}: Props = $props(); + let {pictures = $bindable(), entryId, senseId, readonly = false}: Props = $props(); const api = useLexboxApi(); const dialogsService = useDialogsService(); let fileInputElement = $state(); - // Which operation is in flight: 'add' drives the add-button spinner; 'edit' covers the - // replace/delete/caption operations invoked from the edit dialog. let busyAction = $state<'add' | 'edit' | null>(null); - // The picture currently open in the edit dialog, tracked by id so it stays in sync with the - // reloaded entry after each change (e.g. its image updates in the dialog after a replace). let editingPictureId = $state(); const editingPicture = $derived(editingPictureId ? pictures.find((p) => p.id === editingPictureId) : undefined); let editDialogOpen = $state(false); + // Retain the last picture the dialog showed so it stays mounted for its close animation even after + // that picture is removed from `pictures` (e.g. deleted from within the dialog). + let lastEditedPicture = $state(); + $effect(() => { + if (editingPicture) lastEditedPicture = editingPicture; + }); + + let viewerPictureId = $state(); + let viewerOpen = $state(false); function openEditor(picture: IPicture) { editingPictureId = picture.id; editDialogOpen = true; } + function openViewer(picture: IPicture) { + viewerPictureId = picture.id; + viewerOpen = true; + } + function onFileSelected(event: Event) { const target = event.target as HTMLInputElement; const file = target.files?.[0]; @@ -46,19 +58,13 @@ if (file) void addPicture(file); } - // Uploads the chosen file and returns its mediaUri, or null if the upload was rejected - // (a notification is shown for the rejection). saveFile reports outcome via `result`, not - // exceptions, so we branch on it. We intentionally do NOT pre-check the file size: the size - // limit lives on the server and may change, so we let the server decide and handle `TooBig`. async function uploadFile(file: File): Promise { const response = await api.saveFile(file, {filename: file.name, mimeType: file.type, extraFields: {}}); switch (response.result) { case UploadFileResult.SavedLocally: case UploadFileResult.SavedToLexbox: case UploadFileResult.AlreadyExists: - // AlreadyExists is not an error here: one image file (mediaUri) can back many Picture - // objects across different senses/entries. The server returns the existing file's - // mediaUri, which we reuse to point a Picture at that same image. + // AlreadyExists is not an error here: multiple Picture objects might share one mediaUri break; case UploadFileResult.TooBig: AppNotification.display(tooBigMessage(file), {type: 'error', timeout: 'long'}); @@ -80,8 +86,8 @@ const mediaUri = await uploadFile(file); if (!mediaUri) return; const picture: IPicture = {id: randomId(), order: pictures.length, mediaUri, caption: {}}; - await api.createPicture(entryId, senseId, picture); - // The change surfaces via the entry-changed event, which reloads the entry. + const newPicture = await api.createPicture(entryId, senseId, picture); + pictures = [...pictures, newPicture]; } finally { busyAction = null; } @@ -89,8 +95,7 @@ // --- Edit dialog operations (act on the picture currently open in the dialog) --- - // Uploads a replacement file and returns its mediaUri, WITHOUT touching the model — the dialog - // previews it and only commits on Submit (via submitEdits). + // Uploads replacement file and returns its mediaUri, WITHOUT touching the model until dialog is submitted async function uploadReplacement(file: File): Promise { busyAction = 'edit'; try { @@ -100,52 +105,64 @@ } } - // Applies the dialog's buffered edits (new caption and/or replaced image) in one update. async function submitEdits(after: IPicture): Promise { const before = editingPicture ? $state.snapshot(editingPicture) : undefined; if (!before) return; busyAction = 'edit'; try { - await api.updatePicture(entryId, senseId, before, after); + const newPicture = await api.updatePicture(entryId, senseId, before, after); + pictures = pictures.map((p) => (p.id === after.id ? newPicture : p)); } finally { busyAction = null; } } - async function deleteEditingPicture(): Promise { - const targetId = editingPicture?.id; - if (!targetId) return; + async function deletePicture(pictureId: string): Promise { if (!(await dialogsService.promptDelete($t`Picture`))) return; busyAction = 'edit'; try { - // Close dialog *before* deleting picture so that dialog's close animation has time to play + // Close the edit dialog (if open on this picture) *before* deleting so its close animation + // has time to play; harmless when the delete came from the field/menu instead. editDialogOpen = false; - await api.deletePicture(entryId, senseId, targetId); + await api.deletePicture(entryId, senseId, pictureId); + pictures = pictures.filter((p) => p.id !== pictureId); } finally { busyAction = null; } } + function deleteEditingPicture(): Promise { + return editingPicture ? deletePicture(editingPicture.id) : Promise.resolve(); + } + + async function downloadPicture(picture: IPicture): Promise { + const result = await downloadPictureFile(api, picture.mediaUri); + if (!result.success) { + AppNotification.display(result.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); + } + } + // The server rejects files above its size limit; the advice differs by format. function tooBigMessage(file: File): string { return isLosslessImage(file) - ? $t`This picture is too large to upload. Try reducing the image resolution and uploading again.` + ? $t`This picture is too large to upload. Try reducing the resolution and uploading again.` : $t`This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again.`; }
{#if pictures.length > 0} - +
{#each pictures as picture (picture.id)} openViewer(picture)} onEdit={() => openEditor(picture)} + onDownload={() => void downloadPicture(picture)} + onDelete={() => void deletePicture(picture.id)} /> {/each}
@@ -162,7 +179,7 @@ {$t`Picture`}
- + -{#if editingPicture} +{#if lastEditedPicture} uploadReplacement(file)} onSubmit={(after) => void submitEdits(after)} onDelete={() => deleteEditingPicture()} /> {/if} + + { + viewerOpen = false; + openEditor(picture); + }} + onDownload={(picture) => void downloadPicture(picture)} + onDelete={(picture) => void deletePicture(picture.id)} +/> diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts new file mode 100644 index 0000000000..991db7f628 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -0,0 +1,89 @@ +import {Context} from 'runed'; +import {onDestroy} from 'svelte'; +import {SvelteMap} from 'svelte/reactivity'; +import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; +import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; + +export type ImageState = + | {status: 'loaded'; url: string} + | {status: 'not-downloaded'} + | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'}; + +export type LoadImageOptions = { + downloadIfMissing?: boolean; + bypassCache?: boolean; +}; + +/** + * Loads picture images and hands back a shared blob object URL per mediaUri. Cached until the + * entry view is torn down. + */ +export class ImageService { + readonly #getApi: () => IMiniLcmJsInvokable; + readonly #cache = new SvelteMap>(); + readonly #inFlight = new SvelteMap>(); + #disposed = false; + + constructor(getApi: () => IMiniLcmJsInvokable) { + this.#getApi = getApi; + } + + loadImage(mediaUri: string, options: LoadImageOptions = {}): Promise { + const {downloadIfMissing = false, bypassCache = false} = options; + if (!bypassCache) { + const cached = this.#cache.get(mediaUri); + if (cached) return Promise.resolve(cached); + const inFlight = this.#inFlight.get(mediaUri); + if (inFlight) return inFlight; + } + const promise = this.#load(mediaUri, downloadIfMissing).finally(() => { + if (this.#inFlight.get(mediaUri) === promise) this.#inFlight.delete(mediaUri); + }); + if (!bypassCache) this.#inFlight.set(mediaUri, promise); + return promise; + } + + /** Read inside $derived in order to react to other components loading a mediaUri */ + cached(mediaUri: string): Extract | undefined { + return this.#cache.get(mediaUri); + } + + async #load(mediaUri: string, downloadIfMissing: boolean): Promise { + const api = this.#getApi(); + const file = await api.getFileStream(mediaUri, downloadIfMissing); + if (!file.stream) { + if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { + return {status: 'not-downloaded'}; + } + const reason = + file.result === ReadFileResult.NotFound ? 'not-found' + : file.result === ReadFileResult.Offline ? 'offline' + : 'unknown'; + return {status: 'error', reason}; + } + const blob = await new Response(await file.stream.stream()).blob(); + if (this.#disposed) return {status: 'error', reason: 'unknown'}; + const state = {status: 'loaded', url: URL.createObjectURL(blob)} as const; + this.#cache.set(mediaUri, state); + return state; + } + + dispose(): void { + this.#disposed = true; + for (const state of this.#cache.values()) URL.revokeObjectURL(state.url); + this.#cache.clear(); + } +} + +const imageServiceContext = new Context('image-service'); + +export function initImageService(getApi: () => IMiniLcmJsInvokable): ImageService { + const service = new ImageService(getApi); + imageServiceContext.set(service); + onDestroy(() => service.dispose()); + return service; +} + +export function useImageService(): ImageService | undefined { + return imageServiceContext.getOr(undefined); +} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts new file mode 100644 index 0000000000..8081ddf48a --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts @@ -0,0 +1,17 @@ +import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; + +export type DownloadPictureResult = {success: true} | {success: false; errorMessage?: string}; + +export async function downloadPictureFile(api: IMiniLcmJsInvokable, mediaUri: string): Promise { + const file = await api.getFileStream(mediaUri, true); + if (!file.stream) return {success: false, errorMessage: file.errorMessage ?? undefined}; + const blob = await new Response(await file.stream.stream()).blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = file.fileName ?? 'picture'; + anchor.click(); + // Release the object URL on the next tick, once the browser has captured the blob. + setTimeout(() => URL.revokeObjectURL(url), 0); + return {success: true}; +} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index aaa36c051b..3565cc1984 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -15,6 +15,8 @@