Display pictures in senses#2412
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
It's time to implement picture manipulation in the frontend, at least basic stuff (uploading pictures, etc). No editing planned yet.
Wire up the previously-disabled "+ Picture" button in the sense editor so users can add a picture to a sense. - PicturesEditor: clicking the button opens a file picker limited to JPG/PNG, uploads the chosen file via saveFile, then calls createPicture with the returned mediaUri. Upload results are handled by branching on the result enum (no try/catch), matching PictureImage. - Too-large files: the client does not hardcode the size limit (it may change server-side); it handles the server's TooBig result and shows a helpful message — suggest lowering JPEG quality for a JPG, or reducing resolution for a PNG. - MiniLcmApiNotifyWrapper: notify on Create/Update/Move/DeletePicture (this was missing), so the entry reloads and the new picture renders, like every other write op. - Demo API: implement saveFile (stores the blob, enforces the same 10 MB server limit) plus create/update/deletePicture, so the flow round-trips offline. - Playwright: the empty-state button is now enabled; add coverage for uploading a picture through it and seeing it render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds picture CRUD support end-to-end: backend write API and JS invokable methods, updated generated TypeScript contracts, new Svelte carousel/image/editor components wired into the sense editor and view config, localized strings across eight locales, demo data/API support, and Playwright tests. Separately, LcmMediaService gains download coalescing and retry logic for transient failures. ChangesPicture CRUD feature
Media download coalescing and retry
Estimated code review effort: 3 (Moderate) | ~30 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
frontend/viewer/tests/sense-pictures.test.ts (1)
26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate fragile selector across all three tests.
[style*="grid-area: pictures"]is repeated verbatim in each test, coupling all of them to an inline-style implementation detail. Consider adding apicturesFieldhelper (e.g., onBrowsePage/EntryViewComponent) so the selector lives in one place.♻️ Proposed helper extraction
// browse-page.ts (or EntryViewComponent) + picturesField(page: Page) { + return page.locator('[style*="grid-area: pictures"]').first(); + }- const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + const picturesField = browsePage.entryView.picturesField(page);Also applies to: 46-46, 62-62
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/viewer/tests/sense-pictures.test.ts` at line 26, The three tests are repeating the same fragile pictures selector, which is tightly coupled to an inline style detail. Add a shared helper such as picturesField on BrowsePage or EntryViewComponent and use it in each test instead of inlining page.locator('[style*="grid-area: pictures"]'). Keep the selector logic in one place so the tests reference the helper rather than duplicating the locator.frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte (1)
43-80: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueNo client-side file-type validation before upload.
uploadPicturerelies entirely on the server'sNotSupportedresult to reject non-image files; theacceptattribute on the file input is only a UI hint and can be bypassed (e.g., "All Files" in the OS picker). This is already handled by the server, so it's a minor defense-in-depth gap rather than a functional bug.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte` around lines 43 - 80, Add a client-side image-type check in uploadPicture before calling api.saveFile, since the file input accept hint can be bypassed and the current flow only relies on the server’s UploadFileResult.NotSupported response. Use the existing uploadPicture function in PicturesEditor.svelte to reject non-image File types early with the same user-facing notification, while still keeping the server-side validation as the source of truth.frontend/viewer/src/locales/en.po (1)
1569-1579: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStale translator comment: button is no longer disabled.
The comment describes the "Picture" label as being on a "currently-disabled '+ Picture' add button," but per the PicturesEditor.svelte implementation, the button is wired to
onclick={selectFile}and actively triggers file upload/api.createPicture. This context comment appears to predate the upload-wiring commit and should be updated to avoid confusing translators about the button's actual behavior.✏️ Suggested comment fix
-#. 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 +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on the "+ Picture" add button in the sense editor🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/viewer/src/locales/en.po` around lines 1569 - 1579, The translator comment for the “Picture” msgid is stale and incorrectly says the add button is currently disabled. Update the comment next to the Picture/Pictures entries in the locale file so it matches the current behavior in PictureImage.svelte and PicturesEditor.svelte, describing the label as the add/upload picture button that opens file selection and triggers picture creation rather than a disabled control.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs`:
- Around line 150-161: The JS bridge is missing the picture reordering
operation, so add MovePicture exposure alongside the existing picture
create/update/delete paths. Update the MiniLcmJsInvokable and
IMiniLcmJsInvokable contract to include a MovePicture method that forwards to
the underlying write API, matching the existing patterns used for picture
actions. Ensure the new bridge method uses the same identifiers and parameters
as IMiniLcmWriteApi.MovePicture so the viewer can invoke reordering directly.
In `@frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte`:
- Around line 92-113: The PicturesEditor.svelte upload control is only rendered
in the empty-state branch, so once the carousel shows existing pictures the add
button and file input become unreachable. Update the conditional rendering
around PictureCarousel and the “Picture” Button/file input so the add action
remains available whenever !readonly, even when pictures.length > 0; use the
existing PictureCarousel, selectFile, fileInputElement, and uploading symbols to
place the button alongside the carousel.
In `@frontend/viewer/src/project/demo/in-memory-demo-api.ts`:
- Around line 561-601: The demo upload flow is losing the original filename
because saveFile only stores the Blob in `#uploadedFiles` and getFileStream later
reconstructs fileName from mediaUri. Update the in-memory demo API to persist
metadata.filename alongside the uploaded Blob, and have getFileStream return
that stored name for uploaded files instead of deriving it from demo-upload ids.
Keep the existing demo-picture fallback behavior unchanged in getFileStream and
preserve the current upload/saveFile contract.
---
Nitpick comments:
In `@frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte`:
- Around line 43-80: Add a client-side image-type check in uploadPicture before
calling api.saveFile, since the file input accept hint can be bypassed and the
current flow only relies on the server’s UploadFileResult.NotSupported response.
Use the existing uploadPicture function in PicturesEditor.svelte to reject
non-image File types early with the same user-facing notification, while still
keeping the server-side validation as the source of truth.
In `@frontend/viewer/src/locales/en.po`:
- Around line 1569-1579: The translator comment for the “Picture” msgid is stale
and incorrectly says the add button is currently disabled. Update the comment
next to the Picture/Pictures entries in the locale file so it matches the
current behavior in PictureImage.svelte and PicturesEditor.svelte, describing
the label as the add/upload picture button that opens file selection and
triggers picture creation rather than a disabled control.
In `@frontend/viewer/tests/sense-pictures.test.ts`:
- Line 26: The three tests are repeating the same fragile pictures selector,
which is tightly coupled to an inline style detail. Add a shared helper such as
picturesField on BrowsePage or EntryViewComponent and use it in each test
instead of inlining page.locator('[style*="grid-area: pictures"]'). Keep the
selector logic in one place so the tests reference the helper rather than
duplicating the locator.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 2e80063c-04e1-4921-8d8e-4e30ad159fca
📒 Files selected for processing (23)
backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.csbackend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.csbackend/FwLite/LcmCrdt/MediaServer/LcmMediaService.csfrontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.tsfrontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.tsfrontend/viewer/src/lib/dotnet-types/index.tsfrontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.sveltefrontend/viewer/src/lib/entry-editor/field-editors/PictureImage.sveltefrontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.sveltefrontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.sveltefrontend/viewer/src/lib/views/entity-config.tsfrontend/viewer/src/lib/views/view-data.tsfrontend/viewer/src/locales/en.pofrontend/viewer/src/locales/es.pofrontend/viewer/src/locales/fr.pofrontend/viewer/src/locales/id.pofrontend/viewer/src/locales/ko.pofrontend/viewer/src/locales/ms.pofrontend/viewer/src/locales/sw.pofrontend/viewer/src/locales/vi.pofrontend/viewer/src/project/demo/demo-entry-data.tsfrontend/viewer/src/project/demo/in-memory-demo-api.tsfrontend/viewer/tests/sense-pictures.test.ts
💤 Files with no reviewable changes (1)
- frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts
| // 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<string, Blob>(); | ||
|
|
||
| getFileStream(mediaUri: string): Promise<IReadFileResponseJs> { | ||
| 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'}); | ||
| 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<IUploadFileResponse> { | ||
| return Promise.resolve({result: UploadFileResult.NotSupported}); | ||
| saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise<IUploadFileResponse> { | ||
| 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}); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Uploaded file's original name is discarded.
saveFile receives metadata.filename but never stores it; getFileStream later derives fileName from the synthesized mediaUri instead of the original upload name. Real backend behavior presumably preserves the uploaded filename.
🐛 Proposed fix to preserve original filename
- `#uploadedFiles` = new Map<string, Blob>();
+ `#uploadedFiles` = new Map<string, {blob: Blob; fileName: string}>();
getFileStream(mediaUri: string): Promise<IReadFileResponseJs> {
const uploaded = this.#uploadedFiles.get(mediaUri);
if (uploaded) {
return Promise.resolve({
result: ReadFileResult.Success,
- fileName: mediaUri.split('/').pop() ?? 'demo-upload',
+ fileName: uploaded.fileName,
stream: {
- stream: () => Promise.resolve(uploaded.stream()),
- arrayBuffer: () => uploaded.arrayBuffer(),
+ stream: () => Promise.resolve(uploaded.blob.stream()),
+ arrayBuffer: () => uploaded.blob.arrayBuffer(),
},
});
}
...
const mediaUri = `demo-upload/${randomId()}`;
- this.#uploadedFiles.set(mediaUri, blob);
+ this.#uploadedFiles.set(mediaUri, {blob, fileName: metadata.filename});
return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 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<string, Blob>(); | |
| getFileStream(mediaUri: string): Promise<IReadFileResponseJs> { | |
| 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'}); | |
| 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<IUploadFileResponse> { | |
| return Promise.resolve({result: UploadFileResult.NotSupported}); | |
| saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise<IUploadFileResponse> { | |
| 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}); | |
| // 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<string, {blob: Blob; fileName: string}>(); | |
| getFileStream(mediaUri: string): Promise<IReadFileResponseJs> { | |
| const uploaded = this.#uploadedFiles.get(mediaUri); | |
| if (uploaded) { | |
| return Promise.resolve({ | |
| result: ReadFileResult.Success, | |
| fileName: uploaded.fileName, | |
| stream: { | |
| stream: () => Promise.resolve(uploaded.blob.stream()), | |
| arrayBuffer: () => uploaded.blob.arrayBuffer(), | |
| }, | |
| }); | |
| } | |
| 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<IUploadFileResponse> { | |
| 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, fileName: metadata.filename}); | |
| return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri}); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/viewer/src/project/demo/in-memory-demo-api.ts` around lines 561 -
601, The demo upload flow is losing the original filename because saveFile only
stores the Blob in `#uploadedFiles` and getFileStream later reconstructs fileName
from mediaUri. Update the in-memory demo API to persist metadata.filename
alongside the uploaded Blob, and have getFileStream return that stored name for
uploaded files instead of deriving it from demo-upload ids. Keep the existing
demo-picture fallback behavior unchanged in getFileStream and preserve the
current upload/saveFile contract.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
For the future, if we decide to allow rearranging the order of pictures in the FW Lite UI.
|
Some thoughts:
Things I'd do in a follow up PR:
|
UI mostly coded by Claude; I just gave it design pointers. Please double-check that there aren't any code-style problems that I missed. (For example, did it use the correct carousel component? I think it did, but I'm not nearly as familiar with shadcn as @myieye is).
Tested locally by doing Send/Receive and/or Sync in both directions. The "+ Picture" button successfully uploads a picture and then the sync can send it to FieldWorks Classic. Currently limited to JPG and PNG, because I haven't yet thought about issues like whether we want to convert files in other formats, and so on.
UI shows just one picture if it's the only one, but if there are two or more then it shows a carousel, with a 10-second pause before switching the pictures. Feel free to bikeshed the pause duration if it's too long or too short, or anything else about it for that matter.
Design decisions to discuss
Design decisions we made
Screenshots
One picture:
No pictures:
Two pictures (carousel mode):