From 23a80cb700b9f7855c3da59cdcb4f749eebd732a Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 25 Jun 2026 10:39:28 +0700 Subject: [PATCH 01/32] Add Pictures field to sense editor with image carousel Display a sense's pictures in the FwLite viewer, below Semantic Domains. - Register a new `pictures` sense field (entity-config + FW Lite view) so it shows for every sense. - PictureImage loads each picture via MiniLcmJsInvokable.GetFileStream (MediaUri -> stream -> blob URL), shows its caption (best analysis alternative), and handles not-found/offline/error states. Object URLs are revoked on teardown. No try/catch around async, per viewer conventions. - PictureCarousel wraps the images in an embla carousel (embla-carousel-svelte) that auto-advances every 10s when there is more than one picture, with prev/next + dot navigation. Single-picture senses just show the image. - PicturesEditor shows the carousel when pictures exist, otherwise a disabled "+ Picture" button styled like "+ Component" (adding pictures is not yet possible: MiniLcmJsInvokable exposes no create-picture API to the frontend). - Export IPicture from the dotnet-types barrel (was omitted when generated). - Demo: give the first "nyumba" sense two pictures and serve them as inline SVG blobs from the demo getFileStream, so the carousel is demonstrable in the in-browser demo project. - Add a viewer Playwright test covering the populated (image + caption) and empty (disabled add button) states. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/viewer/src/lib/dotnet-types/index.ts | 1 + .../field-editors/PictureCarousel.svelte | 104 ++++++++++++++++++ .../field-editors/PictureImage.svelte | 84 ++++++++++++++ .../field-editors/PicturesEditor.svelte | 27 +++++ .../SenseEditorPrimitive.svelte | 8 ++ .../viewer/src/lib/views/entity-config.ts | 4 + frontend/viewer/src/lib/views/view-data.ts | 1 + .../src/project/demo/demo-entry-data.ts | 45 +++++++- .../src/project/demo/in-memory-demo-api.ts | 16 ++- frontend/viewer/tests/sense-pictures.test.ts | 45 ++++++++ 10 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte create mode 100644 frontend/viewer/tests/sense-pictures.test.ts diff --git a/frontend/viewer/src/lib/dotnet-types/index.ts b/frontend/viewer/src/lib/dotnet-types/index.ts index 811b6979d1..1e5c6cfbe3 100644 --- a/frontend/viewer/src/lib/dotnet-types/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/index.ts @@ -16,6 +16,7 @@ export * from './generated-types/MiniLcm/Models/ITranslation'; export * from './generated-types/MiniLcm/Models/IMorphType'; export * from './generated-types/MiniLcm/Models/IObjectWithId'; export * from './generated-types/MiniLcm/Models/IPartOfSpeech'; +export * from './generated-types/MiniLcm/Models/IPicture'; export * from './generated-types/MiniLcm/Models/IProjectIdentifier'; export * from './generated-types/MiniLcm/Models/IPublication'; export * from './generated-types/MiniLcm/Models/IRichSpan'; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte new file mode 100644 index 0000000000..de1f816529 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -0,0 +1,104 @@ + + +
+
+
+ {#each pictures as picture (picture.id)} +
+ +
+ {/each} +
+
+ + {#if hasMultiple} +
+ + {/each} +
+
+ {/if} + diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte new file mode 100644 index 0000000000..79831ec3f5 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -0,0 +1,84 @@ + + +
+ {#if state.status === 'loaded'} + {caption + {:else if state.status === 'loading'} +
+ +
+ {:else} +
+ + {state.message} +
+ {/if} + {#if caption} +
{caption}
+ {/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 new file mode 100644 index 0000000000..86e79fd67e --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -0,0 +1,27 @@ + + +{#if value.length > 0} + +{:else if !readonly} + + +{:else} +
+ {$t`No pictures`} +
+{/if} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte index 10e1fdc0b8..328b2da15e 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte @@ -12,6 +12,7 @@ import type {Snippet} from 'svelte'; import {entityConfig, type SenseFieldId} from '../../views/entity-config'; import {tvt} from '$lib/views/view-text'; + import PicturesEditor from '../field-editors/PicturesEditor.svelte'; interface Props extends Omit { sense: ISense; @@ -97,4 +98,11 @@ {@render semanticDomainsDescription?.()} + + + + + + + diff --git a/frontend/viewer/src/lib/views/entity-config.ts b/frontend/viewer/src/lib/views/entity-config.ts index 311dfc8635..16254a14b1 100644 --- a/frontend/viewer/src/lib/views/entity-config.ts +++ b/frontend/viewer/src/lib/views/entity-config.ts @@ -65,6 +65,10 @@ export const entityConfig = { label: msg`Semantic domains`, helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/semantic_domains_field.htm', }, + pictures: { + label: msg`Pictures`, + helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/Pictures_field.htm', + }, }, example: { $label: msg`Example`, diff --git a/frontend/viewer/src/lib/views/view-data.ts b/frontend/viewer/src/lib/views/view-data.ts index 094571043f..12be458a82 100644 --- a/frontend/viewer/src/lib/views/view-data.ts +++ b/frontend/viewer/src/lib/views/view-data.ts @@ -61,6 +61,7 @@ export const FW_LITE_VIEW: RootView = { definition: {show: true, order: 2}, partOfSpeechId: {show: true, order: 3}, semanticDomains: {show: true, order: 4}, + pictures: {show: true, order: 5}, }, example: { sentence: {show: true, order: 1}, diff --git a/frontend/viewer/src/project/demo/demo-entry-data.ts b/frontend/viewer/src/project/demo/demo-entry-data.ts index 787da7cb05..65d4b9ddeb 100644 --- a/frontend/viewer/src/project/demo/demo-entry-data.ts +++ b/frontend/viewer/src/project/demo/demo-entry-data.ts @@ -2,6 +2,36 @@ import {type IEntry, type IMorphType, type IWritingSystems, MorphTypeKind, Writi export const projectName = 'Sena 3'; +/** Media URIs for the demo pictures, mapped to inline SVG markup in {@link demoPictureSvgs}. */ +export const demoPictureMediaUris = { + house1: 'demo://picture/house-1', + house2: 'demo://picture/house-2', +} as const; + +/** + * Inline SVG bodies served by the demo api's getFileStream, so the picture carousel has + * something to display in the in-browser demo project (real projects load actual media files). + */ +export const demoPictureSvgs: Record = { + [demoPictureMediaUris.house1]: + '' + + '' + + '' + + '' + + '' + + 'Demo picture 1' + + '', + [demoPictureMediaUris.house2]: + '' + + '' + + '' + + '' + + '' + + '' + + 'Demo picture 2' + + '', +}; + export const partsOfSpeech = [ {id: '86ff66f6-0774-407a-a0dc-3eeaf873daf7', name: {en: 'Verb'}, predefined: true}, {id: 'a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5', name: {en: 'Noun'}, predefined: true}, @@ -153,7 +183,20 @@ export const _entries: IEntry[] = [ 'partOfSpeech': partsOfSpeech[1], // noun 'partOfSpeechId': partsOfSpeech[1].id, 'semanticDomains': [], - 'pictures': [], + 'pictures': [ + { + 'id': 'ce4a3b1e-6d2f-4f1a-8c0d-1f2e3a4b5c6d', + 'order': 1, + 'mediaUri': demoPictureMediaUris.house1, + 'caption': {'en': {spans: [{text: 'A traditional house', ws: 'en'}]}, 'pt': {spans: [{text: 'Uma casa tradicional', ws: 'pt'}]}}, + }, + { + 'id': 'a1b2c3d4-7e8f-4a0b-9c1d-2e3f4a5b6c7d', + 'order': 2, + 'mediaUri': demoPictureMediaUris.house2, + 'caption': {'en': {spans: [{text: 'A modern house', ws: 'en'}]}, 'pt': {spans: [{text: 'Uma casa moderna', ws: 'pt'}]}}, + }, + ], 'exampleSentences': [ { 'id': 'ex-001', diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 68f85cce75..bbaa214cb0 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -26,7 +26,7 @@ import { ViewBase, MorphTypeKind, } from '$lib/dotnet-types'; -import {entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; +import {demoPictureSvgs, entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; import {WritingSystemService} from '../data/writing-system-service.svelte'; import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform'; @@ -520,8 +520,18 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { throw new Error('Method not implemented.'); } - getFileStream(_mediaUri: string): Promise { - return Promise.resolve({result: ReadFileResult.NotSupported}); + getFileStream(mediaUri: string): Promise { + const svg = demoPictureSvgs[mediaUri]; + if (!svg) return Promise.resolve({result: ReadFileResult.NotFound}); + const blob = new Blob([svg], {type: 'image/svg+xml'}); + return Promise.resolve({ + result: ReadFileResult.Success, + fileName: 'demo-picture.svg', + stream: { + stream: () => Promise.resolve(blob.stream()), + arrayBuffer: () => blob.arrayBuffer(), + }, + }); } saveFile(_streamReference: Blob | ArrayBuffer | Uint8Array, _metadata: ILcmFileMetadata): Promise { diff --git a/frontend/viewer/tests/sense-pictures.test.ts b/frontend/viewer/tests/sense-pictures.test.ts new file mode 100644 index 0000000000..c4edbd633f --- /dev/null +++ b/frontend/viewer/tests/sense-pictures.test.ts @@ -0,0 +1,45 @@ +import {expect, test} from '@playwright/test'; +import {BrowsePage} from './browse-page'; + +/** + * Verifies the Sense "Pictures" field: + * - a sense with pictures renders the image (loaded via getFileStream into a blob url) + caption + * - a sense without pictures shows the disabled "+ Picture" add button + * + * The demo "nyumba" entry's first sense has two demo pictures; its other senses have none. + */ +test.describe('Sense pictures', () => { + test('displays a picture (and its caption) for a sense that has pictures', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + await browsePage.selectEntryByFilter('nyumba'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + + const image = picturesField.locator('img').first(); + await expect(image).toBeVisible({timeout: 5000}); + // A blob: src proves the full pipeline ran: getFileStream returned a stream that we + // turned into a Blob and an object url. A broken/missing image would not have this. + await expect(image).toHaveAttribute('src', /^blob:/); + + // Caption is rendered from the best analysis alternative. + await expect(picturesField.getByText(/A traditional house|Uma casa tradicional/)).toBeVisible(); + }); + + test('shows a disabled "+ Picture" button for a sense with no pictures', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + await browsePage.selectEntryByFilter('nyumba'); + + // First sense has pictures; later senses do not, so their Pictures field shows the add button. + const emptyPicturesField = page.locator('[style*="grid-area: pictures"]').nth(1); + await expect(emptyPicturesField).toBeVisible({timeout: 5000}); + + const addButton = emptyPicturesField.getByRole('button', {name: 'Picture'}); + await expect(addButton).toBeVisible(); + await expect(addButton).toBeDisabled(); + }); +}); From 15214f1e86a392a003ee76d70a83c10182ec6b80 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 25 Jun 2026 10:59:17 +0700 Subject: [PATCH 02/32] Fix picture carousel event/typing and extract i18n strings Follow-up after running svelte-check, eslint and the i18n extractor: - PictureCarousel: use the `onemblaInit` event attribute (Svelte 5 forbids mixing the legacy `on:` directive with `on*` handlers, and the embla package types this attribute) and pass the required `plugins: []` to the action. Convert the dot-sync arrow to a function declaration (func-style). - Extract the new picture UI strings into the locale catalogs and add translator-context comments in en.po per the i18n context guide. svelte-check: 0 errors / 0 warnings. eslint: clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureCarousel.svelte | 12 ++--- frontend/viewer/src/locales/en.po | 48 +++++++++++++++++++ frontend/viewer/src/locales/es.po | 39 +++++++++++++++ frontend/viewer/src/locales/fr.po | 39 +++++++++++++++ frontend/viewer/src/locales/id.po | 39 +++++++++++++++ frontend/viewer/src/locales/ko.po | 39 +++++++++++++++ frontend/viewer/src/locales/ms.po | 39 +++++++++++++++ frontend/viewer/src/locales/sw.po | 39 +++++++++++++++ frontend/viewer/src/locales/vi.po | 39 +++++++++++++++ 9 files changed, 327 insertions(+), 6 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index de1f816529..a87c6c7e49 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -31,10 +31,8 @@ const hasMultiple = $derived(pictures.length > 1); - // embla-carousel-svelte dispatches an `emblaInit` CustomEvent carrying the api in `detail`. - // It fires synchronously while the `use:` action sets up (during mount), which is *before* - // $effects run — so the listener must be bound in the template (the `on:` directive form, - // as documented by embla-carousel-svelte) to avoid missing it. + // embla-carousel-svelte dispatches an `emblaInit` CustomEvent (typed by the package as the + // `onemblaInit` attribute) carrying the api in `detail` once the carousel has initialised. function onEmblaInit(event: Event) { emblaApi = (event as CustomEvent).detail; } @@ -43,7 +41,9 @@ $effect(() => { const api = emblaApi; if (!api) return; - const onSelect = () => (selectedIndex = api.selectedScrollSnap()); + function onSelect() { + selectedIndex = api!.selectedScrollSnap(); + } onSelect(); api.on('select', onSelect); api.on('reInit', onSelect); @@ -63,7 +63,7 @@
-
+
{#each pictures as picture (picture.id)}
diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index c31ad2ee21..5406c7c86b 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -985,6 +985,12 @@ msgstr "Gloss" msgid "Go to {0}" msgstr "Go to {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "Go to picture {0}" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1034,6 +1040,11 @@ msgstr "I don't see my project" msgid "I understand that this can't be undone" msgstr "I understand that this can't be undone" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "Image not found" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1315,6 +1326,11 @@ msgstr "Newest first" msgid "Next" msgstr "Next" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "Next picture" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1369,6 +1385,11 @@ msgstr "No items found" msgid "No new data" msgstr "No new data" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "No pictures" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1448,6 +1469,11 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, unable to download" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "Offline, unable to download image" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1540,6 +1566,17 @@ msgstr "Pending" msgid "Pick a Part of Speech" msgstr "Pick a Part of Speech" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "Picture" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "Pictures" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1564,6 +1601,11 @@ msgstr "Preview" msgid "Preview not available" msgstr "Preview not available" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "Previous picture" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2053,6 +2095,12 @@ msgstr "Type an example sentence" msgid "Type:" msgstr "Type:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "Unable to load image" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 20bb46208c..d171116fa6 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -990,6 +990,10 @@ msgstr "Glosa" msgid "Go to {0}" msgstr "Visite {0}" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "No veo mi proyecto" msgid "I understand that this can't be undone" msgstr "Entiendo que esto no se puede deshacer" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "Siguiente" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "No se han encontrado artículos" msgid "No new data" msgstr "No hay nuevos datos" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "Fuera de línea" msgid "Offline, unable to download" msgstr "Desconectado, no se puede descargar" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "Pendiente" msgid "Pick a Part of Speech" msgstr "Escoge una parte de la voz" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "Vista previa" msgid "Preview not available" msgstr "Vista previa no disponible" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "Escriba una frase de ejemplo" msgid "Type:" msgstr "Tipo:" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 205ebf064e..5a5e00e691 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -990,6 +990,10 @@ msgstr "Glose" msgid "Go to {0}" msgstr "Naviguer à {0}" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "Je ne vois pas mon projet" msgid "I understand that this can't be undone" msgstr "Je comprends que cela ne peut pas être annulé" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "Suivant" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "Aucun élément trouvé" msgid "No new data" msgstr "Aucune nouvelle donnée" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "Déconnecté" msgid "Offline, unable to download" msgstr "Déconnecté, impossible de télécharger" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "Déconnecté, impossible de télécharger l'image" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "En attente" msgid "Pick a Part of Speech" msgstr "Choisir une partie du discours" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "Avant-première" msgid "Preview not available" msgstr "Aperçu non disponible" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "Saisir une phrase exemplaire" msgid "Type:" msgstr "Type :" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 7e3085eb37..530f8daede 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -990,6 +990,10 @@ msgstr "Arti Singkat" msgid "Go to {0}" msgstr "Buka {0}" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "Saya tidak melihat proyek saya" msgid "I understand that this can't be undone" msgstr "Saya memahami bahwa hal ini tidak dapat dibatalkan" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "Berikutnya" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "Tidak ada barang yang ditemukan" msgid "No new data" msgstr "Tidak ada data baru" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, tidak dapat mengunduh" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bagian dari Pidato" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "Pratinjau" msgid "Preview not available" msgstr "Pratinjau tidak tersedia" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "Ketik contoh kalimat" msgid "Type:" msgstr "Ketik:" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 6b92e1006a..312c2e5ebe 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -990,6 +990,10 @@ msgstr "광택" msgid "Go to {0}" msgstr "{0}으로 이동" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "내 프로젝트가 보이지 않습니다." msgid "I understand that this can't be undone" msgstr "이 작업은 되돌릴 수 없다는 것을 이해합니다." +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "다음" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "항목을 찾을 수 없습니다." msgid "No new data" msgstr "새 데이터 없음" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "오프라인" msgid "Offline, unable to download" msgstr "오프라인 상태, 다운로드할 수 없음" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "보류 중" msgid "Pick a Part of Speech" msgstr "품사 선택" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "미리 보기" msgid "Preview not available" msgstr "미리 보기를 사용할 수 없습니다." +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "예제 문장을 입력합니다." msgid "Type:" msgstr "유형:" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 41e2547ffd..97d9ba4344 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -990,6 +990,10 @@ msgstr "Glos" msgid "Go to {0}" msgstr "Pergi ke {0}" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "Saya tidak nampak projek saya" msgid "I understand that this can't be undone" msgstr "Saya faham bahawa ini tidak boleh dibuat asal" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "Seterusnya" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "Tiada item ditemui" msgid "No new data" msgstr "Tiada data baru" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "Luar talian" msgid "Offline, unable to download" msgstr "Luar talian, tidak dapat memuat turun" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bahagian Pertuturan" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "Pratonton" msgid "Preview not available" msgstr "Pratonton tidak tersedia" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "Taip ayat contoh" msgid "Type:" msgstr "Jenis:" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 2bf6258491..b8dc19aa1c 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -990,6 +990,10 @@ msgstr "Glosi" msgid "Go to {0}" msgstr "Nenda kwa {0}" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "Siioni mradi wangu" msgid "I understand that this can't be undone" msgstr "Ninaelewa kuwa hii haiwezi kurudishwa nyuma" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "Inayofuata" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "Hakuna kitu kilichopatikana" msgid "No new data" msgstr "Hakuna data mpya" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "Nyuma ya mtandao" msgid "Offline, unable to download" msgstr "Nyuma ya mtandao, haiwezi kupakua" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "Inasubiri" msgid "Pick a Part of Speech" msgstr "Chagua Sehemu ya Mazungumzo" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "Hakiki" msgid "Preview not available" msgstr "Muonekano awali haupatikani" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "Andika sentensi ya mfano" msgid "Type:" msgstr "Aina:" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index fe3b137341..f9c690cefe 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -990,6 +990,10 @@ msgstr "Ghi chú" msgid "Go to {0}" msgstr "Đi tới {0}" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Go to picture {0}" +msgstr "" + #. Filter option label #. Relevant view: Classic #. Lite view equivalent: "Part of speech" @@ -1039,6 +1043,10 @@ msgstr "Tôi không thấy dự án của mình" msgid "I understand that this can't be undone" msgstr "Tôi hiểu rằng điều này không thể hoàn tác" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1320,6 +1328,10 @@ msgstr "" msgid "Next" msgstr "Tiếp theo" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1374,6 +1386,10 @@ msgstr "Không tìm thấy mục nào" msgid "No new data" msgstr "Không có dữ liệu mới" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1469,11 @@ msgstr "Ngoại tuyến" msgid "Offline, unable to download" msgstr "Ngoại tuyến, không thể tải xuống" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1566,15 @@ msgstr "Đang chờ xử lý" msgid "Pick a Part of Speech" msgstr "Chọn một Loại từ" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1569,6 +1599,10 @@ msgstr "Xem trước" msgid "Preview not available" msgstr "Xem trước không khả dụng" +#: src/lib/entry-editor/field-editors/PictureCarousel.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2058,6 +2092,11 @@ msgstr "Gõ một câu ví dụ" msgid "Type:" msgstr "Loại:" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" From da91533106b3d8672b6144c75c0e40a2061e7c3f Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 25 Jun 2026 11:18:01 +0700 Subject: [PATCH 03/32] Guard against undefined sense.pictures; fix demo data + Playwright test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the new Playwright test against the real demo data surfaced two issues: - The demo pictures were added to `_entries`, which is dead code; the served data is `entries`, whose first item is replaced by `allWsEntry`. Move the two demo pictures onto `allWsEntry`'s sense (the live "nyumba" entry) and revert the `_entries` change. - PicturesEditor crashed ("Cannot read properties of undefined (reading 'length')") on senses whose `pictures` is absent at runtime — the bulk demo data (and legacy data) omit the field even though the type marks it required. Default it to an empty array before use. - Update the test's empty-state case to use a single-sense entry ("ambuka") since the picture-bearing entry now has only one sense. Both Playwright tests pass; svelte-check and eslint are clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PicturesEditor.svelte | 10 ++++-- .../src/project/demo/demo-entry-data.ts | 36 +++++++++++-------- frontend/viewer/tests/sense-pictures.test.ts | 15 ++++---- 3 files changed, 37 insertions(+), 24 deletions(-) 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 86e79fd67e..6d946363ec 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -5,14 +5,18 @@ import {t} from 'svelte-i18n-lingui'; type Props = { - value: IPicture[]; + value: IPicture[] | undefined; readonly?: boolean; }; const {value, readonly = false}: Props = $props(); + + // `pictures` is typed as required, but older/legacy sense data may omit it entirely, + // so guard against undefined rather than trusting the type at runtime. + const pictures = $derived(value ?? []); -{#if value.length > 0} - +{#if pictures.length > 0} + {:else if !readonly} - + + {:else}
{$t`No pictures`} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte index 328b2da15e..0eee7bb04a 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte @@ -102,7 +102,7 @@ - + diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 5406c7c86b..f7b4d8b829 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1981,6 +1981,10 @@ msgstr "Tasks" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Tell us about a bug or issue you’ve encountered." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "That picture has already been uploaded" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2027,6 +2031,14 @@ msgstr "This {0} was deleted" msgid "This date # and this emoji # are snippets" msgstr "This date # and this emoji # are snippets" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "This picture is too large to upload. Try reducing the image resolution and uploading again." + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2106,6 +2118,10 @@ msgstr "Unable to load image" msgid "Unable to open in FieldWorks" msgstr "Unable to open in FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "Unable to upload the picture" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2201,6 +2217,10 @@ msgstr "Update was disallowed by permission settings." msgid "Updates" msgstr "Updates" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "Uploading pictures is not supported here" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index d171116fa6..b4d574285c 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -990,6 +990,8 @@ msgstr "Glosa" msgid "Go to {0}" msgstr "Visite {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "No veo mi proyecto" msgid "I understand that this can't be undone" msgstr "Entiendo que esto no se puede deshacer" +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "Siguiente" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "No se han encontrado artículos" msgid "No new data" msgstr "No hay nuevos datos" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "Pendiente" msgid "Pick a Part of Speech" msgstr "Escoge una parte de la voz" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "Vista previa" msgid "Preview not available" msgstr "Vista previa no disponible" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "Tareas" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Cuéntanos acerca de un error o problema que hayas encontrado." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "Este {0} ha sido eliminado" msgid "This date # and this emoji # are snippets" msgstr "Esta fecha # y este emoji # son fragmentos" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "Escriba una frase de ejemplo" msgid "Type:" msgstr "Tipo:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "No se puede abrir en FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "La actualización no fue permitida por la configuración de permisos." msgid "Updates" msgstr "Actualizaciones" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 5a5e00e691..700c5ccfb0 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -990,6 +990,8 @@ msgstr "Glose" msgid "Go to {0}" msgstr "Naviguer à {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "Je ne vois pas mon projet" msgid "I understand that this can't be undone" msgstr "Je comprends que cela ne peut pas être annulé" +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "Suivant" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "Aucun élément trouvé" msgid "No new data" msgstr "Aucune nouvelle donnée" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "En attente" msgid "Pick a Part of Speech" msgstr "Choisir une partie du discours" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "Avant-première" msgid "Preview not available" msgstr "Aperçu non disponible" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "Tâches" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Parlez-nous d'un bug ou d'un problème que vous avez rencontré." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "Cet {0} a été supprimé" msgid "This date # and this emoji # are snippets" msgstr "Cette date # et cet emoji # sont des extraits." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "Saisir une phrase exemplaire" msgid "Type:" msgstr "Type :" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "Impossible d'ouvrir FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "La mise à jour a été désactivée par les paramètres d'autorisation. msgid "Updates" msgstr "Mises à jour" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 530f8daede..19c546a124 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -990,6 +990,8 @@ msgstr "Arti Singkat" msgid "Go to {0}" msgstr "Buka {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "Saya tidak melihat proyek saya" msgid "I understand that this can't be undone" msgstr "Saya memahami bahwa hal ini tidak dapat dibatalkan" +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "Berikutnya" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "Tidak ada barang yang ditemukan" msgid "No new data" msgstr "Tidak ada data baru" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bagian dari Pidato" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "Pratinjau" msgid "Preview not available" msgstr "Pratinjau tidak tersedia" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "Tugas" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Beritahu kami tentang bug atau masalah yang Anda temui." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "{0} ini telah dihapus" msgid "This date # and this emoji # are snippets" msgstr "Tanggal ini # dan emoji ini # adalah cuplikan" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "Ketik contoh kalimat" msgid "Type:" msgstr "Ketik:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "Tidak dapat dibuka di FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "Pembaruan ditolak oleh pengaturan izin." msgid "Updates" msgstr "Pembaruan" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 312c2e5ebe..946b3891bc 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -990,6 +990,8 @@ msgstr "광택" msgid "Go to {0}" msgstr "{0}으로 이동" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "내 프로젝트가 보이지 않습니다." msgid "I understand that this can't be undone" msgstr "이 작업은 되돌릴 수 없다는 것을 이해합니다." +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "다음" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "항목을 찾을 수 없습니다." msgid "No new data" msgstr "새 데이터 없음" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "보류 중" msgid "Pick a Part of Speech" msgstr "품사 선택" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "미리 보기" msgid "Preview not available" msgstr "미리 보기를 사용할 수 없습니다." +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "작업" msgid "Tell us about a bug or issue you’ve encountered." msgstr "발생한 버그나 문제에 대해 알려주세요." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "이 {0} 삭제됨" msgid "This date # and this emoji # are snippets" msgstr "이 날짜 #와 이 이모티콘 #은 스니펫입니다." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "예제 문장을 입력합니다." msgid "Type:" msgstr "유형:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "FieldWorks에서 열 수 없음" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "권한 설정에 의해 업데이트가 허용되지 않았습니다." msgid "Updates" msgstr "업데이트" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 97d9ba4344..17605d3d58 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -990,6 +990,8 @@ msgstr "Glos" msgid "Go to {0}" msgstr "Pergi ke {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "Saya tidak nampak projek saya" msgid "I understand that this can't be undone" msgstr "Saya faham bahawa ini tidak boleh dibuat asal" +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "Seterusnya" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "Tiada item ditemui" msgid "No new data" msgstr "Tiada data baru" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bahagian Pertuturan" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "Pratonton" msgid "Preview not available" msgstr "Pratonton tidak tersedia" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "Tugasan" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Beritahu kami mengenai pepijat atau isu yang anda temui." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "Item {0} telah dipadam" msgid "This date # and this emoji # are snippets" msgstr "Tarikh ini # dan emoji ini # adalah coretan" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "Taip ayat contoh" msgid "Type:" msgstr "Jenis:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "Tidak dapat membuka dalam FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "Kemas kini tidak dibenarkan oleh tetapan kebenaran." msgid "Updates" msgstr "Kemas kini" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index b8dc19aa1c..c5970a19b8 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -990,6 +990,8 @@ msgstr "Glosi" msgid "Go to {0}" msgstr "Nenda kwa {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "Siioni mradi wangu" msgid "I understand that this can't be undone" msgstr "Ninaelewa kuwa hii haiwezi kurudishwa nyuma" +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "Inayofuata" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "Hakuna kitu kilichopatikana" msgid "No new data" msgstr "Hakuna data mpya" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "Inasubiri" msgid "Pick a Part of Speech" msgstr "Chagua Sehemu ya Mazungumzo" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "Hakiki" msgid "Preview not available" msgstr "Muonekano awali haupatikani" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "Jukumu" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Tuambie kuhusu kosa au matatizo uliyokabiliwa." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "Hili {0} limefutwa" msgid "This date # and this emoji # are snippets" msgstr "Tarehe hii # na emoji hii # ni vigezo" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "Andika sentensi ya mfano" msgid "Type:" msgstr "Aina:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "Haiwezi kufungua katika FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "Sasisho halikuruhusiwa na mipangilio ya ruhusa." msgid "Updates" msgstr "Masasisho" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index f9c690cefe..c349661b97 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -990,6 +990,8 @@ msgstr "Ghi chú" msgid "Go to {0}" msgstr "Đi tới {0}" +#. Aria-label on a carousel dot button below the picture; navigates to a specific picture +#. {0} = 1-based picture number (e.g., "Go to picture 2") #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Go to picture {0}" msgstr "" @@ -1043,6 +1045,7 @@ msgstr "Tôi không thấy dự án của mình" msgid "I understand that this can't be undone" msgstr "Tôi hiểu rằng điều này không thể hoàn tác" +#. Error displayed in place of a picture when the media file is missing from local storage #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Image not found" msgstr "" @@ -1328,6 +1331,7 @@ msgstr "" msgid "Next" msgstr "Tiếp theo" +#. Aria-label on the right-arrow button in the picture carousel; advances to the next image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Next picture" msgstr "" @@ -1386,6 +1390,7 @@ msgstr "Không tìm thấy mục nào" msgid "No new data" msgstr "Không có dữ liệu mới" +#. Empty state shown read-only in the Pictures field when a sense has no pictures #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "No pictures" msgstr "" @@ -1566,11 +1571,13 @@ msgstr "Đang chờ xử lý" msgid "Pick a Part of Speech" msgstr "Chọn một Loại từ" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" msgstr "" @@ -1599,6 +1606,7 @@ msgstr "Xem trước" msgid "Preview not available" msgstr "Xem trước không khả dụng" +#. Aria-label on the left-arrow button in the picture carousel; goes back to the previous image #: src/lib/entry-editor/field-editors/PictureCarousel.svelte msgid "Previous picture" msgstr "" @@ -1978,6 +1986,10 @@ msgstr "Nhiệm vụ" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Hãy cho chúng tôi biết về lỗi hoặc vấn đề bạn gặp phải." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "That picture has already been uploaded" +msgstr "" + #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." @@ -2024,6 +2036,14 @@ msgstr "Đường dẫn này {0} đã bị xóa." msgid "This date # and this emoji # are snippets" msgstr "Ngày này # và biểu tượng cảm xúc này # là đoạn trích" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2092,6 +2112,7 @@ msgstr "Gõ một câu ví dụ" msgid "Type:" msgstr "Loại:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Unable to load image" @@ -2102,6 +2123,10 @@ msgstr "" msgid "Unable to open in FieldWorks" msgstr "Không thể mở trong FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2197,6 +2222,10 @@ msgstr "Cập nhật đã bị từ chối do cài đặt quyền truy cập." msgid "Updates" msgstr "Cập nhật" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index bbaa214cb0..360a571417 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -13,6 +13,7 @@ import { type IMiniLcmJsInvokable, type IMorphType, type IPartOfSpeech, + type IPicture, type IProjectModel, type IPublication, type IQueryOptions, @@ -29,6 +30,7 @@ import { import {demoPictureSvgs, entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; import {WritingSystemService} from '../data/writing-system-service.svelte'; +import {randomId} from '$lib/utils'; import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform'; import {delay} from '$lib/utils/time'; import {initProjectContext, type ProjectContext} from '$project/project-context.svelte'; @@ -50,6 +52,9 @@ function pickWs(ws: string, defaultWs: string): string { return ws === 'default' ? defaultWs : ws; } +/** The demo plays the role of the server; it mirrors the backend's 10 MB upload limit. */ +const DEMO_FILE_SIZE_LIMIT = 10 * 1024 * 1024; + const complexFormTypes = entries .flatMap(entry => entry.complexFormTypes) .filter((value, index, all) => all.findIndex(v2 => v2.id === value.id) === index); @@ -380,6 +385,39 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { return Promise.resolve(); } + createPicture(entryGuid: string, senseGuid: string, picture: IPicture): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + // Generated demo senses may omit `pictures` entirely, so default it rather than spread undefined. + sense.pictures = [...(sense.pictures ?? []), picture]; + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(picture); + } + + updatePicture(entryGuid: string, senseGuid: string, _before: IPicture, after: IPicture): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + const index = (sense.pictures ?? []).findIndex(p => p.id === after.id); + if (index === -1) throw new Error(`Picture ${after.id} not found`); + sense.pictures.splice(index, 1, after); + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(after); + } + + deletePicture(entryGuid: string, senseGuid: string, pictureGuid: string): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + sense.pictures = (sense.pictures ?? []).filter(p => p.id !== pictureGuid); + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(); + } + createWritingSystem(_type: WritingSystemType, _writingSystem: IWritingSystem): Promise { throw new Error('Method not implemented.'); } @@ -520,7 +558,22 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { throw new Error('Method not implemented.'); } + // Files uploaded during the demo session (e.g. via the "+ Picture" button), keyed by the + // mediaUri handed back from saveFile. Lets the demo round-trip an upload without a backend. + #uploadedFiles = new Map(); + getFileStream(mediaUri: string): Promise { + const uploaded = this.#uploadedFiles.get(mediaUri); + if (uploaded) { + return Promise.resolve({ + result: ReadFileResult.Success, + fileName: mediaUri.split('/').pop() ?? 'demo-upload', + stream: { + stream: () => Promise.resolve(uploaded.stream()), + arrayBuffer: () => uploaded.arrayBuffer(), + }, + }); + } const svg = demoPictureSvgs[mediaUri]; if (!svg) return Promise.resolve({result: ReadFileResult.NotFound}); const blob = new Blob([svg], {type: 'image/svg+xml'}); @@ -534,7 +587,17 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { }); } - saveFile(_streamReference: Blob | ArrayBuffer | Uint8Array, _metadata: ILcmFileMetadata): Promise { - return Promise.resolve({result: UploadFileResult.NotSupported}); + saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise { + const blob = streamReference instanceof Blob + ? streamReference + : new Blob([streamReference as BlobPart], {type: metadata.mimeType}); + // The demo stands in for the server, so it enforces the same 10 MB limit the real + // backend does — exercising the client's TooBig handling without a backend. + if (blob.size > DEMO_FILE_SIZE_LIMIT) { + return Promise.resolve({result: UploadFileResult.TooBig}); + } + const mediaUri = `demo-upload/${randomId()}`; + this.#uploadedFiles.set(mediaUri, blob); + return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri}); } } diff --git a/frontend/viewer/tests/sense-pictures.test.ts b/frontend/viewer/tests/sense-pictures.test.ts index 018c2477d9..7ab5aaa2e9 100644 --- a/frontend/viewer/tests/sense-pictures.test.ts +++ b/frontend/viewer/tests/sense-pictures.test.ts @@ -1,10 +1,17 @@ import {expect, test} from '@playwright/test'; import {BrowsePage} from './browse-page'; +// A tiny valid 1x1 PNG, used to exercise the upload flow without a real image file. +const ONE_PX_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + 'base64', +); + /** * Verifies the Sense "Pictures" field: * - a sense with pictures renders the image (loaded via getFileStream into a blob url) + caption - * - a sense without pictures shows the disabled "+ Picture" add button + * - a sense without pictures shows the enabled "+ Picture" add button, and uploading a file + * through it adds a picture that then renders * * The demo "nyumba" entry (allWsEntry) has two demo pictures on its sense; other demo * entries (e.g. "ambuka") have none. @@ -29,7 +36,7 @@ test.describe('Sense pictures', () => { await expect(picturesField.getByText(/A traditional house|Uma casa tradicional/)).toBeVisible(); }); - test('shows a disabled "+ Picture" button for a sense with no pictures', async ({page}) => { + test('shows an enabled "+ Picture" button for a sense with no pictures', async ({page}) => { const browsePage = new BrowsePage(page); await browsePage.goto(); @@ -41,8 +48,31 @@ test.describe('Sense pictures', () => { const addButton = picturesField.getByRole('button', {name: 'Picture'}); await expect(addButton).toBeVisible(); - await expect(addButton).toBeDisabled(); - // The add button is the empty state, so no image should be present. + await expect(addButton).toBeEnabled(); + // The add button is the empty state, so no image should be present yet. + await expect(picturesField.locator('img')).toHaveCount(0); + }); + + test('uploading a picture through the "+ Picture" button adds and renders it', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + await browsePage.selectEntryByFilter('ambuka'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); await expect(picturesField.locator('img')).toHaveCount(0); + + // Clicking the button opens the OS file picker; drive the hidden input directly instead. + await picturesField.locator('input[type="file"]').setInputFiles({ + name: 'photo.png', + mimeType: 'image/png', + buffer: ONE_PX_PNG, + }); + + // The uploaded picture is created and re-loaded via getFileStream into a blob url. + const image = picturesField.locator('img').first(); + await expect(image).toBeVisible({timeout: 5000}); + await expect(image).toHaveAttribute('src', /^blob:/); }); }); From 20b0789fd182f7d8f1397b711ce3736fb57bb04b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 3 Jul 2026 12:16:54 +0700 Subject: [PATCH 07/32] Fix UNIQUE constraint crash when a picture is first loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading a not-yet-cached media file could crash with "SQLite Error 19: 'UNIQUE constraint failed: LocalResource.Id'". LcmMediaService.GetFileStream checks GetLocalResource, and on a miss calls ResourceService.DownloadResource, which inserts a LocalResource keyed by the file id. When the UI requests the same file more than once before it is cached (e.g. on a picture's first render), two calls both see no local resource and both download and insert, so the second insert violates the primary key. Coalesce concurrent downloads of the same file into one shared task, kept in a ConcurrentDictionary keyed by file id: the first caller starts the download and every concurrent caller awaits that same task, which is removed once it completes. Two races are handled: - GetOrAdd's factory overload can run more than once under contention (which would start the download twice), so the not-yet-started TaskCompletionSource is added via the atomic value overload — only the caller whose task is stored starts the download. - A caller can miss in GetLocalResource just before another caller commits the download. The shared task re-checks GetLocalResource before downloading, and the entry is removed only after the download commits, so a later fresh task finds the committed resource instead of inserting a duplicate key. Different files still download in parallel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LcmCrdt/MediaServer/LcmMediaService.cs | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index d4cec3d0e0..063bc5aa1f 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Options; using MiniLcm.Project; using Refit; @@ -48,20 +49,27 @@ public async Task DeleteResource(Guid fileId) /// media file Id /// /// + // Coalesces concurrent downloads of the same file into one shared task. Without this, two + // requests for a not-yet-cached file (e.g. the UI loading a picture more than once at first + // render) both see no local resource and both call DownloadResource, whose AddLocalResource + // inserts a LocalResource keyed by the file id — the second insert then fails with + // "UNIQUE constraint failed: LocalResource.Id". The first caller starts the task; others + // await the same one. Keyed by file id (a unique Guid), so different files still download in + // parallel. + private static readonly ConcurrentDictionary> DownloadTasks = new(); + public async Task GetFileStream(Guid fileId) { var localResource = await resourceService.GetLocalResource(fileId); if (localResource is null) { var connectionStatus = await httpClientProvider.ConnectionStatus(); - if (connectionStatus == ConnectionStatus.Online) - { - localResource = await resourceService.DownloadResource(fileId, this); - } - else + if (connectionStatus != ConnectionStatus.Online) { return new ReadFileResponse(ReadFileResult.Offline); } + + localResource = await GetOrStartDownload(fileId); } //todo, consider trying to download the file again, maybe the cache was cleared if (!File.Exists(localResource.LocalPath)) @@ -69,6 +77,49 @@ public async Task GetFileStream(Guid fileId) return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath)); } + private Task GetOrStartDownload(Guid fileId) + { + // Use the *value* overload of GetOrAdd, not the factory overload: the factory can run more + // than once under contention (only one result is kept), which would start the download + // twice. Passing a not-yet-started TaskCompletionSource.Task lets exactly one caller — the + // one whose task actually got stored — kick off the download. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var task = DownloadTasks.GetOrAdd(fileId, tcs.Task); + if (task != tcs.Task) + { + // Another caller already owns the in-flight download; await theirs (our tcs is unused). + return task; + } + _ = RunDownloadAsync(fileId, tcs); + return task; + } + + private async Task RunDownloadAsync(Guid fileId, TaskCompletionSource tcs) + { + try + { + // Re-check before downloading: a previous download may have committed the LocalResource + // after our caller's GetLocalResource miss (this closes the race between + // GetLocalResource and DownloadResource). The task entry is removed only after this + // completes — i.e. after the download has committed — so any later caller that starts a + // fresh task will find the committed resource here and skip the download, rather than + // inserting a duplicate primary key. + var resource = await resourceService.GetLocalResource(fileId) + ?? await resourceService.DownloadResource(fileId, this); + tcs.SetResult(resource); + } + catch (Exception e) + { + tcs.SetException(e); + } + finally + { + // Cached now, so future misses can start fresh; also prevents a failed download from + // being cached as a permanently-faulted task. + DownloadTasks.TryRemove(fileId, out _); + } + } + private async Task<(Stream? stream, string? filename)> RequestMediaFile(Guid fileId) { var mediaClient = await MediaServerClient(); From c6d63ce18e32941895ef01162b955f1fa77b0ef6 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 3 Jul 2026 14:19:05 +0700 Subject: [PATCH 08/32] Handle media download failures gracefully instead of throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetFileStream threw when a download failed (e.g. the media server/gateway returns 504 Gateway Timeout, or the file is missing on the server). The exception propagated all the way to the JS caller, so instead of the picture UI showing an error state, an unhandled exception surfaced after a long wait. Callers like PictureImage are written to expect failures via the ReadFileResult enum, not exceptions. Catch download failures, log them, and return ReadFileResult.Error with the message so the UI can show a graceful error (and a later navigation can retry) rather than crashing. Note: this does not make an unreachable/slow media server succeed — it only makes the client fail gracefully. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FwLite/LcmCrdt/MediaServer/LcmMediaService.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 063bc5aa1f..d3f41477c9 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -69,7 +69,19 @@ public async Task GetFileStream(Guid fileId) return new ReadFileResponse(ReadFileResult.Offline); } - localResource = await GetOrStartDownload(fileId); + try + { + localResource = await GetOrStartDownload(fileId); + } + catch (Exception e) + { + // The download can fail for reasons outside our control — the file may be missing + // on the server, or the media server/gateway may time out (504). Callers such as + // PictureImage expect failures via the result enum, not exceptions, so surface a + // graceful Error result (shown in the UI) instead of throwing an unhandled exception. + logger.LogError(e, "Failed to download media file {FileId}", fileId); + return new ReadFileResponse(ReadFileResult.Error, e.Message); + } } //todo, consider trying to download the file again, maybe the cache was cleared if (!File.Exists(localResource.LocalPath)) From 933415d88ac07dee51dbf79b34b5680cae666691 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 3 Jul 2026 15:53:46 +0700 Subject: [PATCH 09/32] Retry transient media download failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold request to the proxied media endpoint (lexbox -> FwHeadless) can exceed the proxy's timeout on the first touch — e.g. before the backend's code paths are JIT-warmed — and come back as 504 (or 502/503) even though the backend then warms up and serves the file in milliseconds. Previously the concurrent-download bug masked this: several requests per file meant a later, warm one succeeded. Now that downloads are coalesced into one, a single cold 504 was terminal. Retry transient failures (408/502/503/504) up to 3 times with a short delay. A failed fetch never reaches AddLocalResource, so this cannot reintroduce the duplicate-key insert; non-transient failures still fail immediately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LcmCrdt/MediaServer/LcmMediaService.cs | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index d3f41477c9..bc4ef96edc 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Net; using Microsoft.Extensions.Options; using MiniLcm.Project; using Refit; @@ -132,15 +133,42 @@ private async Task RunDownloadAsync(Guid fileId, TaskCompletionSource FwHeadless). A cold request — e.g. the first one after the + // backend starts, before its code paths are JIT-warmed — can exceed the proxy's timeout and come + // back as 504 (or 502/503) even though the backend keeps working and warms up. Retrying a + // transient failure a few times usually hits the now-warm backend and succeeds. A failed fetch + // never reaches AddLocalResource, so retrying cannot reintroduce the duplicate-key insert. + private const int MaxDownloadAttempts = 3; + private static readonly HashSet TransientDownloadStatusCodes = + [ + HttpStatusCode.RequestTimeout, // 408 + HttpStatusCode.BadGateway, // 502 + HttpStatusCode.ServiceUnavailable, // 503 + HttpStatusCode.GatewayTimeout, // 504 + ]; + private async Task<(Stream? stream, string? filename)> RequestMediaFile(Guid fileId) { var mediaClient = await MediaServerClient(); - var response = await mediaClient.DownloadFile(fileId); - if (!response.IsSuccessStatusCode) + for (var attempt = 1; ; attempt++) { - throw new Exception($"Failed to download file {fileId}: {response.StatusCode} {response.ReasonPhrase}"); + var response = await mediaClient.DownloadFile(fileId); + if (response.IsSuccessStatusCode) + { + return (await response.Content.ReadAsStreamAsync(), response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", "")); + } + + var statusCode = response.StatusCode; + var reasonPhrase = response.ReasonPhrase; + response.Dispose(); + + if (attempt >= MaxDownloadAttempts || !TransientDownloadStatusCodes.Contains(statusCode)) + { + throw new Exception($"Failed to download file {fileId}: {statusCode} {reasonPhrase}"); + } + + await Task.Delay(TimeSpan.FromMilliseconds(200)); } - return (await response.Content.ReadAsStreamAsync(), response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", "")); } private async Task MediaServerClient() From 056d35bb6f43165c6058e3c9d8cef2d2d86a6426 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 3 Jul 2026 16:19:37 +0700 Subject: [PATCH 10/32] Tweak Picture field layout: left-justify image, right-align add button - PictureImage: left-justify the image (and caption) instead of centering it. - PicturesEditor: size the "+ Picture" button to its (translatable) label and right-align it, matching the "+ Component" button, rather than stretching to the full grid-column width. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/entry-editor/field-editors/PictureImage.svelte | 2 +- .../src/lib/entry-editor/field-editors/PicturesEditor.svelte | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 79831ec3f5..776586dbac 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -63,7 +63,7 @@ }); -
+
{#if state.status === 'loaded'} {caption {:else if state.status === 'loading'} 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 b37a4bb980..377ff92b62 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -92,7 +92,10 @@ {#if pictures.length > 0} {:else if !readonly} - From 1db16eae0ce22768ba48f95d4ac4f9e463660d72 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 6 Jul 2026 09:45:17 +0700 Subject: [PATCH 11/32] Left-justify carousel controls under the picture The picture carousel filled the full field width, so its centered prev/next/dot controls floated far to the right of the left-justified picture. Shrink the carousel to the width of its content (the picture) with w-fit, so it stays left-justified and the centered controls land directly under the picture. max-w-full keeps a very wide picture from overflowing the field. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/entry-editor/field-editors/PictureCarousel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index a87c6c7e49..d0a0e6831b 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -62,7 +62,7 @@ }); -
+
{#each pictures as picture (picture.id)} From a87236c35bb24a7b4502cc67800db67f687264c7 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 6 Jul 2026 10:09:52 +0700 Subject: [PATCH 12/32] Left-justify carousel controls under the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picture carousel filled the full field width, so its centered prev/next/dot controls floated far to the right of the left-justified picture. When there are multiple pictures (the only time controls show), bound the carousel to a fixed width and center each picture within it, so the centered controls sit directly under the picture. Sizing the carousel to the picture itself isn't possible here — embla lays its slides out in a flex row, so a shrink-to-fit width would span the sum of all slides. A single picture is unchanged (natural size, left-justified) since it has no controls to align. A bounded box also has a definite width, so the loading placeholder fills it instead of collapsing — the box stays put while the image loads (which can take a second or two on slow mobile networks) rather than reflowing once it arrives. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../entry-editor/field-editors/PictureCarousel.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index d0a0e6831b..be774ecff8 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -62,11 +62,16 @@ }); -
+ +
{#each pictures as picture (picture.id)} -
+
{/each} From 71181ba69e1094f691c89e853650582a6f1962a6 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 6 Jul 2026 13:06:08 +0700 Subject: [PATCH 13/32] Expose MovePicture to frontend API For the future, if we decide to allow rearranging the order of pictures in the FW Lite UI. --- .../FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs | 8 ++++++++ .../FwLiteShared/Services/IMiniLcmJsInvokable.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 20d97a56c8..6ad632950c 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -398,6 +398,14 @@ public async Task UpdatePicture(Guid entryId, Guid senseId, Picture bef return updatedPicture; } + [JSInvokable] + // previousPictureId and nextPictureId represent the target position, where the picture should end up after being moved + public async Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, Guid? previousPictureId, Guid? nextPictureId) + { + await _wrappedApi.MovePicture(entryId, senseId, pictureId, new MiniLcm.SyncHelpers.BetweenPosition(previousPictureId, nextPictureId)); + OnDataChanged(); + } + [JSInvokable] public async Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) { diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 353eb390da..31fc331d67 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -78,6 +78,7 @@ export interface IMiniLcmJsInvokable deleteExampleSentence(entryId: string, senseId: string, exampleSentenceId: string) : Promise; createPicture(entryId: string, senseId: string, picture: IPicture) : Promise; updatePicture(entryId: string, senseId: string, before: IPicture, after: IPicture) : Promise; + movePicture(entryId: string, senseId: string, pictureId: string, previousPictureId: string, nextPictureId: string) : Promise; deletePicture(entryId: string, senseId: string, pictureId: string) : Promise; getFileStream(mediaUri: string) : Promise; saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata) : Promise; From 4ffd9f953ed7976e422673f6b2af7d9616c375ed Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 7 Jul 2026 14:19:24 +0700 Subject: [PATCH 14/32] Add Replace/Delete picture actions; keep "+ Picture" always visible Previously the "+ Picture" button only appeared for a sense with no pictures. Now the picture editor always offers "+ Picture" (so more can be added), and when at least one picture exists it also offers "Replace Picture" and "Delete Picture", both acting on the picture currently shown in the carousel. - PictureCarousel exposes its current slide via a bindable `selectedIndex`, so PicturesEditor knows which picture Replace/Delete target. - Replace uploads a new file (same saveFile flow as add) and calls UpdatePicture with the same picture id/order/caption but the new mediaUri. - Delete confirms via the standard delete dialog, then calls DeletePicture. - Both changes surface via the entry-changed event (the notify wrapper fires on Update/DeletePicture), which reloads the entry so the carousel updates. - Demo API already implements update/deletePicture, so this round-trips offline. - Playwright: cover the always-present add button, delete-with-confirmation, and in-place replace. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureCarousel.svelte | 5 +- .../field-editors/PicturesEditor.svelte | 182 ++++++++++++------ frontend/viewer/src/locales/en.po | 9 + frontend/viewer/src/locales/es.po | 9 + frontend/viewer/src/locales/fr.po | 9 + frontend/viewer/src/locales/id.po | 9 + frontend/viewer/src/locales/ko.po | 9 + frontend/viewer/src/locales/ms.po | 9 + frontend/viewer/src/locales/sw.po | 9 + frontend/viewer/src/locales/vi.po | 9 + frontend/viewer/tests/sense-pictures.test.ts | 56 +++++- 11 files changed, 254 insertions(+), 61 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index be774ecff8..f1fd1eeba3 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -23,11 +23,12 @@ type Props = { pictures: IPicture[]; + /** Index of the picture currently shown; bindable so the parent can act on the current picture. */ + selectedIndex?: number; }; - const {pictures}: Props = $props(); + let {pictures, selectedIndex = $bindable(0)}: Props = $props(); let emblaApi = $state(); - let selectedIndex = $state(0); const hasMultiple = $derived(pictures.length > 1); 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 377ff92b62..4d1e7d02cb 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -5,6 +5,7 @@ import PictureCarousel from './PictureCarousel.svelte'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; + import {useDialogsService} from '$lib/services/dialogs-service'; import {AppNotification} from '$lib/notifications/notifications'; import {randomId} from '$lib/utils'; @@ -17,18 +18,27 @@ const {value, entryId, senseId, readonly = false}: Props = $props(); const api = useLexboxApi(); + const dialogsService = useDialogsService(); // `pictures` is typed as required, but older/legacy sense data may omit it entirely, // so guard against undefined rather than trusting the type at runtime. const pictures = $derived(value ?? []); + // The carousel reports which picture is currently shown; Replace/Delete act on it. + let selectedIndex = $state(0); + const currentPicture = $derived(pictures[selectedIndex]); + // Formats the browser accepts and that the server supports for pictures. const ACCEPTED_TYPES = 'image/jpeg,image/png'; let fileInputElement = $state(); - let uploading = $state(false); + // Which operation is in flight (drives per-button spinners + disabling the whole group). + let busyAction = $state<'add' | 'replace' | 'delete' | null>(null); + // Whether the file picker that's about to open is for adding or replacing. + let pendingFileAction: 'add' | 'replace' = 'add'; - function selectFile() { + function pickFile(action: 'add' | 'replace') { + pendingFileAction = action; fileInputElement?.click(); } @@ -37,45 +47,75 @@ const file = target.files?.[0]; // Reset the input so selecting the same file again re-triggers `change`. target.value = ''; - if (file) void uploadPicture(file); + if (!file) return; + if (pendingFileAction === 'replace') void replacePicture(file); + else void addPicture(file); } - async function uploadPicture(file: File): Promise { - uploading = true; - try { - // saveFile reports the 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 a `TooBig` result. - const response = await api.saveFile(file, {filename: file.name, mimeType: file.type}); - switch (response.result) { - case UploadFileResult.SavedLocally: - case UploadFileResult.SavedToLexbox: - break; - case UploadFileResult.TooBig: - AppNotification.display(tooBigMessage(file), {type: 'error', timeout: 'long'}); - return; - case UploadFileResult.NotSupported: - AppNotification.display($t`Uploading pictures is not supported here`, {type: 'error'}); - return; - case UploadFileResult.AlreadyExists: - AppNotification.display($t`That picture has already been uploaded`, {type: 'error'}); - return; - case UploadFileResult.Error: - AppNotification.display(response.errorMessage ?? $t`Unable to upload the picture`, {type: 'error'}); - return; - } - if (!response.mediaUri) throw new Error('saveFile succeeded but returned no mediaUri'); + // 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}); + switch (response.result) { + case UploadFileResult.SavedLocally: + case UploadFileResult.SavedToLexbox: + break; + case UploadFileResult.TooBig: + AppNotification.display(tooBigMessage(file), {type: 'error', timeout: 'long'}); + return null; + case UploadFileResult.NotSupported: + AppNotification.display($t`Uploading pictures is not supported here`, {type: 'error'}); + return null; + case UploadFileResult.AlreadyExists: + AppNotification.display($t`That picture has already been uploaded`, {type: 'error'}); + return null; + case UploadFileResult.Error: + AppNotification.display(response.errorMessage ?? $t`Unable to upload the picture`, {type: 'error'}); + return null; + } + if (!response.mediaUri) throw new Error('saveFile succeeded but returned no mediaUri'); + return response.mediaUri; + } - const picture: IPicture = { - id: randomId(), - order: pictures.length, - mediaUri: response.mediaUri, - caption: {}, - }; + async function addPicture(file: File): Promise { + busyAction = 'add'; + try { + 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 created picture surfaces via the entry-changed event, which reloads the entry. + // The change surfaces via the entry-changed event, which reloads the entry. + } finally { + busyAction = null; + } + } + + async function replacePicture(file: File): Promise { + const before = currentPicture ? $state.snapshot(currentPicture) : undefined; + if (!before) return; + busyAction = 'replace'; + try { + const mediaUri = await uploadFile(file); + if (!mediaUri) return; + // Keep the same picture (id, order, caption); only swap the image it points at. + const after: IPicture = {...before, mediaUri}; + await api.updatePicture(entryId, senseId, before, after); + } finally { + busyAction = null; + } + } + + async function deletePicture(): Promise { + const target = currentPicture ? $state.snapshot(currentPicture) : undefined; + if (!target) return; + if (!(await dialogsService.promptDelete($t`Picture`))) return; + busyAction = 'delete'; + try { + await api.deletePicture(entryId, senseId, target.id); } finally { - uploading = false; + busyAction = null; } } @@ -89,25 +129,51 @@ } -{#if pictures.length > 0} - -{:else if !readonly} - - - - -{:else} -
- {$t`No pictures`} -
-{/if} +
+ {#if pictures.length > 0} + + {:else if readonly} +
+ {$t`No pictures`} +
+ {/if} + + {#if !readonly} + +
+ + {#if pictures.length > 0} + + + {/if} +
+ + + {/if} +
diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index f7b4d8b829..0f2f20cf6e 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -567,6 +567,10 @@ msgstr "Delete {0}" msgid "Delete Entry" msgstr "Delete Entry" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "Delete Picture" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1569,6 +1573,7 @@ msgstr "Pick a Part of Speech" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "Picture" @@ -1691,6 +1696,10 @@ msgstr "Reopen" msgid "Replace audio" msgstr "Replace audio" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "Replace Picture" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index b4d574285c..97415fbef6 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -572,6 +572,10 @@ msgstr "Borrar {0}" msgid "Delete Entry" msgstr "Borrar entrada" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "Escoge una parte de la voz" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "Vuelva a abrir" msgid "Replace audio" msgstr "Sustituir audio" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 700c5ccfb0..94ddc1752e 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -572,6 +572,10 @@ msgstr "Supprimer {0}" msgid "Delete Entry" msgstr "Supprimer l'entrée" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "Choisir une partie du discours" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "Rouvrir" msgid "Replace audio" msgstr "Remplacer l'audio" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 19c546a124..24a1c440dd 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -572,6 +572,10 @@ msgstr "Hapus {0}" msgid "Delete Entry" msgstr "Hapus Entri" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "Pilih Bagian dari Pidato" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "Buka kembali" msgid "Replace audio" msgstr "Mengganti audio" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 946b3891bc..c45db78ad8 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -572,6 +572,10 @@ msgstr "삭제 {0}" msgid "Delete Entry" msgstr "항목 삭제" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "품사 선택" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "다시 열기" msgid "Replace audio" msgstr "오디오 교체" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 17605d3d58..37d70cd5da 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -572,6 +572,10 @@ msgstr "Hapus {0}" msgid "Delete Entry" msgstr "Padam Entri" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "Pilih Bahagian Pertuturan" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "Buka semula" msgid "Replace audio" msgstr "Ganti audio" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index c5970a19b8..dc2634c135 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -572,6 +572,10 @@ msgstr "Futa {0}" msgid "Delete Entry" msgstr "Futa ingizo" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "Chagua Sehemu ya Mazungumzo" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "Fungua Upya" msgid "Replace audio" msgstr "Badili sauti" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index c349661b97..17bd24e3b8 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -572,6 +572,10 @@ msgstr "Xóa {0}" msgid "Delete Entry" msgstr "Xóa mục" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -1574,6 +1578,7 @@ msgstr "Chọn một Loại từ" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" @@ -1696,6 +1701,10 @@ msgstr "Mở lại" msgid "Replace audio" msgstr "Thay thế âm thanh" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" diff --git a/frontend/viewer/tests/sense-pictures.test.ts b/frontend/viewer/tests/sense-pictures.test.ts index 7ab5aaa2e9..92de537eb1 100644 --- a/frontend/viewer/tests/sense-pictures.test.ts +++ b/frontend/viewer/tests/sense-pictures.test.ts @@ -1,4 +1,4 @@ -import {expect, test} from '@playwright/test'; +import {expect, test, type Page} from '@playwright/test'; import {BrowsePage} from './browse-page'; // A tiny valid 1x1 PNG, used to exercise the upload flow without a real image file. @@ -75,4 +75,58 @@ test.describe('Sense pictures', () => { await expect(image).toBeVisible({timeout: 5000}); await expect(image).toHaveAttribute('src', /^blob:/); }); + + /** Uploads one picture to "ambuka" (which starts empty) and returns the pictures-field locator. */ + async function addOnePicture(page: Page) { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + await browsePage.selectEntryByFilter('ambuka'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + await picturesField.locator('input[type="file"]').setInputFiles({ + name: 'photo.png', mimeType: 'image/png', buffer: ONE_PX_PNG, + }); + await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + return picturesField; + } + + test('"+ Picture" stays available alongside Replace/Delete once a picture exists', async ({page}) => { + const picturesField = await addOnePicture(page); + + // The add button is still present even though a picture now exists... + await expect(picturesField.getByRole('button', {name: 'Picture', exact: true})).toBeVisible(); + // ...plus the two picture-specific actions. + await expect(picturesField.getByRole('button', {name: 'Replace Picture'})).toBeVisible(); + await expect(picturesField.getByRole('button', {name: 'Delete Picture'})).toBeVisible(); + }); + + test('Delete Picture removes the current picture (after confirmation)', async ({page}) => { + const picturesField = await addOnePicture(page); + + await picturesField.getByRole('button', {name: 'Delete Picture'}).click(); + // Confirm in the delete dialog (its confirm button is also labelled "Delete Picture"). + await page.getByRole('alertdialog').getByRole('button', {name: 'Delete Picture', exact: true}).click(); + + // Picture (and the Replace/Delete actions) are gone; the add button remains. + await expect(picturesField.locator('img')).toHaveCount(0, {timeout: 5000}); + await expect(picturesField.getByRole('button', {name: 'Delete Picture'})).toHaveCount(0); + await expect(picturesField.getByRole('button', {name: 'Replace Picture'})).toHaveCount(0); + await expect(picturesField.getByRole('button', {name: 'Picture', exact: true})).toBeVisible(); + }); + + test('Replace Picture swaps the current picture in place', async ({page}) => { + const picturesField = await addOnePicture(page); + const image = picturesField.locator('img').first(); + const originalSrc = await image.getAttribute('src'); + + await picturesField.getByRole('button', {name: 'Replace Picture'}).click(); + await picturesField.locator('input[type="file"]').setInputFiles({ + name: 'replacement.png', mimeType: 'image/png', buffer: ONE_PX_PNG, + }); + + // Still exactly one picture (replaced, not added), re-loaded into a fresh blob url. + await expect(picturesField.locator('img')).toHaveCount(1); + await expect(image).toHaveAttribute('src', /^blob:/); + await expect(image).not.toHaveAttribute('src', originalSrc ?? '', {timeout: 5000}); + }); }); From 36b287e229ac26fc74b1c68d9fdad0236e0c45b4 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 7 Jul 2026 14:30:14 +0700 Subject: [PATCH 15/32] Pause carousel autoplay while hovering/focusing Replace or Delete The picture carousel auto-advances every 10s, so the "current" picture could change between the user reaching for Replace/Delete and clicking it. Suspend auto-advance while the Replace/Delete button group is hovered or focused. - PictureCarousel gains a `paused` prop; its auto-advance effect bails (clearing the interval) while paused and resumes when unpaused. - PicturesEditor wraps the Replace/Delete buttons in a role="group" that toggles the paused state on mouseenter/leave and focusin/focusout. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureCarousel.svelte | 8 ++- .../field-editors/PicturesEditor.svelte | 55 ++++++++++++------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index f1fd1eeba3..14d06d8f80 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -25,8 +25,10 @@ pictures: IPicture[]; /** Index of the picture currently shown; bindable so the parent can act on the current picture. */ selectedIndex?: number; + /** When true, auto-advance is suspended (e.g. while the user is about to act on the current picture). */ + paused?: boolean; }; - let {pictures, selectedIndex = $bindable(0)}: Props = $props(); + let {pictures, selectedIndex = $bindable(0), paused = false}: Props = $props(); let emblaApi = $state(); @@ -54,10 +56,10 @@ }; }); - // Auto-advance every 10 seconds when there is more than one picture. + // Auto-advance every 10 seconds when there is more than one picture (unless paused). $effect(() => { const api = emblaApi; - if (!api || !hasMultiple) return; + if (!api || !hasMultiple || paused) return; const interval = setInterval(() => api.scrollNext(), AUTOPLAY_DELAY_MS); return () => clearInterval(interval); }); 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 4d1e7d02cb..747a18a9b9 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -28,6 +28,10 @@ let selectedIndex = $state(0); const currentPicture = $derived(pictures[selectedIndex]); + // Pause the carousel's auto-advance while the user is hovering/focusing the Replace or Delete + // buttons, so the "current" picture can't change out from under them before they click. + let pauseAutoplay = $state(false); + // Formats the browser accepts and that the server supports for pictures. const ACCEPTED_TYPES = 'image/jpeg,image/png'; @@ -131,7 +135,7 @@
{#if pictures.length > 0} - + {:else if readonly}
{$t`No pictures`} @@ -145,26 +149,37 @@ {$t`Picture`} {#if pictures.length > 0} - - + + +
{/if}
From ba73cf3183a0956014891d357de34f8f14c42a03 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 7 Jul 2026 16:31:07 +0700 Subject: [PATCH 16/32] Implement movePicture in the demo API The "Expose MovePicture to frontend API" change added movePicture to the IMiniLcmJsInvokable interface but not to the in-memory demo API, so the demo class no longer satisfied the interface (svelte-check failed). Implement it as a simple reorder of the sense's pictures between the given neighbours. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/project/demo/in-memory-demo-api.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 360a571417..80adae60f0 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -418,6 +418,25 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { return Promise.resolve(); } + movePicture(entryGuid: string, senseGuid: string, pictureGuid: string, previousPictureGuid: string, nextPictureGuid: string): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + const pictures = [...(sense.pictures ?? [])]; + const from = pictures.findIndex(p => p.id === pictureGuid); + if (from === -1) throw new Error(`Picture ${pictureGuid} not found`); + const [moved] = pictures.splice(from, 1); + // Insert after the previous picture if given, else before the next, else at the end. + const prevIdx = previousPictureGuid ? pictures.findIndex(p => p.id === previousPictureGuid) : -1; + const nextIdx = nextPictureGuid ? pictures.findIndex(p => p.id === nextPictureGuid) : -1; + const insertAt = prevIdx !== -1 ? prevIdx + 1 : nextIdx !== -1 ? nextIdx : pictures.length; + pictures.splice(insertAt, 0, moved); + sense.pictures = pictures; + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(); + } + createWritingSystem(_type: WritingSystemType, _writingSystem: IWritingSystem): Promise { throw new Error('Method not implemented.'); } From 7f875d5eb48a575bb9ea024f947a93ac3dc56ffa Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 7 Jul 2026 16:32:56 +0700 Subject: [PATCH 17/32] Reuse the existing media file when an upload reports AlreadyExists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading an image that already exists on the server is not an error in this app: one image file (mediaUri) can back many Picture objects across different senses and entries. A Picture object is owned by a single sense and stored as JSON, but the mediaUri it holds refers to a shared image file. PicturesEditor no longer rejects an AlreadyExists upload; it treats it like a success and creates a new Picture pointing at the mediaUri the server returns for the existing file. No backend change was needed — UploadFileResponse already carries the existing file's MediaUri on the AlreadyExists result, and that handling stays media-type-agnostic (mediaUri covers images and audio). The demo API now mirrors the server's filename-based dedup: a repeat upload of the same filename returns AlreadyExists with the existing mediaUri. Removes the now-unused "That picture has already been uploaded" string, and adds a Playwright test covering the reuse path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PicturesEditor.svelte | 7 ++++--- frontend/viewer/src/locales/en.po | 4 ---- frontend/viewer/src/locales/es.po | 4 ---- frontend/viewer/src/locales/fr.po | 4 ---- frontend/viewer/src/locales/id.po | 4 ---- frontend/viewer/src/locales/ko.po | 4 ---- frontend/viewer/src/locales/ms.po | 4 ---- frontend/viewer/src/locales/sw.po | 4 ---- frontend/viewer/src/locales/vi.po | 4 ---- .../src/project/demo/in-memory-demo-api.ts | 10 +++++++++ frontend/viewer/tests/sense-pictures.test.ts | 21 +++++++++++++++++++ 11 files changed, 35 insertions(+), 35 deletions(-) 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 747a18a9b9..8e6566dbf8 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -65,6 +65,10 @@ 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 create a new Picture pointing at that same image. break; case UploadFileResult.TooBig: AppNotification.display(tooBigMessage(file), {type: 'error', timeout: 'long'}); @@ -72,9 +76,6 @@ case UploadFileResult.NotSupported: AppNotification.display($t`Uploading pictures is not supported here`, {type: 'error'}); return null; - case UploadFileResult.AlreadyExists: - AppNotification.display($t`That picture has already been uploaded`, {type: 'error'}); - return null; case UploadFileResult.Error: AppNotification.display(response.errorMessage ?? $t`Unable to upload the picture`, {type: 'error'}); return null; diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 0f2f20cf6e..ad6db090dc 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1990,10 +1990,6 @@ msgstr "Tasks" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Tell us about a bug or issue you’ve encountered." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "That picture has already been uploaded" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 97415fbef6..323701ac78 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -1995,10 +1995,6 @@ msgstr "Tareas" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Cuéntanos acerca de un error o problema que hayas encontrado." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 94ddc1752e..f04b672fb0 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -1995,10 +1995,6 @@ msgstr "Tâches" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Parlez-nous d'un bug ou d'un problème que vous avez rencontré." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 24a1c440dd..38d52e2ecf 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -1995,10 +1995,6 @@ msgstr "Tugas" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Beritahu kami tentang bug atau masalah yang Anda temui." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index c45db78ad8..09fcf9067c 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -1995,10 +1995,6 @@ msgstr "작업" msgid "Tell us about a bug or issue you’ve encountered." msgstr "발생한 버그나 문제에 대해 알려주세요." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 37d70cd5da..acc92d8f59 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -1995,10 +1995,6 @@ msgstr "Tugasan" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Beritahu kami mengenai pepijat atau isu yang anda temui." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index dc2634c135..283d56cef6 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -1995,10 +1995,6 @@ msgstr "Jukumu" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Tuambie kuhusu kosa au matatizo uliyokabiliwa." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 17bd24e3b8..5f70d291e4 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -1995,10 +1995,6 @@ msgstr "Nhiệm vụ" msgid "Tell us about a bug or issue you’ve encountered." msgstr "Hãy cho chúng tôi biết về lỗi hoặc vấn đề bạn gặp phải." -#: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "That picture has already been uploaded" -msgstr "" - #. View description #: src/project/browse/ViewPicker.svelte msgid "The *FieldWorks Classic* view, on the other hand, is designed for users who are familiar with *[FieldWorks Language Explorer](https://software.sil.org/fieldworks/)*." diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 80adae60f0..90bef54b67 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -580,6 +580,9 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { // Files uploaded during the demo session (e.g. via the "+ Picture" button), keyed by the // mediaUri handed back from saveFile. Lets the demo round-trip an upload without a backend. #uploadedFiles = new Map(); + // Maps an already-uploaded filename to its mediaUri, mirroring how the real server dedups by + // filename (LcmMediaService keys by the file name within the project's resource cache). + #uploadedFilenames = new Map(); getFileStream(mediaUri: string): Promise { const uploaded = this.#uploadedFiles.get(mediaUri); @@ -615,8 +618,15 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { if (blob.size > DEMO_FILE_SIZE_LIMIT) { return Promise.resolve({result: UploadFileResult.TooBig}); } + // Same filename as an earlier upload: report AlreadyExists and hand back the existing + // mediaUri (as the real server does) so the caller can point a new Picture at that file. + const existing = this.#uploadedFilenames.get(metadata.filename); + if (existing) { + return Promise.resolve({result: UploadFileResult.AlreadyExists, mediaUri: existing}); + } const mediaUri = `demo-upload/${randomId()}`; this.#uploadedFiles.set(mediaUri, blob); + this.#uploadedFilenames.set(metadata.filename, mediaUri); return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri}); } } diff --git a/frontend/viewer/tests/sense-pictures.test.ts b/frontend/viewer/tests/sense-pictures.test.ts index 92de537eb1..2e0b63f3ee 100644 --- a/frontend/viewer/tests/sense-pictures.test.ts +++ b/frontend/viewer/tests/sense-pictures.test.ts @@ -76,6 +76,27 @@ test.describe('Sense pictures', () => { await expect(image).toHaveAttribute('src', /^blob:/); }); + test('re-uploading an existing file adds a second picture that reuses it', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + await browsePage.selectEntryByFilter('ambuka'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + const fileInput = picturesField.locator('input[type="file"]'); + + // First upload creates a picture. + await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: ONE_PX_PNG}); + await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + + // Uploading the same filename again -> server reports AlreadyExists with the existing + // mediaUri; that's not an error here, so a second Picture pointing at the same file is added. + await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: ONE_PX_PNG}); + + // Two pictures now exist (the carousel shows a navigation dot per picture). + await expect(picturesField.getByRole('button', {name: /^Go to picture/})).toHaveCount(2, {timeout: 5000}); + }); + /** Uploads one picture to "ambuka" (which starts empty) and returns the pictures-field locator. */ async function addOnePicture(page: Page) { const browsePage = new BrowsePage(page); From ced0680e5c2778fd3225dd44e2ea13abcba2cd3f Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 8 Jul 2026 13:31:33 +0700 Subject: [PATCH 18/32] Rebuild PictureCarousel on the shadcn-svelte carousel Add the shadcn-svelte carousel component and rewrite PictureCarousel to use its Carousel.Root/Content/Item primitives, capturing the Embla api via setApi. All existing behavior is preserved: bindable selectedIndex, paused, 10s auto-advance, loop, the bounded-width/centered layout for multiple pictures, and the i18n'd dot indicators + prev/next controls. Keep our own control chrome instead of Carousel.Previous/Next: those sit outside the frame with hardcoded English labels and offer no dot indicator, whereas our controls are centered below the image with translated labels. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/carousel/carousel-content.svelte | 43 +++++++++ .../ui/carousel/carousel-item.svelte | 30 ++++++ .../ui/carousel/carousel-next.svelte | 39 ++++++++ .../ui/carousel/carousel-previous.svelte | 39 ++++++++ .../components/ui/carousel/carousel.svelte | 94 +++++++++++++++++++ .../src/lib/components/ui/carousel/context.ts | 58 ++++++++++++ .../src/lib/components/ui/carousel/index.ts | 19 ++++ .../field-editors/PictureCarousel.svelte | 69 ++++++-------- 8 files changed, 349 insertions(+), 42 deletions(-) create mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte create mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte create mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte create mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte create mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel.svelte create mode 100644 frontend/viewer/src/lib/components/ui/carousel/context.ts create mode 100644 frontend/viewer/src/lib/components/ui/carousel/index.ts diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte new file mode 100644 index 0000000000..f1467db826 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte @@ -0,0 +1,43 @@ + + +
+
+ {@render children?.()} +
+
diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte new file mode 100644 index 0000000000..1cf361e90d --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte @@ -0,0 +1,30 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte new file mode 100644 index 0000000000..aa627d09e0 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte @@ -0,0 +1,39 @@ + + + diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte new file mode 100644 index 0000000000..07fe3a8169 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte @@ -0,0 +1,39 @@ + + + diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel.svelte new file mode 100644 index 0000000000..cce28cf192 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/carousel.svelte @@ -0,0 +1,94 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/viewer/src/lib/components/ui/carousel/context.ts b/frontend/viewer/src/lib/components/ui/carousel/context.ts new file mode 100644 index 0000000000..138d672965 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/context.ts @@ -0,0 +1,58 @@ +import type { WithElementRef } from '$lib/utils.js'; +import type { + EmblaCarouselSvelteType, + default as emblaCarouselSvelte, +} from 'embla-carousel-svelte'; +import { getContext, hasContext, setContext } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; + +export type CarouselAPI = + NonNullable['on:emblaInit']> extends ( + evt: CustomEvent + ) => void + ? CarouselAPI + : never; + +type EmblaCarouselConfig = NonNullable[1]>; + +export type CarouselOptions = EmblaCarouselConfig['options']; +export type CarouselPlugins = EmblaCarouselConfig['plugins']; + +//// + +export type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugins; + setApi?: (api: CarouselAPI | undefined) => void; + orientation?: 'horizontal' | 'vertical'; +} & WithElementRef>; + +const EMBLA_CAROUSEL_CONTEXT = Symbol('EMBLA_CAROUSEL_CONTEXT'); + +export type EmblaContext = { + api: CarouselAPI | undefined; + orientation: 'horizontal' | 'vertical'; + scrollNext: () => void; + scrollPrev: () => void; + canScrollNext: boolean; + canScrollPrev: boolean; + handleKeyDown: (e: KeyboardEvent) => void; + options: CarouselOptions; + plugins: CarouselPlugins; + onInit: (e: CustomEvent) => void; + scrollTo: (index: number, jump?: boolean) => void; + scrollSnaps: number[]; + selectedIndex: number; +}; + +export function setEmblaContext(config: EmblaContext): EmblaContext { + setContext(EMBLA_CAROUSEL_CONTEXT, config); + return config; +} + +export function getEmblaContext(name = 'This component') { + if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) { + throw new Error(`${name} must be used within a component`); + } + return getContext>(EMBLA_CAROUSEL_CONTEXT); +} diff --git a/frontend/viewer/src/lib/components/ui/carousel/index.ts b/frontend/viewer/src/lib/components/ui/carousel/index.ts new file mode 100644 index 0000000000..79d2a288e3 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/carousel/index.ts @@ -0,0 +1,19 @@ +import Root from './carousel.svelte'; +import Content from './carousel-content.svelte'; +import Item from './carousel-item.svelte'; +import Previous from './carousel-previous.svelte'; +import Next from './carousel-next.svelte'; + +export { + Root, + Content, + Item, + Previous, + Next, + // + Root as Carousel, + Content as CarouselContent, + Item as CarouselItem, + Previous as CarouselPrevious, + Next as CarouselNext, +}; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index 14d06d8f80..e9b440e2c9 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -1,5 +1,6 @@ + it, so the centered controls below sit under the picture. (Embla lays slides out in a flex + row, so a shrink-to-fit width would span the sum of all slides.) A single picture keeps its + natural, left-justified size since it has no controls to align. -->
-
-
+ (api = a)} opts={{loop: true}}> + {#each pictures as picture (picture.id)} -
+ -
+ {/each} -
-
+ + {#if hasMultiple}
@@ -88,7 +73,7 @@ size="icon-xs" icon="i-mdi-chevron-left" aria-label={$t`Previous picture`} - onclick={() => emblaApi?.scrollPrev()} + onclick={() => api?.scrollPrev()} />
{#each pictures as picture, i (picture.id)} @@ -96,7 +81,7 @@ type="button" aria-label={$t`Go to picture ${i + 1}`} class={cn('size-2 rounded-full transition-colors', i === selectedIndex ? 'bg-primary' : 'bg-muted-foreground/40')} - onclick={() => emblaApi?.scrollTo(i)} + onclick={() => api?.scrollTo(i)} > {/each}
@@ -105,7 +90,7 @@ size="icon-xs" icon="i-mdi-chevron-right" aria-label={$t`Next picture`} - onclick={() => emblaApi?.scrollNext()} + onclick={() => api?.scrollNext()} />
{/if} From f5e4bb471d33358fcdd46f4ba6c138c5f14f8d8c Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 8 Jul 2026 14:20:02 +0700 Subject: [PATCH 19/32] Adjust MovePicture TypeScript API to allow null This will be needed if we want the frontend to be able to allow people to move pictures to the front/top or end/bottom (and if users want to move pictures, nearly every time they're going to be moving one of them to the top or bottom.) --- backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs | 2 +- .../FwLiteShared/Services/IMiniLcmJsInvokable.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 6ad632950c..fd23950b7d 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -400,7 +400,7 @@ public async Task UpdatePicture(Guid entryId, Guid senseId, Picture bef [JSInvokable] // previousPictureId and nextPictureId represent the target position, where the picture should end up after being moved - public async Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, Guid? previousPictureId, Guid? nextPictureId) + public async Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, Guid? previousPictureId = null, Guid? nextPictureId = null) { await _wrappedApi.MovePicture(entryId, senseId, pictureId, new MiniLcm.SyncHelpers.BetweenPosition(previousPictureId, nextPictureId)); OnDataChanged(); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 31fc331d67..7a01a4828f 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -78,7 +78,7 @@ export interface IMiniLcmJsInvokable deleteExampleSentence(entryId: string, senseId: string, exampleSentenceId: string) : Promise; createPicture(entryId: string, senseId: string, picture: IPicture) : Promise; updatePicture(entryId: string, senseId: string, before: IPicture, after: IPicture) : Promise; - movePicture(entryId: string, senseId: string, pictureId: string, previousPictureId: string, nextPictureId: string) : Promise; + movePicture(entryId: string, senseId: string, pictureId: string, previousPictureId?: string, nextPictureId?: string) : Promise; deletePicture(entryId: string, senseId: string, pictureId: string) : Promise; getFileStream(mediaUri: string) : Promise; saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata) : Promise; From 071b5ab58047969e733f0956260f3df19ecf7130 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 8 Jul 2026 14:26:21 +0700 Subject: [PATCH 20/32] Allow TIFF and BMP picture uploads Add image/tiff and image/bmp to the picture file picker's accepted types. The server's SaveFile only rejects on size, not format, so the client accept list is the effective format gate. Also widen the too-large-file advice: BMP and TIFF are lossless/uncompressed like PNG, so they get the "reduce resolution" hint rather than the (wrong for them) "lower JPEG quality" hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/entry-editor/field-editors/PicturesEditor.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 8e6566dbf8..4f458236cd 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -33,7 +33,7 @@ let pauseAutoplay = $state(false); // Formats the browser accepts and that the server supports for pictures. - const ACCEPTED_TYPES = 'image/jpeg,image/png'; + const ACCEPTED_TYPES = 'image/jpeg,image/png,image/tiff,image/bmp'; let fileInputElement = $state(); // Which operation is in flight (drives per-button spinners + disabling the whole group). @@ -125,10 +125,10 @@ } // The server rejects files above its size limit. JPEGs can usually be shrunk by lowering - // the export quality, whereas PNGs (lossless) need a smaller resolution instead. + // the export quality, whereas lossless formats (PNG, BMP, TIFF) need a smaller resolution. function tooBigMessage(file: File): string { - const isPng = file.type === 'image/png' || /\.png$/i.test(file.name); - return isPng + const isLossless = /^image\/(png|bmp|tiff)$/.test(file.type) || /\.(png|bmp|tiff?)$/i.test(file.name); + return isLossless ? $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 saving it at a lower JPEG quality and uploading again.`; } From 85f6042eab483600d2bfaec4c9e4ee588c831fdc Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 8 Jul 2026 15:25:47 +0700 Subject: [PATCH 21/32] Move picture Delete/Replace onto the image for touch support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hover-dependent Replace/Delete button group with affordances on the picture itself, so they work on phones without hover: - an always-visible red trash-can button in the picture's top-right corner deletes that picture - clicking/tapping the picture triggers a replace Both act on the specific picture the user targets (rendered per carousel slide) rather than a tracked "current" picture, so the carousel's autoplay can no longer cause an action to hit the wrong picture — the auto-advance pause logic is therefore removed. The "Replace Picture" / "Delete Picture" strings move to aria-labels on the new controls (catalog reference comments updated). The test picture is now a real 96x96 PNG so the trash button anchored to the image corner has a realistic, clickable footprint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureCarousel.svelte | 32 +++++-- .../field-editors/PictureImage.svelte | 48 +++++++++-- .../field-editors/PicturesEditor.svelte | 85 ++++++------------- frontend/viewer/src/locales/en.po | 4 +- frontend/viewer/src/locales/es.po | 4 +- frontend/viewer/src/locales/fr.po | 4 +- frontend/viewer/src/locales/id.po | 4 +- frontend/viewer/src/locales/ko.po | 4 +- frontend/viewer/src/locales/ms.po | 4 +- frontend/viewer/src/locales/sw.po | 4 +- frontend/viewer/src/locales/vi.po | 4 +- frontend/viewer/tests/sense-pictures.test.ts | 18 ++-- 12 files changed, 121 insertions(+), 94 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte index e9b440e2c9..c1a6c185a9 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte @@ -12,12 +12,24 @@ type Props = { pictures: IPicture[]; - /** Index of the picture currently shown; bindable so the parent can act on the current picture. */ + /** Index of the picture currently shown; bindable so parents can observe the active picture. */ selectedIndex?: number; - /** When true, auto-advance is suspended (e.g. while the user is about to act on the current picture). */ - paused?: boolean; + readonly?: boolean; + /** Disables the per-picture edit affordances while an operation is in flight. */ + busy?: boolean; + /** Called with the picture the user tapped, to replace it. */ + onReplacePicture?: (picture: IPicture) => void; + /** Called with the picture whose trash button was clicked, to delete it. */ + onDeletePicture?: (picture: IPicture) => void; }; - let {pictures, selectedIndex = $bindable(0), paused = false}: Props = $props(); + let { + pictures, + selectedIndex = $bindable(0), + readonly = false, + busy = false, + onReplacePicture, + onDeletePicture, + }: Props = $props(); // The Embla api, captured from . We drive our own controls (dots + // prev/next) through it rather than using , which are positioned @@ -42,9 +54,10 @@ }; }); - // Auto-advance every 10 seconds when there is more than one picture (unless paused). + // Auto-advance every 10 seconds when there is more than one picture. Delete/replace act on + // the specific picture the user targets, so autoplay can't cause them to hit the wrong one. $effect(() => { - if (!api || !hasMultiple || paused) return; + if (!api || !hasMultiple) return; const embla = api; const interval = setInterval(() => embla.scrollNext(), AUTOPLAY_DELAY_MS); return () => clearInterval(interval); @@ -60,7 +73,12 @@ {#each pictures as picture (picture.id)} - + onReplacePicture?.(picture)} + onDelete={readonly ? undefined : () => onDeletePicture?.(picture)} + /> {/each} 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 776586dbac..6dd3adc354 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -7,8 +7,18 @@ type Props = { picture: IPicture; + /** When provided, clicking the image triggers a replace. */ + onReplace?: () => void; + /** When provided, a trash button is shown in the image's top-right corner. */ + onDelete?: () => void; + /** Disables the edit affordances while an operation is in flight. */ + busy?: boolean; }; - const {picture}: Props = $props(); + const {picture, onReplace, onDelete, busy = false}: Props = $props(); + + // The image doubles as edit UI (tap to replace, trash to delete) only when handlers are wired. + // Kept media-agnostic and hover-independent so it works on touch screens. + const editable = $derived(!!onReplace || !!onDelete); const projectContext = useProjectContext(); const api = $derived(projectContext?.maybeApi); @@ -63,21 +73,47 @@ }); -
+{#snippet imageContent()} {#if state.status === 'loaded'} {caption {:else if state.status === 'loading'} -
+
{:else} -
+
{state.message}
{/if} +{/snippet} + +
+ +
+ {#if editable} + + + {:else} + {@render imageContent()} + {/if} +
{#if caption}
{caption}
{/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 4f458236cd..9dc252b21e 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -24,25 +24,22 @@ // so guard against undefined rather than trusting the type at runtime. const pictures = $derived(value ?? []); - // The carousel reports which picture is currently shown; Replace/Delete act on it. - let selectedIndex = $state(0); - const currentPicture = $derived(pictures[selectedIndex]); - - // Pause the carousel's auto-advance while the user is hovering/focusing the Replace or Delete - // buttons, so the "current" picture can't change out from under them before they click. - let pauseAutoplay = $state(false); - // Formats the browser accepts and that the server supports for pictures. const ACCEPTED_TYPES = 'image/jpeg,image/png,image/tiff,image/bmp'; let fileInputElement = $state(); - // Which operation is in flight (drives per-button spinners + disabling the whole group). + // Which operation is in flight (drives the add-button spinner + disabling the edit affordances). let busyAction = $state<'add' | 'replace' | 'delete' | null>(null); - // Whether the file picker that's about to open is for adding or replacing. - let pendingFileAction: 'add' | 'replace' = 'add'; + // The picture to replace once a file is chosen; undefined means the picker is adding a new one. + let pendingReplace = $state(undefined); + + function pickAddFile() { + pendingReplace = undefined; + fileInputElement?.click(); + } - function pickFile(action: 'add' | 'replace') { - pendingFileAction = action; + function requestReplace(picture: IPicture) { + pendingReplace = $state.snapshot(picture); fileInputElement?.click(); } @@ -51,8 +48,10 @@ const file = target.files?.[0]; // Reset the input so selecting the same file again re-triggers `change`. target.value = ''; + const replaceTarget = pendingReplace; + pendingReplace = undefined; if (!file) return; - if (pendingFileAction === 'replace') void replacePicture(file); + if (replaceTarget) void replacePicture(replaceTarget, file); else void addPicture(file); } @@ -97,9 +96,7 @@ } } - async function replacePicture(file: File): Promise { - const before = currentPicture ? $state.snapshot(currentPicture) : undefined; - if (!before) return; + async function replacePicture(before: IPicture, file: File): Promise { busyAction = 'replace'; try { const mediaUri = await uploadFile(file); @@ -112,9 +109,8 @@ } } - async function deletePicture(): Promise { - const target = currentPicture ? $state.snapshot(currentPicture) : undefined; - if (!target) return; + async function deletePicture(picture: IPicture): Promise { + const target = $state.snapshot(picture); if (!(await dialogsService.promptDelete($t`Picture`))) return; busyAction = 'delete'; try { @@ -136,7 +132,15 @@
{#if pictures.length > 0} - + + {:else if readonly}
{$t`No pictures`} @@ -144,46 +148,13 @@ {/if} {#if !readonly} - +
- - {#if pictures.length > 0} - -
(pauseAutoplay = true)} - onmouseleave={() => (pauseAutoplay = false)} - onfocusin={() => (pauseAutoplay = true)} - onfocusout={() => (pauseAutoplay = false)} - > - - -
- {/if}
- + { await picturesField.locator('input[type="file"]').setInputFiles({ name: 'photo.png', mimeType: 'image/png', - buffer: ONE_PX_PNG, + buffer: TEST_PNG, }); // The uploaded picture is created and re-loaded via getFileStream into a blob url. @@ -86,12 +88,12 @@ test.describe('Sense pictures', () => { const fileInput = picturesField.locator('input[type="file"]'); // First upload creates a picture. - await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: ONE_PX_PNG}); + await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); // Uploading the same filename again -> server reports AlreadyExists with the existing // mediaUri; that's not an error here, so a second Picture pointing at the same file is added. - await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: ONE_PX_PNG}); + await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); // Two pictures now exist (the carousel shows a navigation dot per picture). await expect(picturesField.getByRole('button', {name: /^Go to picture/})).toHaveCount(2, {timeout: 5000}); @@ -105,7 +107,7 @@ test.describe('Sense pictures', () => { const picturesField = page.locator('[style*="grid-area: pictures"]').first(); await expect(picturesField).toBeVisible({timeout: 5000}); await picturesField.locator('input[type="file"]').setInputFiles({ - name: 'photo.png', mimeType: 'image/png', buffer: ONE_PX_PNG, + name: 'photo.png', mimeType: 'image/png', buffer: TEST_PNG, }); await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); return picturesField; @@ -142,7 +144,7 @@ test.describe('Sense pictures', () => { await picturesField.getByRole('button', {name: 'Replace Picture'}).click(); await picturesField.locator('input[type="file"]').setInputFiles({ - name: 'replacement.png', mimeType: 'image/png', buffer: ONE_PX_PNG, + name: 'replacement.png', mimeType: 'image/png', buffer: TEST_PNG, }); // Still exactly one picture (replaced, not added), re-loaded into a fresh blob url. From eca1d1b1d691f7f69fbf41b8d2f7601f273ff76d Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 8 Jul 2026 16:00:26 +0700 Subject: [PATCH 22/32] Simplify the multithreaded GetFileStream fix The elaborate three-method dance with a TaskCompletionSource can be simplified down to a simple ConcurrentDictionary.GetOrAdd() call. --- .../LcmCrdt/MediaServer/LcmMediaService.cs | 66 ++----------------- 1 file changed, 5 insertions(+), 61 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index bc4ef96edc..efd5484cb0 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -61,28 +61,15 @@ public async Task DeleteResource(Guid fileId) public async Task GetFileStream(Guid fileId) { + var resource = await resourceService.GetLocalResource(fileId); var localResource = await resourceService.GetLocalResource(fileId); if (localResource is null) { - var connectionStatus = await httpClientProvider.ConnectionStatus(); - if (connectionStatus != ConnectionStatus.Online) + localResource = await DownloadTasks.GetOrAdd(fileId, async _ => { - return new ReadFileResponse(ReadFileResult.Offline); - } - - try - { - localResource = await GetOrStartDownload(fileId); - } - catch (Exception e) - { - // The download can fail for reasons outside our control — the file may be missing - // on the server, or the media server/gateway may time out (504). Callers such as - // PictureImage expect failures via the result enum, not exceptions, so surface a - // graceful Error result (shown in the UI) instead of throwing an unhandled exception. - logger.LogError(e, "Failed to download media file {FileId}", fileId); - return new ReadFileResponse(ReadFileResult.Error, e.Message); - } + // Check again if we have it locally in case another thread completed before the GetOrAdd factory ran + return await resourceService.GetLocalResource(fileId) ?? await resourceService.DownloadResource(fileId, this); + }); } //todo, consider trying to download the file again, maybe the cache was cleared if (!File.Exists(localResource.LocalPath)) @@ -90,49 +77,6 @@ public async Task GetFileStream(Guid fileId) return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath)); } - private Task GetOrStartDownload(Guid fileId) - { - // Use the *value* overload of GetOrAdd, not the factory overload: the factory can run more - // than once under contention (only one result is kept), which would start the download - // twice. Passing a not-yet-started TaskCompletionSource.Task lets exactly one caller — the - // one whose task actually got stored — kick off the download. - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var task = DownloadTasks.GetOrAdd(fileId, tcs.Task); - if (task != tcs.Task) - { - // Another caller already owns the in-flight download; await theirs (our tcs is unused). - return task; - } - _ = RunDownloadAsync(fileId, tcs); - return task; - } - - private async Task RunDownloadAsync(Guid fileId, TaskCompletionSource tcs) - { - try - { - // Re-check before downloading: a previous download may have committed the LocalResource - // after our caller's GetLocalResource miss (this closes the race between - // GetLocalResource and DownloadResource). The task entry is removed only after this - // completes — i.e. after the download has committed — so any later caller that starts a - // fresh task will find the committed resource here and skip the download, rather than - // inserting a duplicate primary key. - var resource = await resourceService.GetLocalResource(fileId) - ?? await resourceService.DownloadResource(fileId, this); - tcs.SetResult(resource); - } - catch (Exception e) - { - tcs.SetException(e); - } - finally - { - // Cached now, so future misses can start fresh; also prevents a failed download from - // being cached as a permanently-faulted task. - DownloadTasks.TryRemove(fileId, out _); - } - } - // Media files are proxied (lexbox -> FwHeadless). A cold request — e.g. the first one after the // backend starts, before its code paths are JIT-warmed — can exceed the proxy's timeout and come // back as 504 (or 502/503) even though the backend keeps working and warms up. Retrying a From 2bb8942c7c6988f3d086353a5a9957f4808e9c1a Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 8 Jul 2026 16:51:16 +0700 Subject: [PATCH 23/32] Don't dispose manually; use using --- backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index efd5484cb0..38591124c8 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -96,7 +96,7 @@ public async Task GetFileStream(Guid fileId) var mediaClient = await MediaServerClient(); for (var attempt = 1; ; attempt++) { - var response = await mediaClient.DownloadFile(fileId); + using var response = await mediaClient.DownloadFile(fileId); if (response.IsSuccessStatusCode) { return (await response.Content.ReadAsStreamAsync(), response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", "")); @@ -104,8 +104,6 @@ public async Task GetFileStream(Guid fileId) var statusCode = response.StatusCode; var reasonPhrase = response.ReasonPhrase; - response.Dispose(); - if (attempt >= MaxDownloadAttempts || !TransientDownloadStatusCodes.Contains(statusCode)) { throw new Exception($"Failed to download file {fileId}: {statusCode} {reasonPhrase}"); From d4fb1fa96a256febd08e20cfb0a15bb62e1bbae7 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 9 Jul 2026 10:46:37 +0700 Subject: [PATCH 24/32] Replace picture carousel with a wrapping flex gallery Lay the sense pictures out in a flexbox that flows left-to-right and wraps, so on a narrow (mobile) screen they stack vertically with no CSS change. Each flex item is one picture plus its caption. - Pictures now have a fixed height and flexible (aspect-preserving) width. - The caption shows a single writing system: the first non-empty value searching vernacular writing systems first, then analysis (writingSystemService.first's default order). - The caption is pinned to the picture's width (w-0 min-w-full) and clamped to two lines (line-clamp-2), so it never extends past the image. The trash-can delete button and tap-to-replace affordance carry over unchanged. Removes PictureCarousel.svelte, the shadcn carousel component, and the now-unused embla-carousel-svelte dependency (all only existed for the carousel). The test's dot-count assertion becomes an image-count assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/pnpm-lock.yaml | 28 ----- frontend/viewer/package.json | 1 - .../ui/carousel/carousel-content.svelte | 43 ------- .../ui/carousel/carousel-item.svelte | 30 ----- .../ui/carousel/carousel-next.svelte | 39 ------ .../ui/carousel/carousel-previous.svelte | 39 ------ .../components/ui/carousel/carousel.svelte | 94 -------------- .../src/lib/components/ui/carousel/context.ts | 58 --------- .../src/lib/components/ui/carousel/index.ts | 19 --- .../field-editors/PictureCarousel.svelte | 115 ------------------ .../field-editors/PictureImage.svelte | 17 ++- .../field-editors/PicturesEditor.svelte | 25 ++-- frontend/viewer/tests/sense-pictures.test.ts | 4 +- 13 files changed, 29 insertions(+), 483 deletions(-) delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/carousel.svelte delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/context.ts delete mode 100644 frontend/viewer/src/lib/components/ui/carousel/index.ts delete mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 160f6180f4..7b3ca922d8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -516,9 +516,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - embla-carousel-svelte: - specifier: ^8.6.0 - version: 8.6.0(svelte@5.56.0(@typescript-eslint/types@8.56.1)) eslint: specifier: 'catalog:' version: 9.39.3(jiti@2.6.1) @@ -4513,19 +4510,6 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - embla-carousel-reactive-utils@8.6.0: - resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} - peerDependencies: - embla-carousel: 8.6.0 - - embla-carousel-svelte@8.6.0: - resolution: {integrity: sha512-ZDsKk8Sdv+AUTygMYcwZjfRd1DTh+JSUzxkOo8b9iKAkYjg+39mzbY/lwHsE3jXSpKxdKWS69hPSNuzlOGtR2Q==} - peerDependencies: - svelte: ^3.49.0 || ^4.0.0 || ^5.0.0 - - embla-carousel@8.6.0: - resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -12292,18 +12276,6 @@ snapshots: electron-to-chromium@1.5.302: {} - embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): - dependencies: - embla-carousel: 8.6.0 - - embla-carousel-svelte@8.6.0(svelte@5.56.0(@typescript-eslint/types@8.56.1)): - dependencies: - embla-carousel: 8.6.0 - embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - svelte: 5.56.0(@typescript-eslint/types@8.56.1) - - embla-carousel@8.6.0: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 645a52abe2..380725d7f7 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -67,7 +67,6 @@ "@vitest/ui": "catalog:", "bits-ui": "2.16.4", "clsx": "^2.1.1", - "embla-carousel-svelte": "^8.6.0", "eslint": "catalog:", "eslint-config-prettier": "^10.1.8", "eslint-output": "catalog:", diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte deleted file mode 100644 index f1467db826..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/carousel-content.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -
-
- {@render children?.()} -
-
diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte deleted file mode 100644 index 1cf361e90d..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/carousel-item.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte deleted file mode 100644 index aa627d09e0..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/carousel-next.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte deleted file mode 100644 index 07fe3a8169..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/carousel-previous.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/viewer/src/lib/components/ui/carousel/carousel.svelte b/frontend/viewer/src/lib/components/ui/carousel/carousel.svelte deleted file mode 100644 index cce28cf192..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/carousel.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -
- {@render children?.()} -
diff --git a/frontend/viewer/src/lib/components/ui/carousel/context.ts b/frontend/viewer/src/lib/components/ui/carousel/context.ts deleted file mode 100644 index 138d672965..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/context.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { WithElementRef } from '$lib/utils.js'; -import type { - EmblaCarouselSvelteType, - default as emblaCarouselSvelte, -} from 'embla-carousel-svelte'; -import { getContext, hasContext, setContext } from 'svelte'; -import type { HTMLAttributes } from 'svelte/elements'; - -export type CarouselAPI = - NonNullable['on:emblaInit']> extends ( - evt: CustomEvent - ) => void - ? CarouselAPI - : never; - -type EmblaCarouselConfig = NonNullable[1]>; - -export type CarouselOptions = EmblaCarouselConfig['options']; -export type CarouselPlugins = EmblaCarouselConfig['plugins']; - -//// - -export type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugins; - setApi?: (api: CarouselAPI | undefined) => void; - orientation?: 'horizontal' | 'vertical'; -} & WithElementRef>; - -const EMBLA_CAROUSEL_CONTEXT = Symbol('EMBLA_CAROUSEL_CONTEXT'); - -export type EmblaContext = { - api: CarouselAPI | undefined; - orientation: 'horizontal' | 'vertical'; - scrollNext: () => void; - scrollPrev: () => void; - canScrollNext: boolean; - canScrollPrev: boolean; - handleKeyDown: (e: KeyboardEvent) => void; - options: CarouselOptions; - plugins: CarouselPlugins; - onInit: (e: CustomEvent) => void; - scrollTo: (index: number, jump?: boolean) => void; - scrollSnaps: number[]; - selectedIndex: number; -}; - -export function setEmblaContext(config: EmblaContext): EmblaContext { - setContext(EMBLA_CAROUSEL_CONTEXT, config); - return config; -} - -export function getEmblaContext(name = 'This component') { - if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) { - throw new Error(`${name} must be used within a component`); - } - return getContext>(EMBLA_CAROUSEL_CONTEXT); -} diff --git a/frontend/viewer/src/lib/components/ui/carousel/index.ts b/frontend/viewer/src/lib/components/ui/carousel/index.ts deleted file mode 100644 index 79d2a288e3..0000000000 --- a/frontend/viewer/src/lib/components/ui/carousel/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import Root from './carousel.svelte'; -import Content from './carousel-content.svelte'; -import Item from './carousel-item.svelte'; -import Previous from './carousel-previous.svelte'; -import Next from './carousel-next.svelte'; - -export { - Root, - Content, - Item, - Previous, - Next, - // - Root as Carousel, - Content as CarouselContent, - Item as CarouselItem, - Previous as CarouselPrevious, - Next as CarouselNext, -}; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte deleted file mode 100644 index c1a6c185a9..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - - -
- (api = a)} opts={{loop: true}}> - - {#each pictures as picture (picture.id)} - - onReplacePicture?.(picture)} - onDelete={readonly ? undefined : () => onDeletePicture?.(picture)} - /> - - {/each} - - - - {#if hasMultiple} -
- - {/each} -
-
- {/if} -
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 6dd3adc354..394fd40962 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -24,7 +24,9 @@ const api = $derived(projectContext?.maybeApi); const writingSystemService = useWritingSystemService(); - const caption = $derived(writingSystemService.first(picture.caption, writingSystemService.analysis) ?? ''); + // Show a single writing system: the first non-empty caption searching vernacular writing + // systems first, then analysis — which is exactly the default order of allWritingSystems(). + const caption = $derived(writingSystemService.first(picture.caption) ?? ''); type LoadState = | {status: 'loading'} @@ -75,13 +77,14 @@ {#snippet imageContent()} {#if state.status === 'loaded'} - {caption + + {caption {:else if state.status === 'loading'} -
+
{:else} -
+
{state.message}
@@ -115,6 +118,10 @@ {/if}
{#if caption} -
{caption}
+ +
+ {caption} +
{/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 9dc252b21e..d85f159dd3 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2,7 +2,7 @@ import type {IPicture} from '$lib/dotnet-types'; import {UploadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/UploadFileResult'; import {Button} from '$lib/components/ui/button'; - import PictureCarousel from './PictureCarousel.svelte'; + import PictureImage from './PictureImage.svelte'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {useDialogsService} from '$lib/services/dialogs-service'; @@ -132,15 +132,20 @@
{#if pictures.length > 0} - - + +
+ {#each pictures as picture (picture.id)} + requestReplace(picture)} + onDelete={readonly ? undefined : () => void deletePicture(picture)} + /> + {/each} +
{:else if readonly}
{$t`No pictures`} diff --git a/frontend/viewer/tests/sense-pictures.test.ts b/frontend/viewer/tests/sense-pictures.test.ts index dcf21d95f8..a6c2685ccf 100644 --- a/frontend/viewer/tests/sense-pictures.test.ts +++ b/frontend/viewer/tests/sense-pictures.test.ts @@ -95,8 +95,8 @@ test.describe('Sense pictures', () => { // mediaUri; that's not an error here, so a second Picture pointing at the same file is added. await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); - // Two pictures now exist (the carousel shows a navigation dot per picture). - await expect(picturesField.getByRole('button', {name: /^Go to picture/})).toHaveCount(2, {timeout: 5000}); + // Two pictures now exist (each renders its own image in the flex layout). + await expect(picturesField.locator('img')).toHaveCount(2, {timeout: 5000}); }); /** Uploads one picture to "ambuka" (which starts empty) and returns the pictures-field locator. */ From 42b09ee82f4e76cdc8ec2b900c821e22ba7ea1ef Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 9 Jul 2026 12:09:28 +0700 Subject: [PATCH 25/32] Add an edit-picture dialog with caption editing Clicking a picture (hinted by a pencil icon in its corner, replacing the old trash-can) now opens an Edit Picture dialog instead of triggering a replace. The dialog shows the picture centered at the top, a RichMultiWsInput for the Caption underneath, and Replace Picture / Delete Picture buttons. - PictureImage: the trash-can + tap-to-replace are replaced by a single tap-to-edit affordance (whole picture clickable; pencil is the hint). Adds a showCaption prop so the dialog can show the image without the read-only caption. - PicturesEditor: keeps add-picture; replace/delete/caption now run as edit-dialog callbacks against the picture being edited (tracked by id so its image updates in the dialog after a replace). - Extracts the shared upload formats into picture-formats.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/EditPictureDialog.svelte | 87 +++++++++++++++ .../field-editors/PictureImage.svelte | 37 +++---- .../field-editors/PicturesEditor.svelte | 100 +++++++++++------- .../field-editors/picture-formats.ts | 11 ++ frontend/viewer/src/locales/en.po | 29 ++--- frontend/viewer/src/locales/es.po | 29 ++--- frontend/viewer/src/locales/fr.po | 29 ++--- frontend/viewer/src/locales/id.po | 29 ++--- frontend/viewer/src/locales/ko.po | 29 ++--- frontend/viewer/src/locales/ms.po | 29 ++--- frontend/viewer/src/locales/sw.po | 29 ++--- frontend/viewer/src/locales/vi.po | 29 ++--- frontend/viewer/tests/sense-pictures.test.ts | 68 ++++++++---- 13 files changed, 310 insertions(+), 225 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/picture-formats.ts diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte new file mode 100644 index 0000000000..c91ab34ac9 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte @@ -0,0 +1,87 @@ + + + + e.preventDefault()}> + + {$t`Edit Picture`} + + + +
+ +
+ + + + + + + + onCaptionChange($state.snapshot(caption))} + /> + + + + + + + + + + + +
+
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 394fd40962..35f4cdc05f 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -7,18 +7,18 @@ type Props = { picture: IPicture; - /** When provided, clicking the image triggers a replace. */ - onReplace?: () => void; - /** When provided, a trash button is shown in the image's top-right corner. */ - onDelete?: () => void; - /** Disables the edit affordances while an operation is in flight. */ + /** When provided, the whole picture is clickable (with a pencil hint) to open the edit dialog. */ + onEdit?: () => void; + /** Disables the edit affordance while an operation is in flight. */ busy?: boolean; + /** Whether to render the caption beneath the picture (hidden inside the edit dialog). */ + showCaption?: boolean; }; - const {picture, onReplace, onDelete, busy = false}: Props = $props(); + const {picture, onEdit, busy = false, showCaption = true}: Props = $props(); - // The image doubles as edit UI (tap to replace, trash to delete) only when handlers are wired. - // Kept media-agnostic and hover-independent so it works on touch screens. - const editable = $derived(!!onReplace || !!onDelete); + // When an edit handler is wired the picture becomes a button that opens the edit dialog. The + // whole picture is clickable; the pencil is only a hover-independent hint (works on touch). + const editable = $derived(!!onEdit); const projectContext = useProjectContext(); const api = $derived(projectContext?.maybeApi); @@ -98,26 +98,21 @@ - {:else} {@render imageContent()} {/if}
- {#if caption} + {#if showCaption && caption}
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 d85f159dd3..5c4912b0c5 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -1,8 +1,10 @@ @@ -52,9 +73,9 @@ {$t`Edit Picture`} - +
- +
@@ -63,24 +84,23 @@ - onCaptionChange($state.snapshot(caption))} - /> + - - - + 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 5c4912b0c5..4eb9cd3094 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -1,5 +1,5 @@