fix(ui): propagate photo tag edits to grid and viewer
Add a shared photo-updates signal the panel publishes on rename or tag add/remove; the album grid and viewer subscribe and patch the changed photo into their lists, so the 360 badge and panorama mode update without a reload.
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
import PhotoPanel from './PhotoPanel.svelte';
|
import PhotoPanel from './PhotoPanel.svelte';
|
||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { selection } from '$lib/stores/selection.svelte';
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
|
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
|
||||||
import { transfer } from '$lib/stores/transfer.svelte';
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
function onRenamed(updated: Photo) {
|
function onRenamed(updated: Photo) {
|
||||||
photo = updated;
|
photo = updated;
|
||||||
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
||||||
|
photoUpdates.publish(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeleted() {
|
function onDeleted() {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
class PhotoUpdatesStore {
|
||||||
|
latest = $state<Photo | null>(null);
|
||||||
|
|
||||||
|
publish(photo: Photo) {
|
||||||
|
this.latest = photo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const photoUpdates = new PhotoUpdatesStore();
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { tick } from 'svelte';
|
import { tick, untrack } from 'svelte';
|
||||||
import { api, photosApi } from '$lib/api';
|
import { api, photosApi } from '$lib/api';
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { density } from '$lib/stores/density.svelte';
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import { selection } from '$lib/stores/selection.svelte';
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
|
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
|
||||||
import { viewerReturn } from '$lib/stores/viewer-return';
|
import { viewerReturn } from '$lib/stores/viewer-return';
|
||||||
import { transfer } from '$lib/stores/transfer.svelte';
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||||
@@ -80,6 +81,18 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Patch a photo edited in the panel (e.g. tags) into the grid list so
|
||||||
|
// derived UI like the 360 badge updates without a reload. Read the list
|
||||||
|
// untracked so writing it cannot re-trigger this effect.
|
||||||
|
const updated = photoUpdates.latest;
|
||||||
|
if (!updated) return;
|
||||||
|
const list = untrack(() => photos);
|
||||||
|
if (list.some((p) => p.id === updated.id)) {
|
||||||
|
photos = list.map((p) => (p.id === updated.id ? updated : p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let client: SSEClient | null = null;
|
let client: SSEClient | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import Viewer from '$lib/components/Viewer.svelte';
|
import Viewer from '$lib/components/Viewer.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||||
|
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
|
||||||
import { viewerReturn } from '$lib/stores/viewer-return';
|
import { viewerReturn } from '$lib/stores/viewer-return';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
@@ -40,6 +42,18 @@
|
|||||||
.finally(() => (loading = false));
|
.finally(() => (loading = false));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Patch a photo edited in the panel (e.g. tags) so the viewer reflects
|
||||||
|
// changes such as panorama mode without reopening. Read the list
|
||||||
|
// untracked so writing it cannot re-trigger this effect.
|
||||||
|
const updated = photoUpdates.latest;
|
||||||
|
if (!updated) return;
|
||||||
|
const list = untrack(() => photos);
|
||||||
|
if (list.some((p) => p.id === updated.id)) {
|
||||||
|
photos = list.map((p) => (p.id === updated.id ? updated : p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
goto(`/albums/${albumId}`);
|
goto(`/albums/${albumId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-11
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See `proposal.md` - Why. Relevant current state:
|
||||||
|
|
||||||
|
- The photo panel lives in the app layout (`AlbumPanel.svelte`) and keeps its own copy of
|
||||||
|
the photo; `PhotoPanel` reports edits through `onRenamed(updated)`.
|
||||||
|
- Each route keeps its own list: the grid page and the viewer page both fetch
|
||||||
|
`photos`, and `Viewer` additionally fetches a single `fresh` photo.
|
||||||
|
- Renames reach the grid through the album SSE `photo.updated` event; `AddTag`/`RemoveTag`
|
||||||
|
publish nothing and the panel's callback only touches panel-local state, so tags never
|
||||||
|
reach the route lists.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- A tag change in the panel is reflected in the grid (360 badge) and the viewer
|
||||||
|
(panorama mode) with no reload.
|
||||||
|
- Keep the change local to the acting client so user-scoped tags are not mixed between
|
||||||
|
users.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Replacing the SSE `photo.updated` path used for renames and variant readiness.
|
||||||
|
- Reworking the photo list into a single shared per-album store.
|
||||||
|
- Propagating tag changes across devices/users (tags remain user-scoped).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Frontend-local signal instead of an SSE broadcast
|
||||||
|
Add a small `photoUpdates` store that exposes the latest refreshed `Photo`. The panel
|
||||||
|
publishes; the grid and viewer pages subscribe and patch their own lists by id.
|
||||||
|
|
||||||
|
- Alternative considered: publish `photo.updated` from `AddTag`/`RemoveTag` like
|
||||||
|
`Rename`. Rejected - the SSE payload is built for the acting viewer and would carry
|
||||||
|
that user's tags to every album member, overwriting their own tags in their grid.
|
||||||
|
Local propagation keeps each client on its own tag set.
|
||||||
|
|
||||||
|
### One shared store, consumed by id patch
|
||||||
|
`photoUpdates.latest` holds the most recent published photo; an `$effect` in each
|
||||||
|
consumer maps over its `photos` and replaces the matching id. Only photos already loaded
|
||||||
|
are affected (the panel only edits photos the user has open/selected), so no
|
||||||
|
insert/remove logic is needed.
|
||||||
|
|
||||||
|
- Alternative considered: promote the album photo list into a shared store. Rejected -
|
||||||
|
it touches loading, pagination and SSE handling for no behavior gain here.
|
||||||
|
|
||||||
|
### Publish on the existing `onRenamed` path
|
||||||
|
`AlbumPanel.onRenamed` already receives the server-refreshed photo for tags and rename.
|
||||||
|
It publishes that photo in addition to its local state and `selection.replace`. Renames
|
||||||
|
also flow through SSE, so the extra patch is idempotent.
|
||||||
|
|
||||||
|
### Cover the viewer by patching its page list
|
||||||
|
The viewer page passes its `photos` to `Viewer`, and `Viewer` derives `current` from it,
|
||||||
|
so patching the page list makes the panorama mode switch live. `Viewer`'s internal
|
||||||
|
`fresh` fetch is left as-is.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Two photos updated within one tick could drop one] -> User edits are sequential; keep
|
||||||
|
the signal as "latest" and patching by id. Revisit with a map if batch edits appear.
|
||||||
|
- [Grid list reassignment on patch] -> Only the changed id is replaced; Svelte keyed each
|
||||||
|
keeps DOM stable.
|
||||||
|
- [Panel state and route list diverge again] -> Single publish point in `onRenamed`
|
||||||
|
covers tags and rename.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No data or API change. Deploy frontend only. Rollback is reverting the deploy.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None blocking.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
After adding or removing a tag in the photo panel, the grid and viewer keep showing
|
||||||
|
the stale photo: the `pano360` badge and panorama mode only appear after reloading the
|
||||||
|
page. Tag edits are the one panel change that updates nothing outside the panel —
|
||||||
|
`AddTag`/`RemoveTag` publish no event, and the panel's `onRenamed` only refreshes the
|
||||||
|
panel's own copy, not the page-level photo lists.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Introduce a shared photo-updates signal that the panel publishes to and the grid and
|
||||||
|
viewer pages subscribe to, so a changed photo (tags included) is patched into their
|
||||||
|
local lists immediately.
|
||||||
|
- Wire the photo panel to publish the refreshed photo on tag add/remove (and rename is
|
||||||
|
covered by the same path, harmlessly).
|
||||||
|
- Add a `ui-workspace` scenario: changing a photo's tags in the panel updates the grid
|
||||||
|
badge and the panorama mode without a page reload.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
<!-- none -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `ui-workspace`: the «Метки в панели фото» requirement gains a no-reload propagation
|
||||||
|
scenario.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Frontend: new `frontend/src/lib/stores/photo-updates.svelte.ts`;
|
||||||
|
`frontend/src/lib/components/AlbumPanel.svelte` (publish);
|
||||||
|
`frontend/src/routes/(app)/albums/[id]/+page.svelte` and
|
||||||
|
`frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte` (subscribe+patch).
|
||||||
|
- Spec: `openspec/specs/ui-workspace/spec.md` («Метки в панели фото»).
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Метки в панели фото
|
||||||
|
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
|
||||||
|
поиск по меткам пользователя и создание новых. Изменение меток в панели SHALL сразу отражаться
|
||||||
|
в сетке и в режиме просмотра без перезагрузки страницы.
|
||||||
|
|
||||||
|
#### Scenario: Раздел «Метки»
|
||||||
|
- **WHEN** открыт просмотр или выбран один фото в сетке
|
||||||
|
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
|
||||||
|
|
||||||
|
#### Scenario: Обновление без перезагрузки
|
||||||
|
- **WHEN** пользователь добавляет или снимает метку в панели фото
|
||||||
|
- **THEN** сетка и режим просмотра сразу отражают изменение (например, бейдж и режим 360-панорамы), без перезагрузки страницы
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Shared photo-updates store
|
||||||
|
|
||||||
|
- [x] 1.1 Add `frontend/src/lib/stores/photo-updates.svelte.ts` exposing `latest: Photo | null` and `publish(photo: Photo)`; verify `npm run check` passes.
|
||||||
|
- [x] 1.2 In `frontend/src/lib/components/AlbumPanel.svelte`, call `photoUpdates.publish(updated)` from `onRenamed` (keeping the local `photo` assignment and `selection.replace`); verify the tag chips in the panel still update as before.
|
||||||
|
|
||||||
|
## 2. Route lists reflect the update
|
||||||
|
|
||||||
|
- [x] 2.1 In `frontend/src/routes/(app)/albums/[id]/+page.svelte`, add an effect that patches `photos` by id from `photoUpdates.latest`; verify that adding a `pano360` tag shows the 360 badge in the grid without reloading.
|
||||||
|
- [x] 2.2 In `frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte`, add the same patch to its `photos`; verify the panorama mode switches on/off live after tagging while in the viewer.
|
||||||
|
|
||||||
|
## 3. Integration verification
|
||||||
|
|
||||||
|
- [x] 3.1 Run `npm run check` and `npm run build` (frontend); verify both pass with no regressions.
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Run `/opsx-apply` to implement this change.
|
||||||
|
- Archive the change after review and verification (sync the delta spec into `ui-workspace`).
|
||||||
Reference in New Issue
Block a user