diff --git a/docs/specs/4-architecture/features/058-album-listing-v3-adoption/plan.md b/docs/specs/4-architecture/features/058-album-listing-v3-adoption/plan.md index 05714a053d5..9e3db0d8d52 100644 --- a/docs/specs/4-architecture/features/058-album-listing-v3-adoption/plan.md +++ b/docs/specs/4-architecture/features/058-album-listing-v3-adoption/plan.md @@ -8,32 +8,42 @@ _Last updated:_ 2026-08-22 ## Vision & Success Criteria -Flip `ALBUM_LISTING_V3_ENABLED=true` in `.env` and all three consumers (Move-target picker, Fix Tree, Bulk Album Edit) transparently run on the lighter, cacheable v3 endpoint with no visible behavior change to the end user — including thumbnails. Flip it back (or leave it unset) and everything behaves exactly as it does today. Success = FR-058-01..07 implemented, S-058-01..09 manually verified in both flag states, `npm run check`/`format` and `make phpstan`/`php-cs-fixer` clean. +Flip `STRUCT_OF_ARRAY_ENABLED=true` in `.env` and all three consumers (Move-target picker, Fix Tree, Bulk Album Edit) transparently run on the lighter, cacheable v3 endpoint — with full functional parity to v2 (including the "move to root" option), a new breadcrumb display the v8 picker doesn't have today, thumbnails rendered through a shared, cancellation-safe `` component instead of a bare ``, and the Move/Merge picker drawing its album list from one session-cached shared store instead of re-fetching per dialog — correctly invalidated on every login/registration/logout transition and every regular-Album mutation that changes what the store should return. Flip the flag back (or leave it unset) and everything behaves exactly as it does today. Success = FR-058-01..12 implemented, S-058-01..24 manually verified in both flag states, `npm run check`/`format` and `make phpstan`/`php-cs-fixer` clean. ## Scope Alignment -- **In scope:** `config/features.php` entry; `ModulesRightsResource` addition; new `album-list-v3-service.ts`; flag-gated rewiring of `SearchTargetAlbum.vue`, `FixTree.vue`, `BulkAlbumEdit.vue`; new small TS tree/breadcrumb helper; new SoA→AoS adapter for Fix Tree. -- **Out of scope:** Any change to `routes/api_v2.php`, `Actions\Album\ListAlbums`, `FullTree::check()`, `BulkAlbumController`, or Feature 057/056's backend contracts; any change to `treeOperations.ts`'s WASM module itself; any new backend routes. +- **In scope:** `config/features.php` entry (`struct-of-array`); `ModulesRightsResource` addition (`is_struct_of_array_enabled`); new `album-list-v3-service.ts`; new shared `AlbumListState` Pinia store (tree + multi-root exclusion); new `Thumb.vue` component + `thumb-asset-service.ts`; flag-gated rewiring of `SearchTargetAlbum.vue`, `FixTree.vue`, `BulkAlbumEdit.vue`; new SoA→AoS adapter for Fix Tree. +- **Out of scope:** Any change to `routes/api_v2.php`, `Actions\Album\ListAlbums`, `FullTree::check()`, `BulkAlbumController`, `Actions\Album\Move`, or Feature 057/056's backend contracts; any change to `treeOperations.ts`'s WASM module itself; any new backend routes; any Photos SoA endpoint or consumer (the flag is named generally for that future work, but nothing photos-related is built here); **anything under `resources/js/v7/`** — every v8 file this feature reads or wires `AlbumListState.invalidate()` into (`SearchTargetAlbum.vue`, `FixTree.vue`, `BulkAlbumEdit.vue`, `LoginForm.vue`, `WebauthnModal.vue`, `RegisterPage.vue`, `LeftMenu.vue`, `AlbumDelete.vue`, `DeleteDialog.vue`, `Unlock.vue`, `AlbumVisibility.vue`, `ImportFromServer.vue`) has an independent `resources/js/v7/...` counterpart with its own `AlbumService.clearCache()`/`clearAlbums()` call, left untouched — this feature is v8-only, consistent with the project's default of forking/replacing in v8 rather than editing v7 in place. ## Dependencies & Interfaces -- Feature 057 (`GET /api/v3/Albums`, `AlbumListResource`, `cover_ids`) — **must already be implemented** before this feature's work can be manually verified end-to-end (I2 onward depends on a real v3 endpoint to call). If 057 isn't implemented yet when this plan starts, front-load 057's implementation first. -- Feature 056 (`GET /api/v3/Asset/{album_id}/{photo_id}/{size_variant}`) — already implemented (roadmap: Completed Features). +- Feature 057 (`GET /api/v3/Albums`, `AlbumListResource`, `cover_ids`) — **already implemented** (backend, this branch). +- Feature 056 (`GET /api/v3/Asset/{album_id}/{photo_id}/{size_variant}`) — already implemented; authorization is resolved through `album_id` via `AlbumPolicy::CAN_ACCESS` plus photo-membership/cover checks (`GetPhotoAssetRequest`), which is why `` takes `album-id` as a required prop, not just `photo-id`. - `App\Http\Resources\Rights\ModulesRightsResource` (`app/Http/Resources/Rights/ModulesRightsResource.php`). -- `resources/js/services/album-service.ts` (`getTargetListAlbums`, to be left in place as the flag-off path). +- `resources/js/services/album-service.ts` (`getTargetListAlbums`, `clearCache`, `clearAlbums` — the flag-off path and the existing cache-invalidation convention this feature's store mirrors, NFR-058-09). - `resources/js/services/maintenance-service.ts` (`fullTreeGet`, left in place). - `resources/js/services/bulk-album-edit-service.ts` (`getAlbums`/`getIds`, left in place). +- `resources/js/services/upload-service.ts` — existing precedent for the `AbortController`/axios `signal` pattern `thumb-asset-service.ts` reuses. +- `resources/js/stores/AlbumsState.ts` / `AlbumState.ts` — existing Pinia `defineStore("id", {state, getters, actions})` Options-API convention the new `AlbumListState.ts` follows. - `resources/js/v8/composables/album/treeOperations.ts` (WASM bridge, untouched — only fed adapted data). - `resources/js/v8/components/forms/album/SearchTargetAlbum.vue`, `resources/js/v8/views/FixTree.vue`, `resources/js/v8/views/BulkAlbumEdit.vue`. +- `resources/js/v8/components/forms/album/AlbumMove.vue`, `resources/js/v8/components/forms/gallery-dialogs/MoveDialog.vue`, `resources/js/v8/components/forms/photo/PhotoCopyDialog.vue`, `resources/js/v8/components/forms/gallery-dialogs/AlbumMergeDialog.vue` — the four `SearchTargetAlbum` call sites; none require prop/event changes (NFR-058-02), but all four benefit from the shared store's de-duplication (NFR-058-07). +- `resources/js/v8/components/forms/auth/LoginForm.vue` (`login()`, `:125`), `resources/js/v8/components/modals/WebauthnModal.vue` (`login()`, `:65`), `resources/js/v8/views/RegisterPage.vue` (registration success, `:125`), and `resources/js/v8/menus/LeftMenu.vue` (`logout()`, `:170`, alongside `photoStore.reset()`/`photosStore.reset()`/`albumsStore.reset()`/`albumStore.reset()`) — the four existing identity-transition `AlbumService.clearCache()` call sites the shared store's `invalidate()` is additionally wired into (FR-058-11). +- `resources/js/v8/components/forms/album/AlbumDelete.vue` (`:67`), `resources/js/v8/components/forms/gallery-dialogs/DeleteDialog.vue` (`executeDeleteAlbum()`, `:105`), `resources/js/v8/components/forms/album/Unlock.vue` (`:48`), `resources/js/v8/components/forms/album/AlbumVisibility.vue` (`:149`), `resources/js/v8/views/FixTree.vue` (post-save, `:156`), `resources/js/v8/components/modals/ImportFromServer.vue` (`:170`) — the six existing regular-Album-mutation `AlbumService.clearCache()`/`clearAlbums()` call sites the shared store's `invalidate()` is additionally wired into (FR-058-12), found via an audit of every such call site in `resources/js/v8` (full list and exclusion reasoning in Q-058-07). ## Assumptions & Risks -- **Assumptions:** Feature 057 is implemented and its `cover_ids`/`for_bulk_edit`/`with_parent_id` behavior matches its spec exactly (re-verify at I1 if any drift occurred since 057's Implementation Drift Gate). +- **Assumptions:** Feature 057's `cover_ids`/`for_bulk_edit`/`with_parent_id` behavior matches its spec exactly (re-verify at I1 if any drift occurred since 057's Implementation Drift Gate). Feature 056's Asset endpoint route is `GET /api/v3/Asset/{album_id}/{photo_id}/{size_variant}` (confirmed against `routes/api_v3.php` and `GetPhotoAssetRequest` while drafting this revision — `album_id` is required because authorization is resolved through `AlbumPolicy::CAN_ACCESS` on that album plus a photo-membership/cover check, not through `PhotoPolicy` alone). - **Risks / Mitigations:** - - *Risk:* An `` tag hitting `GET /api/v3/Asset/...` for an album whose cover photo was deleted between the listing fetch and render (race) returns a 403/404, showing a broken-image icon. *Mitigation:* bind the ``'s `@error` handler to swap to the existing placeholder asset (same technique likely already used for other thumbnail displays in this codebase — confirm at I2 and reuse). - - *Risk:* Client-side subtree exclusion for the move-picker (FR-058-04) is security-relevant (prevents moving an album into its own descendant) — a bug here is worse than a bug in, say, a display-only breadcrumb. *Mitigation:* NFR-058-03's explicit side-by-side manual comparison against `ListAlbums::do()`'s existing exclusion logic (`app/Actions/Album/ListAlbums.php:44-53`) before considering I2 done; also note the backend `Move`/`MoveAlbums` action itself presumably re-validates the target isn't a descendant server-side regardless (confirm at I2 — if it does, this client-side exclusion is UX-only, not the actual security boundary, which lowers this risk's severity and should be documented). - - *Risk:* Bulk Album Edit holding the full curated album list in browser memory could be slow on very large installs. *Mitigation:* accepted (NFR-058-04), not solved here; flagged in Follow-ups. - - *Risk:* No automated frontend test suite exists to catch a regression automatically. *Mitigation:* thorough manual verification per Feature 054's precedent, in both flag states, before considering the feature done. + - *Risk:* Client-side subtree exclusion (FR-058-04) is security-relevant (prevents moving/merging an album into its own or a co-selected album's descendant) — a bug here is worse than a bug in, say, a display-only breadcrumb. *Mitigation:* NFR-058-03's explicit side-by-side manual comparison against `ListAlbums::do()`'s existing single-root exclusion, plus a dedicated multi-root fixture for the merge case (no server precedent exists for that case — see next risk). *Confirmed during this plan's drafting:* the actual server-side backstop is `NodeTrait::appendNode()`'s `\LogicException` guard (`vendor/lychee-org/nestedset/src/NodeTrait.php:1199-1200`), called per-album from `Actions\Album\Move::do()` with no visible dedicated HTTP-exception translation — so a client that bypassed this feature's exclusion would likely hit an uncaught 500, not a clean 4xx. This raises the severity of getting FR-058-04 right: it is the actual UX-facing (and arguably only graceful) safety net, not merely a nicety. Whether the backend should translate that `LogicException` into a proper 4xx is a candidate follow-up, explicitly out of scope here (Non-Goals). + - *Risk:* ``'s module-level object-URL cache (DO-058-06) grows unbounded for the lifetime of the page/SPA session (no eviction), and blob object URLs are not `URL.revokeObjectURL()`'d as long as they stay cached. *Mitigation:* accepted at this feature's scale — a move/merge picker shows at most a few dozen albums, not thousands (unlike Bulk Album Edit's list, NFR-058-04); flagged in Follow-ups if it ever needs an eviction policy. Revoking on unmount is explicitly *not* done, since the whole point of the cache is that a second render of the same `(album_id, photo_id, type)` — e.g. reopening the same dialog — must not re-fetch. + - *Risk:* An `` inside `` for an album whose cover photo was deleted between the listing fetch and render (race) returns a 403/404 from the Asset endpoint. *Mitigation:* `` itself owns this fallback (FR-058-05) — catch the rejected blob fetch and swap to the placeholder, no `@error` handler needed on a raw `` since the component controls the fetch directly (this is simpler than the previously-drafted `@error`-on-`` approach, since `` never sets `src` to the real endpoint URL directly). + - *Risk:* The shared store (FR-058-03) going stale after a move/merge performed via the very dialogs that read it. *Mitigation:* NFR-058-09 — wire the store's `invalidate()` into the same call sites that already call `AlbumService.clearCache()`/`clearAlbums()` today (`AlbumMove.vue`, `MoveDialog.vue`, `AlbumMergeDialog.vue`), so this is an additive one-line call at each existing invalidation point, not new logic. + - *Risk:* The shared store is identity-scoped but has no built-in notion of identity — a list cached as a guest (or a lower-privileged user) would silently under-serve the actual visibility of a user who then logs in within the same SPA session, without a hard page reload in between (unlike `LeftMenu.vue`'s current logout flow, which does `window.location.href = ...` and so incidentally wipes all in-memory state today). *Mitigation:* FR-058-11/NFR-058-10 — wire `invalidate()` into all four existing identity-transition call sites (`LoginForm.vue`, `WebauthnModal.vue`, `RegisterPage.vue`, `LeftMenu.vue`), the same additive one-line-call pattern as the move/merge case, rather than relying on logout's incidental full-page-reload behavior (which none of the other three have, and which logout could lose if that flow is ever changed to a SPA-only transition). `WebauthnModal.vue` and `RegisterPage.vue` were found only by auditing every existing `AlbumService.clearCache()` call site — a first pass that only checked `LoginForm.vue` (the obvious one) would have missed both. + - *Risk:* A regular-Album mutation outside the move/merge dialogs (delete, unlock, visibility change, Fix Tree's own repair save, server-folder import) also changes what the shared store should return, and none of those are dialogs this feature otherwise touches. *Mitigation:* FR-058-12 — the same audit that found the two extra login call sites also found these six mutation call sites; wire `invalidate()` into each, and explicitly document (Q-058-07) why the *other* `AlbumService.clearCache()` call sites found in that audit (pin-toggle, Tag/Person-Album operations, photo star/highlight) are correctly left alone — none of them change a field this store tracks. + - *Risk:* Every v8 file this feature reads or wires into has an independent `resources/js/v7/...` counterpart with its own `AlbumService.clearCache()` call (confirmed via `find` across all 11 non-picker files) — easy to accidentally wire the wrong tree's file, or to wonder later why v7 wasn't "fixed" too. *Mitigation:* v7 is explicitly out of scope (Scope Alignment); v7 never reads from `AlbumListState`, so leaving its independent cache-clear calls untouched carries no staleness risk — only v8's rewired `SearchTargetAlbum.vue`/`FixTree.vue`/`BulkAlbumEdit.vue` consume the new store. + - *Risk:* Bulk Album Edit holding the full curated album list in browser memory could be slow on very large installs. *Mitigation:* accepted (NFR-058-04), not solved here; flagged in Follow-ups. Unaffected by the shared-store work, since Bulk Album Edit deliberately does **not** use the shared store (it needs `for_bulk_edit` fields the store never requests, per Q-058-04's reasoning). + - *Risk:* No automated frontend test suite exists to catch a regression automatically, and the new caching/cancellation behavior (``, the shared store) is inherently timing-sensitive, which is harder to verify by eye than a plain data-source swap. *Mitigation:* thorough manual verification per Feature 054's precedent, in both flag states, using the browser DevTools Network panel specifically for the cancellation (S-058-12) and de-duplication (S-058-10) scenarios — these cannot be eyeballed from the rendered UI alone. ## Implementation Drift Gate @@ -43,37 +53,66 @@ At I1, re-read `app/Http/Resources/Rights/ModulesRightsResource.php` and Feature 1. **I1 – Backend flag + init exposure** - _Goal:_ `config/features.php` entry and `ModulesRightsResource` addition, independently testable before any frontend work starts. - - _Preconditions:_ Feature 057 implemented (or its I1-I4 at minimum, so `GET /api/v3/Albums` exists to call later). + - _Preconditions:_ Feature 057 implemented (confirmed on this branch). - _Steps:_ - - Feature test first: assert `modules.is_album_listing_v3_enabled` in the init response matches `config('features.album-listing-v3')` for both `true`/`false` (set via `config()` override in the test, not `.env`, per this repo's existing config-flag test convention). - - Add `'album-listing-v3' => (bool) env('ALBUM_LISTING_V3_ENABLED', false)` to `config/features.php`, following the existing doc-comment-block style. - - Add `public bool $is_album_listing_v3_enabled = false;` + `isAlbumListingV3Enabled()` resolver to `ModulesRightsResource`, wired into its constructor. + - Feature test first: assert `modules.is_struct_of_array_enabled` in the init response matches `config('features.struct-of-array')` for both `true`/`false` (set via `config()` override in the test, not `.env`, per this repo's existing config-flag test convention). + - Add `'struct-of-array' => (bool) env('STRUCT_OF_ARRAY_ENABLED', false)` to `config/features.php`, following the existing doc-comment-block style. + - Add `public bool $is_struct_of_array_enabled = false;` + `isStructOfArrayEnabled()` resolver to `ModulesRightsResource`, wired into its constructor. - _Commands:_ `php artisan test --filter=ModulesRightsResource` (or the existing init-payload test class name — confirm exact name at I1), `make phpstan`. - _Exit:_ Flag round-trips correctly; `make phpstan` clean. -2. **I2 – Move-target picker (`SearchTargetAlbum.vue`)** - - _Goal:_ Flag-gated v3 path for the highest-traffic, lowest-complexity consumer first (de-risks the pattern before the two heavier admin pages). +2. **I2 – Shared album-list store** + - _Goal:_ `AlbumListState.ts` (DO-058-03) built and independently verifiable before any consumer wires into it — this is the foundation FR-058-06/07/08 all sit on. - _Preconditions:_ I1 done; Feature 057's endpoint reachable. - _Steps:_ - - Add `resources/js/services/album-list-v3-service.ts` (DO-058-02). - - Add the tree/breadcrumb/exclusion TS helper (DO-058-03) — confirm at this step whether the backend `Move`/`MoveAlbums` action re-validates descendant-safety server-side (per the Risks note above) and document the finding in this plan before proceeding. - - Rewire `SearchTargetAlbum.vue`: when `modules.is_album_listing_v3_enabled`, call the new service, run the helper, render thumbnails via `` with an `@error` fallback to the placeholder; else keep today's `AlbumService.getTargetListAlbums()` path unchanged. - - Manual verification: exercise `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue` in both flag states (NFR-058-02). + - Add `resources/js/services/album-list-v3-service.ts` (DO-058-02) if not already present from a prior increment; confirm its shape matches `App.Http.Resources.V3.AlbumListResource`'s generated TS types. + - Add `resources/js/stores/AlbumListState.ts`: state (raw base-mode arrays, `isLoading`, `error`), `ensureLoaded()`/`invalidate()` actions, `tree` getter (nested-set stack reconstruction — implement and manually sanity-check against a small hand-built fixture: a 2-level tree with a sibling pair), `getExcludedTargetIds(rootIds: string[])` getter/function (single-root case first, verify against `ListAlbums::do()`'s existing behavior; then the multi-root case against a hand-built fixture with a selected ancestor/descendant pair). + - Manual verification: exercise the store from the browser console (no UI wired yet) against a real logged-in session — confirm `tree` shape and `getExcludedTargetIds` results for a few hand-picked album ids. - _Commands:_ `npm run check`, `npm run format`. - - _Exit:_ S-058-01/02/03 manually verified. + - _Exit:_ Store's `tree`/`getExcludedTargetIds` verified correct against hand-built fixtures; no consumer wired yet. -3. **I3 – Fix Tree page** - - _Goal:_ Flag-gated v3 path with a SoA→AoS adapter, zero changes to the WASM module. +3. **I3 – `` component** + - _Goal:_ `Thumb.vue` + `thumb-asset-service.ts` (DO-058-05/06) built and independently verifiable (e.g. temporarily dropped into an existing dev page) before the move-picker depends on it. + - _Preconditions:_ I1 done (flag exists, though `` itself is not flag-gated — it is just a component the flag-on path uses). + - _Steps:_ + - Add `resources/js/services/thumb-asset-service.ts`: `getObjectUrl(albumId, photoId, type, signal)`, module-level `Map` cache keyed by `` `${albumId}:${photoId}:${type}` ``, axios `GET /api/v3/Asset/{albumId}/{photoId}/{type}` with `responseType: 'blob'` and the passed `signal`. + - Add `resources/js/v8/components/thumbs/Thumb.vue`: `onMounted`/prop-`watch` creates an `AbortController`, calls the service, sets the resolved object URL; `onBeforeUnmount` calls `controller.abort()`; `photoId === null` or a rejected/aborted fetch renders the existing placeholder asset (reuse whatever helper `PhotoThumb.vue`/`useImageHelpers` already exposes for the no-image icon). + - Manual verification: render several `` instances for the same `(albumId, photoId, type)` on one page, confirm only one network request in DevTools; unmount one mid-request (e.g. via `v-if` toggle), confirm the request shows `(canceled)`. + - _Commands:_ `npm run check`, `npm run format`. + - _Exit:_ S-058-12/13-equivalent behavior manually confirmed in isolation, before any real consumer uses the component. + +4. **I4 – Move-target picker (`SearchTargetAlbum.vue`)** + - _Goal:_ Flag-gated v3 path wired to the I2 store and I3 component, covering both single-root Move and multi-root Merge through the same unchanged `album-ids` prop, with full v2 parity (root option) plus the new breadcrumb display. + - _Preconditions:_ I1-I3 done. + - _Steps:_ + - Rewire `SearchTargetAlbum.vue`: when `modules.is_struct_of_array_enabled`, call the store's `ensureLoaded()`, filter by `getExcludedTargetIds(props.albumIds ?? [])`, prepend the synthetic "move to root" row when `props.albumIds` is non-empty and `!isTopLevel(props.albumIds[0])` (mirroring `AlbumController::getTargetListAlbums`'s first-selected-album rule), render each option's label via `buildBreadcrumb` through an `#item-label` slot override, render each row's thumbnail via `` (root row gets `photo-id: null` → placeholder); else keep today's `AlbumService.getTargetListAlbums()` path and `label-key="original"` binding unchanged. + - Manual verification: exercise `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue` in both flag states (NFR-058-02); specifically exercise `AlbumMergeDialog.vue` with 3 albums selected including an ancestor/descendant pair (S-058-11); check the Network panel across dialog opens for de-duplication (S-058-10); verify the root option appears/doesn't appear correctly (S-058-16/17); verify breadcrumb text disambiguates two same-titled albums under different parents (S-058-18). + - _Commands:_ `npm run check`, `npm run format`. + - _Exit:_ S-058-01/02/03/10/11/12/13/16/17/18 manually verified. + +5. **I5 – Shared-store invalidation wiring** + - _Goal:_ Wire the store's `invalidate()` into every existing `AlbumService.clearCache()`/`clearAlbums()` call site identified as relevant (FR-058-11/12) — kept as its own increment, separate from I4, since it touches ten unrelated v8 files rather than the picker itself, and is independently testable. + - _Preconditions:_ I2 done (store must exist); I4 not required first, but verification is easiest once the picker (I4) can display the effect of each invalidation. + - _Steps:_ + - Identity transitions (FR-058-11): wire `invalidate()` into `LoginForm.vue`'s `login()` success handler (`:125`), `WebauthnModal.vue`'s `login()` success handler (`:65`), `RegisterPage.vue`'s registration success handler (`:125`), and `LeftMenu.vue`'s `logout()` handler (`:170`) — each alongside its existing `AlbumService.clearCache()` call. All four are v8-only; do not touch the `resources/js/v7/...` counterparts (Scope Alignment). + - Regular-Album mutations (FR-058-12): wire `invalidate()` into `AlbumMove.vue`/`MoveDialog.vue`/`AlbumMergeDialog.vue` (move/merge, already implied by NFR-058-09), `AlbumDelete.vue:67`, `DeleteDialog.vue:105` (album-delete path), `Unlock.vue:48`, `AlbumVisibility.vue:149`, `FixTree.vue:156` (after `updateFullTree()`'s save), and `ImportFromServer.vue:170` — again v8-only. + - Explicitly do **not** touch: `AlbumPanel.vue`/`Albums.vue` (`togglePin`), `AlbumCreatePersonDialog.vue`/`AlbumCreateTagDialog.vue`/`TagRenameDialog.vue`/`TagDeleteDialog.vue`/`TagPanel.vue`/`TagMergeDialog.vue` (Tag/Person-Album operations), `Search.vue`/`Timeline.vue` (star/unstar) — confirmed irrelevant to this store's tracked fields (Q-058-07). + - Manual verification: one pass per included call site (S-058-11/14/15/19..24), confirming the picker (from I4) reflects the change after each; spot-check one excluded call site (e.g. pin-toggle) to confirm the store is *not* invalidated, as expected. + - _Commands:_ `npm run check`, `npm run format`. + - _Exit:_ S-058-14/15/19/20/21/22/23/24 manually verified. + +6. **I6 – Fix Tree page** + - _Goal:_ Flag-gated v3 path with a SoA→AoS adapter, zero changes to the WASM module, using its own separate admin-gated fetch (not the I2 shared store). - _Preconditions:_ I1 done. - _Steps:_ - Add the SoA→`AlbumTree[]` adapter (DO-058-04). - - Rewire `FixTree.vue`'s `fetch()`: when the flag is on, call `AlbumListV3Service.getAlbums({with_parent_id: true})` + adapter instead of `MaintenanceService.fullTreeGet()`; feed the result into the existing `prepareAlbums()`/WASM pipeline unchanged. `updateFullTree()` (save) untouched regardless of flag. + - Rewire `FixTree.vue`'s `fetch()`: when the flag is on, call `AlbumListV3Service.getAlbums({with_parent_id: true})` + adapter instead of `MaintenanceService.fullTreeGet()`; feed the result into the existing `prepareAlbums()`/WASM pipeline unchanged. `updateFullTree()` (save) untouched regardless of flag, except for I5's added `invalidate()` call on its completion. - Manual verification: run the existing validity-check/repair flow in both flag states against the same seeded inconsistent-tree fixture data. - _Commands:_ `npm run check`, `npm run format`. - _Exit:_ S-058-04 manually verified. -4. **I4 – Bulk Album Edit page** - - _Goal:_ Flag-gated v3 path with client-side pagination/search/select-all, the largest rewrite of the three. +7. **I7 – Bulk Album Edit page** + - _Goal:_ Flag-gated v3 path with client-side pagination/search/select-all, using its own separate admin-gated fetch (not the I2 shared store), the largest rewrite of the three. - _Preconditions:_ I1 done. - _Steps:_ - Rewire `BulkAlbumEdit.vue`: when the flag is on, fetch once via `AlbumListV3Service.getAlbums({for_bulk_edit: true})`; reimplement `load(page)` as an in-memory slice, the debounced search handler as an in-memory filter (matching v2's title-substring semantics), and "select all matching" as a filter over the same in-memory list (no `::ids` call). Both "numbered" and "infinite-scroll" UI modes keep their existing markup/props, only their data source changes. @@ -81,12 +120,12 @@ At I1, re-read `app/Http/Resources/Rights/ModulesRightsResource.php` and Feature - _Commands:_ `npm run check`, `npm run format`. - _Exit:_ S-058-05/06/07/08 manually verified. -5. **I5 – Quality gate, docs, wrap-up** +8. **I8 – Quality gate, docs, wrap-up** - _Goal:_ Full verification and documentation sync. - - _Preconditions:_ I1–I4 done. + - _Preconditions:_ I1–I7 done. - _Steps:_ - `npm run format`; `npm run check`; `make phpstan`; `vendor/bin/php-cs-fixer fix`; `php artisan test --filter=ModulesRightsResource`. - - Full manual pass of S-058-01..09 (both flag states), documenting results in this plan's Implementation Drift Gate. + - Full manual pass of S-058-01..24 (both flag states), documenting results in this plan's Implementation Drift Gate. - Update `docs/specs/3-reference/api-design.md`, `docs/specs/4-architecture/knowledge-map.md`; move roadmap.md's Feature 058 row to Completed. - Prepare commit summary per AGENTS.md commit protocol — do not commit directly. - _Commands:_ as above. @@ -96,29 +135,50 @@ At I1, re-read `app/Http/Resources/Rights/ModulesRightsResource.php` and Feature | Scenario ID | Increment / Task reference | Notes | |-------------|---------------------------|-------| -| S-058-01 | I1-I4 / T-058-01..08 | Flag-off regression guard, checked at every increment. | -| S-058-02 | I2 / T-058-03 | Move dialog, flag on. | -| S-058-03 | I2 / T-058-03 | Move dialog, missing cover. | -| S-058-04 | I3 / T-058-05 | Fix Tree, flag on. | -| S-058-05 | I4 / T-058-07 | Bulk edit, numbered pagination. | -| S-058-06 | I4 / T-058-07 | Bulk edit, infinite scroll. | -| S-058-07 | I4 / T-058-07 | Bulk edit, search. | -| S-058-08 | I4 / T-058-07 | Bulk edit, select-all-matching. | -| S-058-09 | I5 / T-058-08 | Pure `.env` toggle, no rebuild. | +| S-058-01 | I1-I7 / T-058-01..12 | Flag-off regression guard, checked at every increment. | +| S-058-02 | I4 / T-058-06 | Move dialog, flag on. | +| S-058-03 | I4 / T-058-06 | Move dialog, missing cover. | +| S-058-04 | I6 / T-058-09 | Fix Tree, flag on. | +| S-058-05 | I7 / T-058-10 | Bulk edit, numbered pagination. | +| S-058-06 | I7 / T-058-10 | Bulk edit, infinite scroll. | +| S-058-07 | I7 / T-058-10 | Bulk edit, search. | +| S-058-08 | I7 / T-058-10 | Bulk edit, select-all-matching. | +| S-058-09 | I8 / T-058-12 | Pure `.env` toggle, no rebuild. | +| S-058-10 | I4 / T-058-06 | Shared-store request de-duplication across dialogs. | +| S-058-11 | I4 / T-058-06; invalidation re-verified I5 / T-058-07 | Merge dialog, multi-root cyclic exclusion; also exercises move/merge invalidation. | +| S-058-12 | I3 / T-058-05; re-verified I4 / T-058-06 | Thumb request cancellation on unmount. | +| S-058-13 | I3 / T-058-05; re-verified I4 / T-058-06 | Thumb cache reuse across renders. | +| S-058-14 | I5 / T-058-07 | Shared-store invalidation on login (visibility gain). | +| S-058-15 | I5 / T-058-07 | Shared-store invalidation on logout (visibility narrowing). | +| S-058-16 | I4 / T-058-06 | "Move to root" option shown when applicable. | +| S-058-17 | I4 / T-058-06 | "Move to root" option hidden when already at root. | +| S-058-18 | I4 / T-058-06 | Breadcrumb disambiguates same-titled albums. | +| S-058-19 | I5 / T-058-07 | Shared-store invalidation on album delete. | +| S-058-20 | I5 / T-058-07 | Shared-store invalidation on album unlock. | +| S-058-21 | I5 / T-058-07 | Shared-store invalidation on visibility/protection-policy change. | +| S-058-22 | I5 / T-058-07 | Shared-store invalidation on Fix Tree repair save. | +| S-058-23 | I5 / T-058-07 | Shared-store invalidation on server-folder import. | +| S-058-24 | I5 / T-058-07 | Shared-store invalidation on registration (auto-login). | ## Analysis Gate -Not yet run. Per AGENTS.md, run the analysis gate checklist once spec, plan, and tasks agree, before starting I1 — and confirm Feature 057 is implemented first (see Dependencies). +Not yet run. Per AGENTS.md, run the analysis gate checklist once spec, plan, and tasks agree, before starting I1. ## Exit Criteria -- FR-058-01..07 and NFR-058-01..06 implemented. -- S-058-01..09 manually verified in both flag states. +- FR-058-01..12 and NFR-058-01..10 implemented. +- S-058-01..24 manually verified in both flag states. - `npm run check`/`npm run format` clean; `make phpstan`/`vendor/bin/php-cs-fixer fix` clean. - Docs updated (`api-design.md`, `knowledge-map.md`, `roadmap.md`). -- Open questions Q-058-01..02 remain resolved (recorded in spec.md Appendix). +- Open questions Q-058-01..07 remain resolved (recorded in spec.md Appendix). +- No file under `resources/js/v7/` touched (NFR-058-01). ## Follow-ups / Backlog - If Bulk Album Edit's full-in-memory approach proves too heavy on very large installs, revisit with a dedicated paginated/searchable v3 variant — no evidence of a problem exists yet, so not pursued now (NFR-058-04). +- Whether `Actions\Album\Move::do()` should catch `NodeTrait::appendNode()`'s `\LogicException` and translate it into a clean 4xx (instead of an uncaught 500) is a candidate backend follow-up, surfaced while investigating Q-058-05 — explicitly out of scope for this feature (Non-Goals: no backend change to `Actions\Album\Move`). +- ``'s object-URL cache has no eviction policy (Risks) — acceptable at this feature's scale; revisit if a future consumer renders far more distinct thumbnails per session than the move/merge picker does. +- `STRUCT_OF_ARRAY_ENABLED` is deliberately named for future reuse by a Photos SoA v3 endpoint (Q-058-03) — that endpoint and its consumer(s) are a separate future feature, not started here. +- The client-side breadcrumb builder (FR-058-04) deliberately doesn't reproduce `ListAlbums::shorten()`'s byte-length proportional-truncation algorithm, relying on CSS truncation instead (Q-058-06) — revisit only if that visually diverges from v2's truncation in a way users notice. +- v7 is entirely out of scope (Non-Goals) — if v7 is ever retired or v8 becomes the only UI, its now-redundant `AlbumService.clearCache()` call sites mirrored by this feature's invalidation wiring become dead code, worth a cleanup pass at that point, not now. - Once this feature is stable, consider a separate future feature to retire the now-unused v2 endpoints entirely — explicitly out of scope here (NFR-058-01), since other unaudited consumers may still exist. diff --git a/docs/specs/4-architecture/features/058-album-listing-v3-adoption/spec.md b/docs/specs/4-architecture/features/058-album-listing-v3-adoption/spec.md index 8a9409ee5a6..76c80e69a28 100644 --- a/docs/specs/4-architecture/features/058-album-listing-v3-adoption/spec.md +++ b/docs/specs/4-architecture/features/058-album-listing-v3-adoption/spec.md @@ -15,18 +15,28 @@ Feature 057 built `GET /api/v3/Albums` but deliberately left every existing v2 consumer untouched. This feature switches the three existing v2 album-listing consumers over to it, **entirely behind one new combined `.env`-driven feature flag** so the cutover is reversible without a code change: -1. The Move-target picker (`resources/js/v8/components/forms/album/SearchTargetAlbum.vue`, shared by `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue`) — today calls `GET /api/v2/Album::getTargetListAlbums` (`Actions\Album\ListAlbums`), which fully hydrates Eloquent models, builds the tree server-side, computes breadcrumb strings, and resolves a thumbnail URL. Moves to the v3 default mode; tree/breadcrumb/subtree-exclusion move client-side; the thumbnail is preserved via the separate Feature 056 v3 Asset endpoint (not dropped) using the `cover_id` Feature 057 already added (Q-057-05). +1. The Move-target picker (`resources/js/v8/components/forms/album/SearchTargetAlbum.vue`, shared by `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue`) — today calls `GET /api/v2/Album::getTargetListAlbums` (`Actions\Album\ListAlbums`), which fully hydrates Eloquent models, builds the tree server-side, computes breadcrumb strings, and resolves a thumbnail URL. Moves to the v3 default mode; tree/breadcrumb/subtree-exclusion move client-side via a new shared store; the thumbnail is preserved via a new reusable `` component that queries the separate Feature 056 v3 Asset endpoint (not dropped) using the `cover_id` Feature 057 already added (Q-057-05). 2. The admin Fix Tree page (`resources/js/v8/views/FixTree.vue`) — today calls `GET /Maintenance::fullTree`. Moves to `GET /api/v3/Albums?with_parent_id=true`; a thin adapter reshapes the SoA response back into the `AlbumTree[]` array-of-structs shape the existing WASM validity-checking module (`resources/js/v8/composables/album/treeOperations.ts`) already consumes, so that module itself is untouched. The write-back `POST /Maintenance::fullTree` is untouched. 3. The admin Bulk Album Edit page (`resources/js/v8/views/BulkAlbumEdit.vue`) — today calls paginated, searchable `GET /BulkAlbumEdit`. Moves to a single `GET /api/v3/Albums?for_bulk_edit=true` fetch; pagination (both its "numbered" and "infinite-scroll" modes), debounced search, and "select all matching" become client-side operations over the in-memory result. The write endpoints (`PATCH`/`POST ::setOwner`/`DELETE /BulkAlbumEdit`) are untouched. -All three consumers are gated by **one** combined flag (Q-058-02), following the existing `App\Http\Resources\Rights\ModulesRightsResource` pattern (`is_*_enabled` booleans exposed to the frontend init payload, e.g. `is_mod_webshop_enabled`) rather than inventing a new exposure mechanism. +All three consumers are gated by **one** combined flag (Q-058-02), following the existing `App\Http\Resources\Rights\ModulesRightsResource` pattern (`is_*_enabled` booleans exposed to the frontend init payload, e.g. `is_mod_webshop_enabled`) rather than inventing a new exposure mechanism. The flag is named `struct-of-array` / `STRUCT_OF_ARRAY_ENABLED` (Q-058-03) rather than album-listing-specific, because it is intended to also gate a future Photos SoA v3 endpoint — this feature only implements the albums side, but the flag's name and scope are chosen so that future work doesn't need a second flag. + +Beyond the pure data-source swap, this feature introduces two new pieces of shared frontend infrastructure that the three consumers (and their eight call sites) draw on rather than each reinventing: + +- **A shared album-list store** (`resources/js/stores/AlbumListState.ts`) that fetches `GET /api/v3/Albums` (base mode) once per session, caches the raw Struct-of-Arrays response, and exposes a `tree` computed property that reconstructs the album hierarchy purely from `_lft`/`_rgt` (a nested-set stack algorithm — not the admin-gated `parent_ids`, since this store must also serve non-admin move/merge dialogs). The same store also exposes a pure subtree-exclusion helper used to prevent cyclic parent/child relationships when moving or merging albums (Q-058-04). +- **A reusable `` component** that resolves a photo asset via the Feature 056 v3 Asset endpoint, caches the resolved image per `(album_id, photo_id, type)` so the same asset is never fetched twice in a session, and cancels its in-flight request if the component unmounts before the response arrives. ## Goals -- Add `'album-listing-v3' => (bool) env('ALBUM_LISTING_V3_ENABLED', false)` to `config/features.php`, following the exact existing pattern (e.g. `'webhook'`/`env('WEBHOOK_ENABLED', false)`). -- Expose it to the frontend as `ModulesRightsResource::$is_album_listing_v3_enabled`, mirroring `is_mod_webshop_enabled`'s existing `config('features.webshop')`-backed resolver. +- Add `'struct-of-array' => (bool) env('STRUCT_OF_ARRAY_ENABLED', false)` to `config/features.php`, following the exact existing pattern (e.g. `'webhook'`/`env('WEBHOOK_ENABLED', false)`). +- Expose it to the frontend as `ModulesRightsResource::$is_struct_of_array_enabled`, mirroring `is_mod_webshop_enabled`'s existing `config('features.webshop')`-backed resolver. - Migrate all three v2 consumers to v3 when the flag is on, with zero behavior change when it is off (default). -- Preserve thumbnails in the move-target picker via the Feature 056 v3 Asset endpoint, not drop them. +- Provide a shared, session-cached album-list store with a `_lft`/`_rgt`-derived tree, so the three consumers (and the four dialogs that embed the Move-target picker) fetch the base album list at most once. +- Provide a pure, multi-root subtree-exclusion helper on that store so that moving or merging one or more albums can never offer a target that would create a cyclic parent/child relationship. +- Provide a reusable `` component that renders a v3 asset with request de-duplication/caching and unmount-safe cancellation, and use it everywhere this feature needs a thumbnail (replacing a bare ``). +- Invalidate the shared album-list store on every identity transition — login (`LoginForm.vue`, `WebauthnModal.vue`), registration's auto-login (`RegisterPage.vue`), and logout (`LeftMenu.vue`) — mirroring the existing `AlbumService.clearCache()` convention already called from all four of those places, so a session that gains or loses album visibility is never served a list cached under the prior identity. +- Invalidate the shared album-list store after every regular-Album mutation that changes what `GET /api/v3/Albums` would return — not just move/merge, but delete, unlock, and visibility/protection-policy changes, plus Fix Tree's own repair save and server-folder import (which can add/remove albums) — so the picker never offers a stale target. +- Reproduce v2's synthetic "move to root" option (offered whenever the moving/merging album isn't already at the top level) and its breadcrumb-path text, so the flag-on picker has full functional and visual parity with today's v2 dropdown, not just thumbnail/exclusion parity. - Keep every migrated Vue component's external contract (props/events) unchanged, so no unrelated caller needs to change. - Leave every v2 backend route/controller fully functional and untouched — the flag only changes what the frontend calls. @@ -35,58 +45,85 @@ All three consumers are gated by **one** combined flag (Q-058-02), following the - **No backend v2 changes.** `Actions\Album\ListAlbums`, `Admin\Maintenance\FullTree::check()`, `Admin\BulkAlbumController::index()` are not modified, deprecated, or removed. They remain the flag-off path and continue serving any other consumer not covered by this feature's audit. - **No further changes to Feature 057's contract.** This feature consumes `GET /api/v3/Albums` (and the Feature 056 Asset endpoint) exactly as specified; if a genuine new gap is found, it is fixed in 057's spec, not worked around here. - **No per-consumer flags** (Q-058-02) — one combined flag only. +- **No Photos SoA endpoint or consumer.** `STRUCT_OF_ARRAY_ENABLED` is named generally so it can gate a future Photos SoA v3 endpoint, but this feature implements and gates only the albums side (Q-058-03); a photos equivalent is explicitly out of scope here. - **No server-side pagination/search reintroduced for Bulk Album Edit.** Its client-side full-list approach is an accepted trade-off (NFR-058-04), inherited from Feature 057's Q-057-04 "never paginate" resolution. +- **No backend change to `Actions\Album\Move`'s cycle handling.** The existing `NodeTrait::appendNode()` guard (throws `\LogicException` when the target is the moved node itself or one of its descendants) is left as-is; this feature's client-side exclusion (FR-058-04) is the UX-facing safety net, not a change to the server-side backstop (see plan.md Risks and Q-058-05). - **No automated frontend test suite is introduced** — this repo has none today (confirmed during Feature 049); verification is manual/browser-based, consistent with existing precedent (e.g. Feature 054). -- **No visual UI redesign.** The three surfaces look the same to the user; this feature only swaps their data source. +- **No visual UI redesign.** Fix Tree and Bulk Album Edit look the same to the user; the Move/Merge/Copy picker's overall layout is also unchanged, with two narrow, deliberate additions when the flag is on: breadcrumb text per option (Q-058-06) and, when applicable, a "move to root" option (both reproduce/complete v2 parity rather than introduce new design). This feature otherwise only swaps the three surfaces' underlying data source and thumbnail-loading mechanism. +- **v8 only — no v7 changes.** Every component this feature touches or reads for its `AlbumService.clearCache()`/`clearAlbums()` invalidation wiring (`SearchTargetAlbum.vue`, `FixTree.vue`, `BulkAlbumEdit.vue`, `LoginForm.vue`, `WebauthnModal.vue`, `RegisterPage.vue`, `LeftMenu.vue`, `AlbumDelete.vue`, `DeleteDialog.vue`, `Unlock.vue`, `AlbumVisibility.vue`, `ImportFromServer.vue`) has a `resources/js/v7/...` counterpart with the same filename and its own independent `AlbumService.clearCache()`/`clearAlbums()` call. None of those v7 files are read, modified, or wired into the new `AlbumListState` store — v7 keeps calling only the existing v2 `AlbumService` cache-clear, unchanged, consistent with this project's v8-migration convention (fork/replace in v8, never edit v7 in place). This is safe with no staleness risk: v7 never reads from `AlbumListState`, since only v8's rewired `SearchTargetAlbum.vue`/`FixTree.vue`/`BulkAlbumEdit.vue` consume it. ## Functional Requirements | ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | |----|-------------|--------------|-----------------|--------------|--------------------|--------| -| FR-058-01 | `config/features.php` gains `'album-listing-v3' => (bool) env('ALBUM_LISTING_V3_ENABLED', false)`. | `App\Assets\Features::active('album-listing-v3')` reflects the env var. | N/A. | N/A. | None. | User instruction. | -| FR-058-02 | `App\Http\Resources\Rights\ModulesRightsResource` gains `public bool $is_album_listing_v3_enabled`, resolved by a new private `isAlbumListingV3Enabled(): bool` returning `config('features.album-listing-v3') === true` (no DB config layer, no auth-gating — a pure client-behavior switch). | Frontend init payload's `modules.is_album_listing_v3_enabled` matches the env var. | N/A. | N/A. | None. | `ModulesRightsResource` existing pattern (`app/Http/Resources/Rights/ModulesRightsResource.php:210-243`). | -| FR-058-03 | When `modules.is_album_listing_v3_enabled === true`, `SearchTargetAlbum.vue` fetches `GET /api/v3/Albums` (new `AlbumListV3Service`) instead of `AlbumService.getTargetListAlbums()`; when `false`, behavior is byte-for-byte unchanged from today. | Dropdown lists the same albums as v2 would, correctly excluding the moving album's own subtree. | N/A (no new user input). | Empty curated list → picker shows the existing "no target" state (`error_no_target`), unchanged from today. | None. | Move-target picker consumer. | -| FR-058-04 | The v3 path builds album hierarchy/breadcrumb strings client-side from `_lft`/`_rgt` (new small TS helper — not the existing WASM module, see NFR-058-06), excludes the moving album's own subtree (mirrors `ListAlbums::do()`'s `_lft`/`_rgt` exclusion), and renders each row's thumbnail as `` (session-cookie-authenticated, same as existing v2 image serving — no signed-link params needed for a logged-in SPA request); falls back to the existing "no image" placeholder asset when `cover_id` is `null`. | Thumbnail renders identically to today for albums with a cover; placeholder renders for albums without one. | N/A. | A 403/404 from the Asset endpoint (e.g. race with a concurrent delete) falls back to the placeholder, not a broken image icon. | None. | Q-057-05; Feature 056 (`GET /api/v3/Asset/{album_id}/{photo_id}/{size_variant}`). | -| FR-058-05 | When the flag is on, `FixTree.vue` fetches `GET /api/v3/Albums?with_parent_id=true` instead of `GET /Maintenance::fullTree`; a new thin adapter transposes the SoA response into `AlbumTree[]` before handing it to `prepareAlbums()`/the WASM module, unchanged otherwise. `POST /Maintenance::fullTree` (save) is untouched. | Validity-check/repair results identical to today for the same data. | N/A. | N/A. | None. | Fix Tree consumer; `FullTree::check()` precedent. | -| FR-058-06 | When the flag is on, `BulkAlbumEdit.vue` fetches `GET /api/v3/Albums?for_bulk_edit=true` once instead of paginated `GET /BulkAlbumEdit` calls; `load(page)`, the debounced search handler, and "select all matching" (today's separate `GET /BulkAlbumEdit::ids`) are reimplemented as client-side operations (filter/slice) over the single in-memory result. Write endpoints unchanged. | Both "numbered" and "infinite-scroll" table modes, and search, behave identically to today from the user's perspective. | N/A. | N/A. | None. | Bulk Album Edit consumer. | -| FR-058-07 | All three consumers gate on the same single flag (`modules.is_album_listing_v3_enabled`) — no independent per-consumer flag exists. | Toggling the one env var switches all three together. | N/A. | N/A. | None. | Q-058-02. | +| FR-058-01 | `config/features.php` gains `'struct-of-array' => (bool) env('STRUCT_OF_ARRAY_ENABLED', false)`. | `App\Assets\Features::active('struct-of-array')` reflects the env var. | N/A. | N/A. | None. | User instruction; Q-058-03. | +| FR-058-02 | `App\Http\Resources\Rights\ModulesRightsResource` gains `public bool $is_struct_of_array_enabled`, resolved by a new private `isStructOfArrayEnabled(): bool` returning `config('features.struct-of-array') === true` (no DB config layer, no auth-gating — a pure client-behavior switch). | Frontend init payload's `modules.is_struct_of_array_enabled` matches the env var. | N/A. | N/A. | None. | `ModulesRightsResource` existing pattern (`app/Http/Resources/Rights/ModulesRightsResource.php:210-243`). | +| FR-058-03 | New Pinia store `resources/js/stores/AlbumListState.ts` (`useAlbumListStore`). `ensureLoaded()` calls `GET /api/v3/Albums` in **base mode only** (no `with_parent_id`/`for_bulk_edit` — those are admin-gated per FR-057-02/03 and this store must also serve non-admin move/merge dialogs), caches the raw `ids`/`titles`/`lft`/`rgt`/`cover_ids` arrays in store state, and is a no-op on a second call unless `invalidate()` was called first. | Repeated calls to `ensureLoaded()` across multiple mounted consumers in the same session issue at most one network request. | N/A. | A failed fetch leaves the store's `error` state set; consumers fall back to their existing empty/"no target" state. | None. | Q-058-04; DO-058-03. | +| FR-058-04 | The store exposes a computed `tree` — the album hierarchy reconstructed purely from `_lft`/`_rgt` via a nested-set stack algorithm (ascending `_lft` order; pop the stack while the current album's `_lft` exceeds the stack top's `_rgt`; the new stack top, if any, is the parent) — and three pure functions, all derived directly from the flat `lft`/`rgt` arrays (no tree walk needed): (1) `getExcludedTargetIds(rootIds: string[]): Set`, the union over every id in `rootIds` of that album's own id plus every descendant (whose `(lft, rgt)` range is strictly contained within it); `rootIds` accepts one id (single-album Move) or several (multi-album Merge) uniformly. (2) `isTopLevel(albumId: string): boolean` — true iff no other album's `(lft, rgt)` range strictly contains `albumId`'s (i.e. it has no ancestor among the visible set). (3) `buildBreadcrumb(albumId: string): string` — the full ancestor-chain path, ancestor titles joined by `/` then the album's own title appended, mirroring `ListAlbums::do()`'s `flatten()` prefixing (`title = prefix + '/' + node.title`) — full-path text only; the client relies on CSS truncation (e.g. this UI kit's existing `truncate` utility) for overflow rather than reproducing the server's byte-length proportional-shortening algorithm (`ListAlbums::shorten()`'s `SHORTEN_BY=80` logic) — a deliberate simplification, not algorithm-for-algorithm parity. | For any `rootIds`, `getExcludedTargetIds` makes every remaining album a legal move/merge target with no cyclic parent/child relationship possible; `isTopLevel`/`buildBreadcrumb` reproduce v2's root-option and breadcrumb behavior without needing the admin-gated `parent_ids`. | N/A (pure functions, no I/O). | Empty `rootIds` → empty exclusion set (no-op), not an error. | None. | Q-058-05; mirrors `ListAlbums::do()`'s single-root `_lft`/`_rgt` exclusion (`app/Actions/Album/ListAlbums.php:44-53`), generalized to N roots; `ListAlbums::do()`'s root-option/breadcrumb behavior (`app/Actions/Album/ListAlbums.php:69-119`, `AlbumController::getTargetListAlbums`). | +| FR-058-05 | New component `resources/js/v8/components/thumbs/Thumb.vue`, props `albumId: string`, `photoId: string \| null`, `type: App.Enum.SizeVariantAssetType` (default `'thumb'`). Fetches `GET /api/v3/Asset/{album_id}/{photo_id}/{size_variant}` as a blob via axios with an `AbortController` signal (mirrors `upload-service.ts`'s existing signal usage), caches the resolved object URL keyed by `` `${albumId}:${photoId}:${type}` `` in a module-level `Map` so the same asset is never re-fetched within the session, and aborts its own in-flight request in `onBeforeUnmount`. Renders the existing "no image" placeholder asset immediately (no request) when `photoId === null`, and falls back to the same placeholder on a 403/404/aborted response. | Thumbnail renders identically to today for albums with a cover; placeholder renders for albums without one; a second `` for the same `(album_id, photo_id, type)` renders instantly from cache. | N/A. | A 403/404 from the Asset endpoint (e.g. race with a concurrent delete) falls back to the placeholder, not a broken image icon. | None. | Q-057-05; Feature 056 (`GET /api/v3/Asset/{album_id}/{photo_id}/{size_variant}`); DO-058-05/06. | +| FR-058-06 | When `modules.is_struct_of_array_enabled === true`, `SearchTargetAlbum.vue` calls the shared store's `ensureLoaded()` (FR-058-03) instead of `AlbumService.getTargetListAlbums()`; filters the store's flat album list by `getExcludedTargetIds(props.albumIds ?? [])` (FR-058-04); when `props.albumIds` is non-empty and `!isTopLevel(props.albumIds[0])` (mirroring `AlbumController::getTargetListAlbums`'s existing `$parent_id = $albums->first()->parent_id` rule — v2 already only inspects the *first* selected album even for a multi-select merge, so this reproduces that exact precedent rather than inventing new multi-root root-option logic), prepends a synthetic `{id: null, breadcrumb: trans('gallery.root')}` "move to root" row, exactly as `ListAlbums::do()` does server-side; omitted entirely when `props.albumIds` is empty/undefined (photo move/copy — no root row today, unchanged); builds each option's breadcrumb label via `buildBreadcrumb` (FR-058-04) instead of the plain title, rendered through an `#item-label` slot override (the flag-off/v2 branch keeps its existing `label-key="original"` binding unchanged, per NFR-058-02); renders each row's thumbnail via `` (FR-058-05), with the synthetic root row rendering the placeholder (`photo-id: null`). When `false`, behavior is byte-for-byte unchanged from today. | Dropdown lists the same albums as v2 would, correctly excluding the moving/merging album(s)' own subtree(s), offering "move to root" exactly when v2 would, and disambiguating same-titled albums via breadcrumb text (a new, real UX capability — today's v8 picker does not render breadcrumbs at all, see NFR-058-03). | N/A (no new user input). | Empty curated list → picker shows the existing "no target" state (`error_no_target`), unchanged from today. | None. | Move-target picker consumer; Q-058-04/05. | +| FR-058-07 | When the flag is on, `FixTree.vue` fetches `GET /api/v3/Albums?with_parent_id=true` (its own separate, admin-gated request — **not** the shared store from FR-058-03, which never requests `parent_ids`) instead of `GET /Maintenance::fullTree`; a new thin adapter transposes the SoA response into `AlbumTree[]` before handing it to `prepareAlbums()`/the WASM module, unchanged otherwise. `POST /Maintenance::fullTree` (save) is untouched. | Validity-check/repair results identical to today for the same data. | N/A. | N/A. | None. | Fix Tree consumer; `FullTree::check()` precedent. | +| FR-058-08 | When the flag is on, `BulkAlbumEdit.vue` fetches `GET /api/v3/Albums?for_bulk_edit=true` once (also its own separate, admin-gated request, not the shared store) instead of paginated `GET /BulkAlbumEdit` calls; `load(page)`, the debounced search handler, and "select all matching" (today's separate `GET /BulkAlbumEdit::ids`) are reimplemented as client-side operations (filter/slice) over the single in-memory result. Write endpoints unchanged. | Both "numbered" and "infinite-scroll" table modes, and search, behave identically to today from the user's perspective. | N/A. | N/A. | None. | Bulk Album Edit consumer. | +| FR-058-09 | All three consumers gate on the same single flag (`modules.is_struct_of_array_enabled`) — no independent per-consumer flag exists. | Toggling the one env var switches all three together. | N/A. | N/A. | None. | Q-058-02. | +| FR-058-10 | Album Merge's existing `SearchTargetAlbum` call site (`AlbumMergeDialog.vue`) already passes its full `albumIds: string[]` (one or more source albums) through the unchanged `album-ids` prop (NFR-058-02) — no dialog-specific special-casing is added; the multi-root support already required by FR-058-04/06 covers the merge case natively, since `getExcludedTargetIds` accepts any number of root ids. | Merging 2+ albums simultaneously excludes all of them, and all of their descendants, from the target list. | N/A. | N/A. | None. | User instruction (cyclic-dependency safety for merge). | +| FR-058-11 | The shared store's `invalidate()` (FR-058-03) is called from every existing identity-transition call site that already calls `AlbumService.clearCache()`: `LoginForm.vue`'s `login()` success handler (`:125`), `WebauthnModal.vue`'s `login()` success handler (`:65` — a second, independent login entry point, e.g. passkey login from the same modal group as `LoginForm.vue` but not nested inside it), `RegisterPage.vue`'s registration success handler (`:125` — registration auto-logs the new user in), and `LeftMenu.vue`'s `logout()` handler (`:170`, alongside its existing `*Store.reset()` calls) — a login, registration, or logout changes which albums `AlbumQueryPolicy::applyVisibilityFilter()` returns for the caller, so a list cached under the prior identity must never be served under the new one. | Store re-fetches on the next `ensureLoaded()` call after any of the four transitions. | N/A. | N/A. | None. | User instruction ("clear the AlbumListState [on login] because we will gain access to more"); existing `AlbumService.clearCache()` login/registration/logout convention (4 call sites). | +| FR-058-12 | The shared store's `invalidate()` (FR-058-03) is also called from every existing regular-Album-mutation call site whose action changes a field `GET /api/v3/Albums` returns (`id`/`title`/`lft`/`rgt`/`cover_id`) beyond the move/merge dialogs already covered by NFR-058-09: `AlbumDelete.vue:67`, `DeleteDialog.vue:105` (its album-delete path only), `Unlock.vue:48`, `AlbumVisibility.vue:149`, `FixTree.vue:156` (after `MaintenanceService.updateFullTree()`'s save — the repair write-back itself is untouched per FR-058-07, but its completion must invalidate this feature's own shared store), and `ImportFromServer.vue:170` (server-folder import can create or, via `delete_missing_albums`, remove regular albums). Deliberately **excluded**, with each call site's own `AlbumService.clearCache()`/`clearAlbums()` call left as-is: `AlbumPanel.vue:419`/`Albums.vue:229` (`togglePin` — pin status isn't a field this store tracks), `AlbumCreatePersonDialog.vue:75`/`AlbumCreateTagDialog.vue:75`/`TagRenameDialog.vue:57`/`TagDeleteDialog.vue:63`/`TagPanel.vue:93,98`/`TagMergeDialog.vue:58` (all operate on `TagAlbum`/`PersonAlbum` models, not the regular `Album` rows `GET /api/v3/Albums` queries — confirmed against FR-057-01's `albums._lft`-ordered query and `FullTree::check()`'s Album-table-only precedent), and `Search.vue`/`Timeline.vue`'s star/unstar callbacks (photo highlight state, not any SoA-tracked album field). | Store re-fetches on the next `ensureLoaded()` after any of the six included mutations; unaffected by any of the explicitly-excluded ones. | N/A. | N/A. | None. | User instruction (widen the invalidation net to regular-Album mutations); audit of all `AlbumService.clearCache()`/`clearAlbums()` call sites in `resources/js/v8`. | ## Non-Functional Requirements | ID | Requirement | Driver | Measurement | Dependencies | Source | |----|-------------|--------|-------------|--------------|--------| -| NFR-058-01 | No v2 backend route/controller is modified, deprecated, or removed. | Flag-off must be a true no-op; other unaudited consumers of the v2 routes must keep working. | `git diff` shows no changes under `app/Http/Controllers/Gallery/AlbumController.php` (`getTargetListAlbums`), `app/Http/Controllers/Admin/Maintenance/FullTree.php`, `app/Http/Controllers/Admin/BulkAlbumController.php`, or `routes/api_v2.php`. | Feature 057 (already built, untouched by that feature too). | Non-Goals. | +| NFR-058-01 | No v2 backend route/controller is modified, deprecated, or removed, and no `resources/js/v7/...` file is touched. | Flag-off must be a true no-op; other unaudited consumers of the v2 routes must keep working; v7 is legacy and out of scope for this feature (Non-Goals). | `git diff` shows no changes under `app/Http/Controllers/Gallery/AlbumController.php` (`getTargetListAlbums`), `app/Http/Controllers/Admin/Maintenance/FullTree.php`, `app/Http/Controllers/Admin/BulkAlbumController.php`, `routes/api_v2.php`, or anywhere under `resources/js/v7/`. | Feature 057 (already built, untouched by that feature too). | Non-Goals. | | NFR-058-02 | `SearchTargetAlbum.vue`'s external prop/event contract (`album-ids` prop; `selected`/`no-target` events) is unchanged. | So `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue` require zero changes. | Manual verification: exercise all 4 call sites with the flag on and off. | `resources/js/v8/components/forms/album/SearchTargetAlbum.vue`. | Goals. | -| NFR-058-03 | Client-side tree-building/exclusion for the move-picker (FR-058-04) produces results identical to today's server-side `ListAlbums::do()` for the same fixture data. | Regression safety for a security-relevant behavior (can't move an album into its own descendant). | Manual side-by-side comparison, flag on vs. off, same fixture album set. | `Actions\Album\ListAlbums::do()` (`app/Actions/Album/ListAlbums.php`). | User instruction (no behavior regressions). | +| NFR-058-03 | Client-side tree-building/exclusion for the move-picker (FR-058-04) produces results identical to today's server-side `ListAlbums::do()` for the same fixture data in the single-root case, and — since no server precedent exists for the multi-root (merge) case — is reasoned from first principles and manually verified against a fixture with a selected parent/child pair and a selected pair of unrelated albums. Separately, the "move to root" option and breadcrumb text (FR-058-04/06) are verified for **behavioral** parity with `ListAlbums::do()`/`flatten()` (same root-option trigger condition, same full breadcrumb path text) — **not** pixel-for-pixel `short_title` truncation parity, since the client deliberately uses CSS truncation instead of reproducing `ListAlbums::shorten()`'s algorithm (documented simplification, FR-058-04). Note also that today's v8 `SearchTargetAlbum.vue` doesn't render breadcrumb text at all (`label-key="original"`, no use of `title`/`short_title`) — so this is new rendered behavior for v8, not a restoration of an existing v8 display. | Regression safety for a security-relevant behavior (can't move or merge an album into its own descendant, or into a co-selected album's subtree), plus full functional/visual parity with v2's dropdown (not just thumbnail/exclusion parity). | Manual side-by-side comparison, flag on vs. off, same fixture album set; dedicated multi-root fixture for the merge case; dedicated fixture with a root-level album and a nested album to exercise the "move to root" option's presence/absence; dedicated fixture with two same-titled albums under different parents to exercise breadcrumb disambiguation. | `Actions\Album\ListAlbums::do()`/`flatten()`/`shorten()` (`app/Actions/Album/ListAlbums.php`); `NodeTrait::appendNode()`'s `\LogicException` guard (`vendor/lychee-org/nestedset/src/NodeTrait.php:1199-1200`) as the documented server-side backstop (see plan.md Risks). | User instruction (no behavior regressions; "Add breadcrumb display" decision); Q-058-05. | | NFR-058-04 | Bulk Album Edit's full-list-in-memory approach is an accepted scale trade-off; no server-side pagination is reintroduced. | Inherits Feature 057's Q-057-04 "never paginate" resolution. | Documented here and in Follow-ups; not solved in this feature. | Q-057-04. | Q-058-01 resolution. | | NFR-058-05 | `npm run format`/`npm run check` clean on all changed frontend files; `make phpstan`/`vendor/bin/php-cs-fixer fix` clean on the small backend flag-exposure change. | Repo quality gate. | Run both. | AGENTS.md. | AGENTS.md. | -| NFR-058-06 | The move-picker's tree/breadcrumb construction uses a new, small, purpose-built TS helper — not the existing WASM `treeOperations.ts` module. | That module is purpose-built for Fix Tree's heavier validity-checking/repair workflow; routing a simple breadcrumb transform through it would be unnecessary coupling and overhead. | Code review: no new import of `treeOperations.ts`'s WASM bindings from the move-picker's code path. | `resources/js/v8/composables/album/treeOperations.ts`. | Design decision (this plan). | +| NFR-058-06 | Neither the shared store's `tree` computed (FR-058-04) nor the move-picker's breadcrumb construction (FR-058-06) uses the existing WASM `treeOperations.ts` module. | That module is purpose-built for Fix Tree's heavier validity-checking/repair workflow; routing a simple tree reconstruction/breadcrumb transform through it would be unnecessary coupling and overhead. | Code review: no new import of `treeOperations.ts`'s WASM bindings from the store or the move-picker's code path. | `resources/js/v8/composables/album/treeOperations.ts`. | Design decision (this plan). | +| NFR-058-07 | The shared store (FR-058-03) de-duplicates concurrent/sequential `ensureLoaded()` calls: mounting the Move dialog, then closing it and opening the Merge dialog, then `PhotoCopyDialog`, all within one session, issues at most one `GET /api/v3/Albums` request, verified via the browser Network panel. | Avoids re-fetching the same curated list for every dialog open, the whole point of a shared store. | Manual DevTools Network-panel check, S-058-10. | FR-058-03. | User instruction ("saves the global list of albums when fetched"). | +| NFR-058-08 | `` (FR-058-05) aborts its underlying HTTP request — not merely its own state update — when unmounted before the response arrives. | Avoids wasted bandwidth/server load from thumbnails for rows the user scrolled past or a dialog closed before rendering finished. | Manual DevTools Network-panel check: request shows as `(canceled)`, S-058-12. | FR-058-05. | User instruction ("cancel the request if unmounted"). | +| NFR-058-09 | The shared store's cached list is invalidated (re-fetch on next `ensureLoaded()`) after any of the regular-Album mutations listed in FR-058-12 — move, merge, delete, unlock, visibility change, Fix Tree save, server-folder import — mirroring the existing `AlbumService.clearCache()`/`clearAlbums()` convention already called at each of those exact sites today. | Prevents the shared list from going stale mid-session after the user performs an action that changes what it should return. | Manual verification per mutation: perform the action, reopen a Move/Merge dialog, confirm the store reflects the change (S-058-11/19..23). | `resources/js/services/album-service.ts` (`clearCache`/`clearAlbums`, existing convention); FR-058-12. | User instruction ("saves the global list ... when fetched", implies staying correct across mutations; widened to all regular-Album mutations on follow-up). | +| NFR-058-10 | The shared store never serves a list fetched under a different auth identity than the current one — a cached pre-login (guest or less-privileged) list must not under-serve a freshly-logged-in or freshly-registered user's actual visibility, and a cached pre-logout list must not linger and over-serve (or leak titles from) the prior identity after logout. | `AlbumQueryPolicy::applyVisibilityFilter()`'s result set is identity-dependent; the shared store's whole purpose (session-wide caching) is unsafe unless it tracks identity transitions. | Manual: as a guest (or lower-privileged user), open a dialog that populates the store, then log in (password or WebAuthn) as a user with visibility into more albums — reopening the picker shows the newly-visible albums (S-058-14); same check after registering a new account (S-058-24); separately, log out after the store is populated and confirm the next dialog open re-fetches rather than reusing the prior identity's cached list (S-058-15). | FR-058-11. | User instruction. | ## UI / Interaction Mock-ups -No visual change — all three surfaces render identically with the flag on or off; this feature swaps only their underlying data source. See [docs/specs/4-architecture/spec-guidelines/ui-ascii-mockups.md](../../spec-guidelines/ui-ascii-mockups.md); no new mock-up is meaningful here since the existing UI for these three pages is unchanged. +Fix Tree and Bulk Album Edit's layout is unchanged with the flag on or off. The Move/Merge/Copy picker's overall layout (dropdown, thumbnail slot) is also unchanged, with three visible differences when the flag is on: (1) the thumbnail, previously a bare ``, is now rendered by `` — visually identical once loaded, but may show a brief blank/placeholder state while its request is in flight, matching how any other lazily-loaded thumbnail in this app already behaves; (2) each option's label shows a breadcrumb path (e.g. `Vacations / 2024 / Beach`) rather than just the album's own title — new for v8 (Q-058-06); (3) a "move to root" option appears when applicable, restoring existing v2 functionality the flag-off path already has. See [docs/specs/4-architecture/spec-guidelines/ui-ascii-mockups.md](../../spec-guidelines/ui-ascii-mockups.md); no new mock-up is meaningful here. ## Branch & Scenario Matrix | Scenario ID | Description / Expected outcome | |-------------|--------------------------------| | S-058-01 | Flag off (default): all three consumers behave exactly as today (regression guard). | -| S-058-02 | Flag on, Move dialog: picker lists the correct curated albums via v3, correct thumbnail via the v3 Asset endpoint, correct exclusion of the moving album's own subtree, correct breadcrumb text. | -| S-058-03 | Flag on, Move dialog, an album with `cover_id === null`: placeholder image shown, no broken ``/failed request. | -| S-058-04 | Flag on, Fix Tree page: same validity-check/repair behavior as v2; WASM operates identically on the SoA→AoS-adapted data. | -| S-058-05 | Flag on, Bulk Album Edit, "numbered" pagination mode: correct page slices from the in-memory list. | +| S-058-02 | Flag on, Move dialog (single album): picker lists the correct curated albums via the shared store, correct thumbnail via ``, correct exclusion of the moving album's own subtree, correct breadcrumb text. | +| S-058-03 | Flag on, Move dialog, an album with `cover_id === null`: placeholder image shown via ``, no broken ``/failed request. | +| S-058-04 | Flag on, Fix Tree page: same validity-check/repair behavior as v2; WASM operates identically on the SoA→AoS-adapted data (fetched separately from the shared store, with `parent_ids`). | +| S-058-05 | Flag on, Bulk Album Edit, "numbered" pagination mode: correct page slices from the in-memory list (fetched separately from the shared store, with bulk-edit fields). | | S-058-06 | Flag on, Bulk Album Edit, "infinite-scroll" mode: correct incremental reveal from the in-memory list. | | S-058-07 | Flag on, Bulk Album Edit, search box: correct client-side filtered results, matching v2's title-substring search semantics. | | S-058-08 | Flag on, Bulk Album Edit, "select all matching": selects the correct filtered set from the in-memory list, no separate `::ids` call. | -| S-058-09 | Toggling `ALBUM_LISTING_V3_ENABLED` (pure `.env`/config change, no code deploy) flips behavior on the next request — confirms no build-time-only wiring. | +| S-058-09 | Toggling `STRUCT_OF_ARRAY_ENABLED` (pure `.env`/config change, no code deploy) flips behavior on the next request — confirms no build-time-only wiring. | +| S-058-10 | Flag on: opening the Move dialog, closing it, then opening the Merge dialog (or Photo Copy dialog) in the same session issues at most one `GET /api/v3/Albums` network request total (shared store de-dup, NFR-058-07). | +| S-058-11 | Flag on, Merge dialog, 3 albums selected where one is an ancestor of another among the selected set: target picker excludes all 3 selected albums and every descendant of each — no selectable target can create a cycle (multi-root exclusion, FR-058-04/10). | +| S-058-12 | Flag on, Move dialog opened then immediately closed while a row's `` request is still in flight: DevTools Network panel shows that request as canceled, not merely its result discarded (NFR-058-08). | +| S-058-13 | Flag on, the same `(album_id, photo_id, type)` is rendered by `` twice in the same session (e.g. dialog reopened): the second render shows the image immediately from cache, no second network request (NFR-058-07-adjacent, cache verification). | +| S-058-14 | Flag on: as a guest (or a less-privileged user), open a dialog that populates the shared store, then log in as a user with visibility into more albums — reopening the picker shows the newly-visible albums, not the stale pre-login set (FR-058-11, NFR-058-10). | +| S-058-15 | Flag on: with the shared store populated, log out — the next dialog open (post-redirect, or pre-redirect if that ever changes) re-fetches under the new (guest) identity rather than reusing the prior user's cached list (FR-058-11, NFR-058-10). | +| S-058-16 | Flag on, Move/Merge dialog, the moving/first-selected album currently has a parent (not already at gallery root): picker includes a "move to root" option; selecting and confirming it moves the album to root, matching v2 (FR-058-04/06). | +| S-058-17 | Flag on, Move/Merge dialog, the moving/first-selected album is already at the gallery root: no "move to root" option is shown, matching v2 (FR-058-04/06). | +| S-058-18 | Flag on, two albums with the same title exist under different parents and both are valid targets: their breadcrumb text (not just plain title) disambiguates them in the dropdown — new v8 UX capability, not present today (FR-058-06, NFR-058-03). | +| S-058-19 | Flag on: delete an album (via `AlbumDelete.vue` or `DeleteDialog.vue`), then reopen a Move/Merge dialog — the deleted album no longer appears as a target (FR-058-12). | +| S-058-20 | Flag on: unlock a password-protected album, then reopen a Move/Merge dialog — the newly-unlocked album (and any now-visible descendants) appears as a target (FR-058-12). | +| S-058-21 | Flag on: change an album's visibility/protection policy, then reopen the picker — the change is reflected (FR-058-12). | +| S-058-22 | Flag on: run Fix Tree's repair/save, then open the Move dialog — the picker reflects the repaired `_lft`/`_rgt` values, not the pre-repair ones (FR-058-12). | +| S-058-23 | Flag on: import photos from a server folder (creating new albums), then open the picker — the newly-imported albums appear as valid targets (FR-058-12). | +| S-058-24 | Flag on: register a new account (auto-login), then open the picker — reflects the new account's visibility, not a stale guest-scoped list (FR-058-11, NFR-058-10). | ## Test Strategy - **Core:** N/A. - **Application:** N/A — no new backend logic beyond the flag/resolver (covered by a small Feature test on `ModulesRightsResource`'s init payload). -- **REST:** Feature test asserting `modules.is_album_listing_v3_enabled` reflects `config('features.album-listing-v3')` (both `true`/`false`). +- **REST:** Feature test asserting `modules.is_struct_of_array_enabled` reflects `config('features.struct-of-array')` (both `true`/`false`). - **CLI:** N/A. -- **UI (JS/Selenium):** No automated frontend suite exists in this repo (confirmed, Feature 049). Manual/browser-based verification for S-058-01..09, per Feature 054's precedent (ad hoc Playwright/Chromium session if available, or direct browser click-through), covering both flag states. +- **UI (JS/Selenium):** No automated frontend suite exists in this repo (confirmed, Feature 049). Manual/browser-based verification for S-058-01..24, per Feature 054's precedent (ad hoc Playwright/Chromium session if available, or direct browser click-through plus DevTools Network-panel inspection for S-058-10/12/13, a real login/registration/logout cycle for S-058-14/15/24, and one pass per mutation type for S-058-19..23), covering both flag states. - **Docs/Contracts:** `docs/specs/3-reference/api-design.md` — note the flag-gated v3 adoption alongside the existing v3 endpoint entries. ## Interface & Contract Catalogue @@ -95,10 +132,12 @@ No visual change — all three surfaces render identically with the flag on or o | ID | Description | Modules | |----|-------------|---------| -| DO-058-01 | `App\Http\Resources\Rights\ModulesRightsResource` gains `public bool $is_album_listing_v3_enabled` (TypeScript-exported, existing `#[TypeScript]` class). | REST, UI | -| DO-058-02 | New `resources/js/services/album-list-v3-service.ts` — `AlbumListV3Service.getAlbums(params: {with_parent_id?: boolean; for_bulk_edit?: boolean}): Promise>`, calling `GET /api/v3/Albums`. | UI | -| DO-058-03 | New TS helper (e.g. `resources/js/v8/composables/album/buildAlbumTreeFromFlatList.ts`) — pure function: `(rows: {id, title, lft, rgt, cover_id}[]) => tree with breadcrumb paths`, plus a subtree-exclusion function mirroring `ListAlbums::do()`. | UI | +| DO-058-01 | `App\Http\Resources\Rights\ModulesRightsResource` gains `public bool $is_struct_of_array_enabled` (TypeScript-exported, existing `#[TypeScript]` class). | REST, UI | +| DO-058-02 | New `resources/js/services/album-list-v3-service.ts` — `AlbumListV3Service.getAlbums(params: {with_parent_id?: boolean; for_bulk_edit?: boolean}): Promise>`, calling `GET /api/v3/Albums`. Used directly by `FixTree.vue`/`BulkAlbumEdit.vue` (their own admin-gated params) and internally by DO-058-03's store (base params only). | UI | +| DO-058-03 | New Pinia store `resources/js/stores/AlbumListState.ts` (`useAlbumListStore`) — state: raw base-mode `AlbumListResource` (`ids`/`titles`/`lft`/`rgt`/`cover_ids`), `isLoading`, `error`; actions: `ensureLoaded()`, `invalidate()`; getters: `tree` (nested-set stack reconstruction from `_lft`/`_rgt`, `{id, title, cover_id, lft, rgt, depth, children}[]`), `getExcludedTargetIds(rootIds: string[]): Set` (pure `lft`/`rgt` range-containment union over one or more roots), `isTopLevel(albumId: string): boolean`, `buildBreadcrumb(albumId: string): string` (both also pure `lft`/`rgt` derivations, FR-058-04). The latter three are Pinia "getter methods" (a getter returning a function of its argument, since a plain Pinia getter takes no parameters) — an established Pinia pattern, called as e.g. `store.getExcludedTargetIds(ids)`, not `store.getExcludedTargetIds`. Follows the existing `defineStore("id", {state, getters, actions})` Options-API convention (`resources/js/stores/AlbumsState.ts` precedent). | UI | | DO-058-04 | New thin adapter (e.g. in `FixTree.vue` or a small composable) — `AlbumListResource` (SoA, with `parent_ids`) → `AlbumTree[]` (AoS), feeding the existing `prepareAlbums()`/WASM pipeline unchanged. | UI | +| DO-058-05 | New component `resources/js/v8/components/thumbs/Thumb.vue` — props `albumId: string`, `photoId: string \| null`, `type?: App.Enum.SizeVariantAssetType` (default `'thumb'`); no emitted events; renders an `` bound to a resolved object URL or the placeholder asset. | UI | +| DO-058-06 | New `resources/js/services/thumb-asset-service.ts` — `ThumbAssetService.getObjectUrl(albumId: string, photoId: string, type: App.Enum.SizeVariantAssetType, signal: AbortSignal): Promise` backing `` (DO-058-05): fetches `GET /api/v3/Asset/{album_id}/{photo_id}/{type}` as a blob via axios (`responseType: 'blob'`, `signal`), converts to an object URL via `URL.createObjectURL`, and memoizes in-flight/resolved promises in a module-level `Map` keyed by `` `${albumId}:${photoId}:${type}` `` so concurrent/repeated callers share one request and one object URL. | UI | ### API Routes / Services @@ -120,10 +159,15 @@ Reuses existing Feature_v2/v3 fixtures; no new fixture files. | ID | State | Trigger / Expected outcome | |----|-------|---------------------------| -| UI-058-01 | Move-picker, flag on, cover present | `` shows the album's resolved cover via the v3 Asset endpoint. | -| UI-058-02 | Move-picker, flag on, cover absent | Existing "no image" placeholder asset shown. | +| UI-058-01 | Move-picker, flag on, cover present | `` shows the album's resolved cover via the v3 Asset endpoint. | +| UI-058-02 | Move-picker, flag on, cover absent (`cover_id === null`) | `` shows the existing "no image" placeholder asset immediately, no request issued. | | UI-058-03 | Bulk Album Edit, flag on, numbered mode | Pagination controls operate over the in-memory list, same visual behavior as today. | | UI-058-04 | Bulk Album Edit, flag on, infinite-scroll mode | Sentinel-row intersection reveals more in-memory rows, same visual behavior as today. | +| UI-058-05 | ``, request in flight | Blank/placeholder state until the blob resolves (no explicit spinner beyond what today's picker already shows while `options === undefined`). | +| UI-058-06 | ``, request fails (403/404) | Falls back to the "no image" placeholder asset, same as the no-cover case. | +| UI-058-07 | Merge dialog, flag on, 3+ albums selected including an ancestor/descendant pair | Target picker excludes all selected albums and all of their descendants (FR-058-04/10). | +| UI-058-08 | Move/Merge dialog, flag on, moving album not already at root | A "move to root" option is offered, matching v2 (FR-058-04/06). | +| UI-058-09 | Move/Merge dialog, flag on | Each option's label shows its breadcrumb path, not just its own title — new v8 UX capability (FR-058-06). | ## Telemetry & Observability @@ -132,7 +176,7 @@ No new telemetry events. ## Documentation Deliverables - `docs/specs/3-reference/api-design.md` — note the flag-gated frontend adoption. -- `docs/specs/4-architecture/knowledge-map.md` — reference the new `album-list-v3-service.ts`, the tree-building helper, and the `ModulesRightsResource` addition. +- `docs/specs/4-architecture/knowledge-map.md` — reference the new `album-list-v3-service.ts`, the `AlbumListState` store, `Thumb.vue`/`thumb-asset-service.ts`, and the `ModulesRightsResource` addition. - `docs/specs/4-architecture/roadmap.md` — Feature 058 entry. ## Fixtures & Sample Data @@ -144,37 +188,73 @@ None new. ``` domain_objects: - id: DO-058-01 - name: ModulesRightsResource.is_album_listing_v3_enabled + name: ModulesRightsResource.is_struct_of_array_enabled fields: - - name: is_album_listing_v3_enabled + - name: is_struct_of_array_enabled type: bool - id: DO-058-02 name: AlbumListV3Service - id: DO-058-03 - name: buildAlbumTreeFromFlatList + name: AlbumListState (Pinia store: tree + getExcludedTargetIds) - id: DO-058-04 name: SoA-to-AlbumTree adapter + - id: DO-058-05 + name: Thumb.vue + - id: DO-058-06 + name: ThumbAssetService routes: [] fixtures: [] ui_states: - id: UI-058-01 - description: Move-picker thumbnail via v3 Asset endpoint + description: Move-picker thumbnail via Thumb/v3 Asset endpoint - id: UI-058-02 description: Move-picker placeholder when no cover - id: UI-058-03 description: Bulk Album Edit numbered pagination over in-memory list - id: UI-058-04 description: Bulk Album Edit infinite-scroll over in-memory list + - id: UI-058-05 + description: Thumb request in flight + - id: UI-058-06 + description: Thumb request failure fallback + - id: UI-058-07 + description: Merge dialog multi-root cyclic exclusion + - id: UI-058-08 + description: Move/Merge dialog "move to root" option + - id: UI-058-09 + description: Move/Merge dialog breadcrumb label ``` ## Appendix -### Decision Cards (Q-058-01..02) +### Decision Cards (Q-058-01..07) #### Q-058-01 — Migration scope & approach -**Resolved: all three consumers, with client-side compensation for pagination/search/breadcrumb/exclusion — and thumbnails preserved, not dropped**, via the Feature 056 v3 Asset endpoint paired with the `cover_id` field Feature 057 was amended to expose (Q-057-05). This was a direct user correction of the initially-proposed "drop thumbnails" default — the user pointed out the v3 Asset endpoint already exists and should be used instead of accepting a UX regression. +**Resolved: all three consumers, with client-side compensation for pagination/search/breadcrumb/exclusion — and thumbnails preserved, not dropped**, via a reusable `` component (FR-058-05) wrapping the Feature 056 v3 Asset endpoint, paired with the `cover_id` field Feature 057 was amended to expose (Q-057-05). This was a direct user correction of the initially-proposed "drop thumbnails" default — the user pointed out the v3 Asset endpoint already exists and should be used instead of accepting a UX regression. + +**Addendum (2026-08-22):** a spec review found that today's v8 `SearchTargetAlbum.vue` doesn't actually render breadcrumb text at all (`label-key="original"`, plain title only) — so "breadcrumb" compensation here is new v8 UX, not a restoration. Asked explicitly whether to drop that scope or build it: **resolved to build it** (FR-058-04/06, Q-058-06). #### Q-058-02 — Feature-flag granularity -**Resolved: Option A — one combined flag**, `album-listing-v3` in `config/features.php` / `ALBUM_LISTING_V3_ENABLED` in `.env`, gating all three consumers together via `ModulesRightsResource::$is_album_listing_v3_enabled`. +**Resolved: Option A — one combined flag**, gating all three consumers together via `ModulesRightsResource::$is_struct_of_array_enabled` (renamed per Q-058-03). + +#### Q-058-03 — Flag naming/scope + +**Resolved:** the `.env` variable is `STRUCT_OF_ARRAY_ENABLED` (config key `'struct-of-array'`, resource field `is_struct_of_array_enabled`) rather than the originally-drafted `ALBUM_LISTING_V3_ENABLED`/`'album-listing-v3'`/`is_album_listing_v3_enabled`. Direct user correction: the flag is intended to also gate a future Photos SoA v3 endpoint, so it should be named after the response-shape convention (ADR-0009) it toggles, not after this specific feature. Feature 058 itself still only builds and gates the albums side (Non-Goals); a photos consumer is future work under the same flag. + +#### Q-058-04 — Where the shared album list lives and how its tree is computed + +**Resolved:** a Pinia store (`AlbumListState.ts`, DO-058-03), matching this codebase's existing `resources/js/stores/*State.ts` convention, rather than a bare composable — because the requirement is a **shared, cached, cross-component** list (four dialog call sites plus potentially more later), which is exactly Pinia's role here (c.f. `AlbumsState.ts`, `AlbumState.ts`). The tree is computed purely from `_lft`/`_rgt` via a nested-set stack reconstruction, deliberately **not** from `parent_ids` — `with_parent_id=true` is admin-gated (FR-057-02, 403 for non-admins), and this store must also serve the Move/Merge/Copy dialogs for ordinary, non-admin users. Fix Tree keeps its own separate, admin-gated, `parent_ids`-bearing fetch (FR-058-07) precisely because it needs a field the shared store cannot request. + +#### Q-058-05 — Cyclic-dependency prevention for Album Move and Album Merge + +**Resolved:** a single pure function, `getExcludedTargetIds(rootIds: string[])` (FR-058-04), computes the union of each root album's own id and every descendant id via `_lft`/`_rgt` range containment, and accepts one root (Move) or several (Merge) uniformly — no separate code path for the multi-album case. Investigation found the actual server-side backstop is `NodeTrait::appendNode()`'s `\LogicException` (`vendor/lychee-org/nestedset/src/NodeTrait.php:1199-1200`, "Node must not be a descendant"), thrown by `Actions\Album\Move::do()` per-album with no dedicated HTTP-exception mapping visible in that action — meaning a client that bypassed this feature's exclusion would likely surface an uncaught 500, not a clean 4xx. This feature's client-side filter is therefore the real UX-facing safety net; whether the backend should catch that `LogicException` and translate it to a proper 4xx is flagged as a candidate follow-up (Non-Goals: no backend change made here). + +#### Q-058-06 — Breadcrumb display and the "move to root" option + +**Resolved:** both are built, as real, rendered UI additions to `SearchTargetAlbum.vue` (FR-058-04/06), not dropped as dead scope. Investigation found that (a) today's v8 picker doesn't render breadcrumb text at all despite `TargetAlbumResource` carrying it server-side, and (b) v2's synthetic "move to root" option (`ListAlbums::do()`, prepended whenever `$albums->first()->parent_id !== null`) wasn't addressed anywhere in this feature's original drafting — a real functional-parity gap, not just cosmetic. Both turned out to be derivable purely from `_lft`/`_rgt` (an album "is root" iff no other album's range contains it — the same containment check `getExcludedTargetIds` already needs), so neither requires the admin-gated `parent_ids` mode, keeping Q-058-04's non-admin-store reasoning intact. The breadcrumb's *full path text* mirrors `ListAlbums::do()`'s `flatten()` exactly; its *truncation* deliberately does not reproduce `ListAlbums::shorten()`'s byte-length proportional-shortening algorithm — CSS truncation is used instead, since the v8 dropdown component can truncate visually (a simplification, not full algorithmic parity). + +#### Q-058-07 — Breadth of the shared store's mutation-invalidation net + +**Resolved:** widened beyond move/merge/login/logout to every other existing `AlbumService.clearCache()`/`clearAlbums()` call site whose action changes a field `GET /api/v3/Albums` returns — delete, unlock, visibility/protection-policy change, Fix Tree's repair save, and server-folder import (FR-058-12) — plus two more identity-transition call sites found during the same audit, WebAuthn login and registration's auto-login (FR-058-11). Explicitly **not** widened to pin-toggling (no tracked field changes) or Tag-Album/Person-Album operations (confirmed, via FR-057-01's `albums._lft`-ordered query and `FullTree::check()`'s precedent, that `GET /api/v3/Albums` only ever queries the regular `Album` model, never `TagAlbum`/`PersonAlbum`) — those call sites' existing `AlbumService.clearCache()`/`clearAlbums()` calls are left untouched, and this feature adds nothing there. diff --git a/docs/specs/4-architecture/features/058-album-listing-v3-adoption/tasks.md b/docs/specs/4-architecture/features/058-album-listing-v3-adoption/tasks.md index 981a2667982..7bb2b82ce0b 100644 --- a/docs/specs/4-architecture/features/058-album-listing-v3-adoption/tasks.md +++ b/docs/specs/4-architecture/features/058-album-listing-v3-adoption/tasks.md @@ -7,65 +7,99 @@ _Last updated: 2026-08-22_ > **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. > When referencing requirements, keep feature IDs (`F-`), non-goal IDs (`N-`), and scenario IDs (`S-058-`) inside the same parentheses immediately after the task title (omit categories that do not apply). > When new high- or medium-impact questions arise during execution, add them to [docs/specs/4-architecture/open-questions.md](../../open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections reflect the clarified behaviour. +> +> **v8 only.** Every file this feature touches lives under `resources/js/v8/` (or the shared, non-versioned `resources/js/stores/`/`resources/js/services/`). Several tasks below name a `resources/js/v7/...` file with the same basename purely to say "leave it alone" — never edit it. ## Checklist -- [ ] T-058-01 – Confirm Feature 057 is implemented (or implement it first) before starting this feature (dependency gate). - _Intent:_ This feature's I2-I5 all require a real `GET /api/v3/Albums` to call; verify `docs/specs/4-architecture/features/057-album-listing-v3/tasks.md` is fully `[x]` before proceeding. +- [ ] T-058-01 – Confirm Feature 057 is implemented (dependency gate). + _Intent:_ This feature's I2-I8 all require a real `GET /api/v3/Albums` to call; confirm `docs/specs/4-architecture/features/057-album-listing-v3/tasks.md` is fully `[x]` before proceeding (already true on this branch — backend for 056/057 exists). _Verification commands:_ N/A (documentation check). _Notes:_ Plan Dependencies. -- [ ] T-058-02 – Feature test + implement `config/features.php` entry and `ModulesRightsResource::$is_album_listing_v3_enabled` (FR-058-01, FR-058-02). - _Intent:_ Test asserts init payload's `modules.is_album_listing_v3_enabled` matches `config('features.album-listing-v3')` for both `true`/`false`. Written to fail first, then implement. +- [ ] T-058-02 – Feature test + implement `config/features.php` entry and `ModulesRightsResource::$is_struct_of_array_enabled` (FR-058-01, FR-058-02). + _Intent:_ Test asserts init payload's `modules.is_struct_of_array_enabled` matches `config('features.struct-of-array')` for both `true`/`false`. Written to fail first, then implement. _Verification commands:_ - `php artisan test --filter=ModulesRightsResource` (confirm exact existing test class name at implementation time) - `make phpstan` _Notes:_ Plan I1. -- [ ] T-058-03 – Add `album-list-v3-service.ts`, tree/breadcrumb/exclusion helper, and rewire `SearchTargetAlbum.vue` (FR-058-03, FR-058-04, NFR-058-02, NFR-058-03, NFR-058-06, S-058-02, S-058-03). - _Intent:_ DO-058-02/DO-058-03; flag-gated v3 path with client-side tree/breadcrumb/subtree-exclusion and ``-based thumbnails (v3 Asset endpoint) with placeholder fallback on error. Confirm and document whether `Move`/`MoveAlbums` re-validates descendant-safety server-side (Risks note in plan.md). +- [ ] T-058-03 – Add `album-list-v3-service.ts` and the shared `AlbumListState` Pinia store (`tree`, `getExcludedTargetIds`, `isTopLevel`, `buildBreadcrumb`) (FR-058-03, FR-058-04). + _Intent:_ DO-058-02/DO-058-03; base-mode-only fetch (no `with_parent_id`/`for_bulk_edit`), `ensureLoaded()`/`invalidate()`, nested-set-stack `tree` getter, and three pure `lft`/`rgt`-derived getter methods: `getExcludedTargetIds(rootIds)` (multi-root exclusion), `isTopLevel(albumId)` (root-option trigger), `buildBreadcrumb(albumId)` (full ancestor-chain path text, no truncation — CSS handles overflow). No UI consumer wired yet — verify from the browser console against hand-built fixtures (single-root exclusion against `ListAlbums::do()`'s existing behavior; multi-root against a hand-built ancestor/descendant selection; `isTopLevel`/`buildBreadcrumb` against a hand-built 2-3 level tree). _Verification commands:_ - `npm run check` - `npm run format` - - Manual: `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue` in both flag states. + - Manual: browser-console exercise of `tree`/`getExcludedTargetIds`/`isTopLevel`/`buildBreadcrumb` against a real session. _Notes:_ Plan I2. -- [ ] T-058-04 – Manual verification pass: move-picker parity, flag on vs. off (NFR-058-02, NFR-058-03, S-058-01, S-058-02, S-058-03). - _Intent:_ Side-by-side comparison of breadcrumb text, thumbnail, and subtree-exclusion behavior between the v2 and v3 paths for the same fixture data. +- [ ] T-058-04 – Manual verification pass: shared store tree/exclusion/root/breadcrumb correctness (NFR-058-03). + _Intent:_ Side-by-side comparison of the store's `tree`, single-root `getExcludedTargetIds`, `isTopLevel`, and `buildBreadcrumb` output against `ListAlbums::do()`/`flatten()`'s existing server-side behavior for the same fixture data (full breadcrumb text only — not `shorten()`'s truncation algorithm, Q-058-06); separately verify the multi-root exclusion case against a hand-built selection containing an ancestor/descendant pair. _Verification commands:_ Manual/browser-based (no automated frontend suite exists in this repo). _Notes:_ Plan I2. -- [ ] T-058-05 – Add SoA→`AlbumTree[]` adapter and rewire `FixTree.vue` (FR-058-05, S-058-04). - _Intent:_ DO-058-04; `fetch()` calls the v3 endpoint with `with_parent_id=true` when flagged on, adapts the response, hands it to the existing `prepareAlbums()`/WASM pipeline unchanged. `updateFullTree()` untouched. +- [ ] T-058-05 – Add `Thumb.vue` + `thumb-asset-service.ts` (FR-058-05). + _Intent:_ DO-058-05/DO-058-06; blob fetch via axios with `AbortController` signal, module-level cache keyed by `(albumId, photoId, type)`, abort on unmount, placeholder fallback for `photoId === null` or a failed/aborted fetch. Verified standalone (e.g. dropped into an existing dev page), before any real consumer depends on it. _Verification commands:_ - `npm run check` - `npm run format` - - Manual: run validity-check/repair against seeded inconsistent-tree fixture data, both flag states. + - Manual: DevTools Network panel — confirm request de-dup for two same-key `` instances (S-058-13), confirm `(canceled)` status when unmounted mid-request (S-058-12). _Notes:_ Plan I3. -- [ ] T-058-06 – Manual verification pass: Fix Tree parity, flag on vs. off (S-058-01, S-058-04). - _Intent:_ Confirm identical validity-check/repair results between v2 and v3 paths. - _Verification commands:_ Manual/browser-based. - _Notes:_ Plan I3. +- [ ] T-058-06 – Rewire `SearchTargetAlbum.vue` onto the shared store + ``, add "move to root" option and breadcrumb labels (FR-058-06, FR-058-10, NFR-058-02, NFR-058-03, NFR-058-07, S-058-02, S-058-03, S-058-10, S-058-11, S-058-12, S-058-13, S-058-16, S-058-17, S-058-18). + _Intent:_ Flag-gated v3 path: `ensureLoaded()` from the store, filter via `getExcludedTargetIds(props.albumIds ?? [])`; when `props.albumIds` is non-empty and `!isTopLevel(props.albumIds[0])`, prepend a synthetic `{id: null}` "move to root" row (mirrors `AlbumController::getTargetListAlbums`'s first-selected-album rule); render each option's label via `buildBreadcrumb` through an `#item-label` slot override (flag-off branch keeps `label-key="original"` unchanged); thumbnails via `` (root row → `photo-id: null` → placeholder). + _Verification commands:_ + - `npm run check` + - `npm run format` + - Manual: `AlbumMove.vue`, `MoveDialog.vue`, `PhotoCopyDialog.vue`, `AlbumMergeDialog.vue` in both flag states; `AlbumMergeDialog.vue` with 3 albums selected including an ancestor/descendant pair; DevTools Network panel across sequential dialog opens for de-dup (S-058-10); moving album with vs. without a parent to confirm the root option's presence/absence (S-058-16/17); two same-titled albums under different parents to confirm breadcrumb disambiguation (S-058-18). + _Notes:_ Plan I4. -- [ ] T-058-07 – Rewire `BulkAlbumEdit.vue` for client-side pagination/search/select-all (FR-058-06, NFR-058-04, S-058-05, S-058-06, S-058-07, S-058-08). - _Intent:_ Single `for_bulk_edit=true` fetch when flagged on; `load(page)`, debounced search, and "select all matching" reimplemented as in-memory operations; both "numbered" and "infinite-scroll" UI modes keep existing markup. Write endpoints untouched. +- [ ] T-058-07 – Wire shared-store `invalidate()` into all identity-transition and regular-Album-mutation call sites (FR-058-11, FR-058-12, NFR-058-09, NFR-058-10, S-058-14, S-058-15, S-058-19, S-058-20, S-058-21, S-058-22, S-058-23, S-058-24). + _Intent:_ Ten v8 call sites, each an additive one-line call next to an existing `AlbumService.clearCache()`/`clearAlbums()` call (never touch the matching `resources/js/v7/...` file). Identity: `LoginForm.vue:125`, `WebauthnModal.vue:65`, `RegisterPage.vue:125`, `LeftMenu.vue:170`. Mutations: `AlbumMove.vue`/`MoveDialog.vue`/`AlbumMergeDialog.vue` (move/merge — likely already wired in T-058-06's dialog work; confirm here if not), `AlbumDelete.vue:67`, `DeleteDialog.vue:105` (album-delete path), `Unlock.vue:48`, `AlbumVisibility.vue:149`, `FixTree.vue:156` (post-`updateFullTree()`), `ImportFromServer.vue:170`. Do **not** wire: `AlbumPanel.vue`/`Albums.vue` (`togglePin`), any Tag/Person-Album dialog (`AlbumCreatePersonDialog.vue`, `AlbumCreateTagDialog.vue`, `TagRenameDialog.vue`, `TagDeleteDialog.vue`, `TagPanel.vue`, `TagMergeDialog.vue`), or `Search.vue`/`Timeline.vue`'s star/unstar callbacks — confirmed irrelevant to this store's tracked fields (Q-058-07). + _Verification commands:_ + - `npm run check` + - `npm run format` + - Manual, one pass per included call site: perform the action, reopen a Move/Merge dialog (from T-058-06), confirm the store reflects the change. Login: password (S-058-14) and WebAuthn. Registration (S-058-24). Logout (S-058-15). Delete (S-058-19). Unlock (S-058-20). Visibility change (S-058-21). Fix Tree save (S-058-22) — may need to stub/precede T-058-09 with a minimal Fix Tree v3 read path, or verify this scenario after T-058-09 instead. Server-folder import (S-058-23). Spot-check one excluded site (e.g. pin-toggle) to confirm the store is *not* invalidated. + _Notes:_ Plan I5. S-058-22's full verification may be more natural after T-058-09 (Fix Tree's v3 read path) exists — order the manual pass accordingly, the wiring itself has no such dependency. + +- [ ] T-058-08 – Manual verification pass: move-picker parity, flag on vs. off (NFR-058-02, NFR-058-03, S-058-01). + _Intent:_ Side-by-side comparison of breadcrumb text, thumbnail, root option, and subtree-exclusion behavior between the v2 (flag-off) and v3 (flag-on) paths for the same fixture data. + _Verification commands:_ Manual/browser-based (no automated frontend suite exists in this repo). + _Notes:_ Plan I4/I5. + +- [ ] T-058-09 – Add SoA→`AlbumTree[]` adapter and rewire `FixTree.vue` (FR-058-07, S-058-04). + _Intent:_ DO-058-04; `fetch()` calls the v3 endpoint with `with_parent_id=true` when flagged on (its own separate admin-gated request, not the shared store), adapts the response, hands it to the existing `prepareAlbums()`/WASM pipeline unchanged. `updateFullTree()` untouched except for T-058-07's added `invalidate()` call on its completion. + _Verification commands:_ + - `npm run check` + - `npm run format` + - Manual: run validity-check/repair against seeded inconsistent-tree fixture data, both flag states. + _Notes:_ Plan I6. + +- [ ] T-058-10 – Rewire `BulkAlbumEdit.vue` for client-side pagination/search/select-all (FR-058-08, NFR-058-04, S-058-05, S-058-06, S-058-07, S-058-08). + _Intent:_ Single `for_bulk_edit=true` fetch when flagged on (its own separate admin-gated request, not the shared store); `load(page)`, debounced search, and "select all matching" reimplemented as in-memory operations; both "numbered" and "infinite-scroll" UI modes keep existing markup. Write endpoints untouched. _Verification commands:_ - `npm run check` - `npm run format` - Manual: numbered pagination, infinite scroll, search, select-all-matching, both flag states. - _Notes:_ Plan I4. + _Notes:_ Plan I7. + +- [ ] T-058-11 – Manual verification pass: Fix Tree and Bulk Album Edit parity, flag on vs. off (S-058-01, S-058-04..08). + _Intent:_ Confirm identical validity-check/repair results (Fix Tree) and identical pagination/search/select-all behavior (Bulk Album Edit) between v2 and v3 paths. + _Verification commands:_ Manual/browser-based. + _Notes:_ Plan I6/I7. -- [ ] T-058-08 – Full manual verification pass S-058-01..09 (both flag states) + quality gate + docs sync (NFR-058-05). - _Intent:_ Confirm S-058-09 (pure `.env` toggle, no rebuild) in addition to re-confirming S-058-01..08 end to end. Update `docs/specs/3-reference/api-design.md`, `docs/specs/4-architecture/knowledge-map.md`; move roadmap.md's Feature 058 row to Completed. +- [ ] T-058-12 – Full manual verification pass S-058-01..24 (both flag states) + quality gate + docs sync (NFR-058-05). + _Intent:_ Confirm S-058-09 (pure `.env` toggle, no rebuild) in addition to re-confirming every other scenario end to end. Update `docs/specs/3-reference/api-design.md`, `docs/specs/4-architecture/knowledge-map.md`; move roadmap.md's Feature 058 row to Completed. Confirm via `git diff` that no file under `resources/js/v7/` was touched (NFR-058-01). _Verification commands:_ - `npm run format` - `npm run check` - `make phpstan` - `vendor/bin/php-cs-fixer fix` - `php artisan test --filter=ModulesRightsResource` - _Notes:_ Plan I5. Prepare commit summary per AGENTS.md commit protocol; do not commit directly. + - `git diff --stat -- resources/js/v7/` (expect empty) + _Notes:_ Plan I8. Prepare commit summary per AGENTS.md commit protocol; do not commit directly. ## Notes / TODOs -- Depends on Feature 057 being implemented first (T-058-01) — not yet started as of this writing. +- Feature 057/056 (backend) already implemented on this branch — T-058-01 is a formality, not a blocker. +- `STRUCT_OF_ARRAY_ENABLED` is named for future reuse by a Photos SoA v3 endpoint (Q-058-03); no photos work is included in this task list. +- v8 only — see the checklist header note. Every `resources/js/v7/...` counterpart file stays untouched. diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 9d014372eeb..a26c6871cf7 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -7,8 +7,13 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| | ~~Q-057-05~~ | 057 – Album Listing v3 | High | Discovered while scoping Feature 058: the move-target picker consumer needs a per-album thumbnail, which the original minimal shape (id/title/_lft/_rgt) didn't carry | Resolved (add `cover_ids` to the base/default response — resolved server-side via the same 3-column priority `HasAlbumThumb::getCoverTypeForAlbum()` already uses, zero extra joins since all 3 columns live on `albums`; frontend resolves actual thumbnail bytes via the separate Feature 056 v3 Asset endpoint) | 2026-08-22 | 2026-08-22 | -| Q-058-01 | 058 – Album Listing v3 Adoption | High | Scope & approach for migrating the 3 v2 consumers, given real UX mismatches (move-picker loses thumbnail/breadcrumb/self-exclusion; bulk-edit loses server pagination+search) — migrate all 3 with client-side compensation vs. a narrower scope | Open | 2026-08-22 | 2026-08-22 | -| Q-058-02 | 058 – Album Listing v3 Adoption | Medium | Feature-flag granularity in `config/features.php` — one combined flag vs. one independent flag per consumer | Open | 2026-08-22 | 2026-08-22 | +| ~~Q-058-01~~ | 058 – Album Listing v3 Adoption | High | Scope & approach for migrating the 3 v2 consumers, given real UX mismatches (move-picker loses thumbnail/breadcrumb/self-exclusion; bulk-edit loses server pagination+search) — migrate all 3 with client-side compensation vs. a narrower scope | Resolved (all 3, client-side compensation, thumbnails preserved via a new `` component) | 2026-08-22 | 2026-08-22 | +| ~~Q-058-02~~ | 058 – Album Listing v3 Adoption | Medium | Feature-flag granularity in `config/features.php` — one combined flag vs. one independent flag per consumer | Resolved (A — one combined flag) | 2026-08-22 | 2026-08-22 | +| ~~Q-058-03~~ | 058 – Album Listing v3 Adoption | Medium | Flag naming/scope — `ALBUM_LISTING_V3_ENABLED` (feature-specific) vs. a general SoA toggle | Resolved (`STRUCT_OF_ARRAY_ENABLED`/`struct-of-array`/`is_struct_of_array_enabled` — reused later by a future Photos SoA endpoint) | 2026-08-22 | 2026-08-22 | +| ~~Q-058-04~~ | 058 – Album Listing v3 Adoption | High | Where the shared album list lives and how its tree is computed — bare composable vs. Pinia store; `_lft`/`_rgt` reconstruction vs. admin-gated `parent_ids` | Resolved (Pinia store `AlbumListState.ts`; tree from `_lft`/`_rgt` only, since `parent_ids` is admin-gated and unusable for non-admin move/merge dialogs) | 2026-08-22 | 2026-08-22 | +| ~~Q-058-05~~ | 058 – Album Listing v3 Adoption | High | Cyclic-dependency prevention for Album Move and Album Merge (multi-album) — single-root vs. multi-root exclusion | Resolved (one pure `getExcludedTargetIds(rootIds)` function, self ∪ descendants via `_lft`/`_rgt`, covering N roots uniformly) | 2026-08-22 | 2026-08-22 | +| ~~Q-058-06~~ | 058 – Album Listing v3 Adoption | High | Breadcrumb display and the "move to root" option — drop as dead scope (today's v8 picker renders neither) vs. build both as real UI additions | Resolved (build both — breadcrumb full-path text mirrors `ListAlbums::do()`, truncation uses CSS not `shorten()`'s algorithm; root option derived from `_lft`/`_rgt` containment, no `parent_ids` needed) | 2026-08-22 | 2026-08-22 | +| ~~Q-058-07~~ | 058 – Album Listing v3 Adoption | Medium | Breadth of the shared store's mutation-invalidation net — move/merge/login/logout only vs. widen to all regular-Album mutations | Resolved (widen to delete/unlock/visibility-change/Fix-Tree-save/server-import, plus WebAuthn login and registration's auto-login; explicitly excludes pin-toggle and Tag/Person-Album operations, confirmed irrelevant to `GET /api/v3/Albums`'s tracked fields) | 2026-08-22 | 2026-08-22 | | ~~Q-057-01~~ | 057 – Album Listing v3 | High | Rights-curation filter for the default (root) listing mode — `applyVisibilityFilter()` vs. `applyReachabilityFilter()` (password-lock aware) | Resolved (A — visibility only) | 2026-08-22 | 2026-08-22 | | ~~Q-057-02~~ | 057 – Album Listing v3 | High | Query-parameter shape for the fixTree/bulk-edit variants — two independent booleans vs. one `mode` enum | Resolved (A — two independent booleans, `with_parent_id` + `for_bulk_edit`) | 2026-08-22 | 2026-08-22 | | ~~Q-057-03~~ | 057 – Album Listing v3 | Medium | Field set returned when the bulk-edit flag is set — minimal owner/visibility subset vs. full parity with today's `BulkAlbumResource` | Resolved (B — full `BulkAlbumResource` field parity) | 2026-08-22 | 2026-08-22 | diff --git a/package-lock.json b/package-lock.json index e0ffcdd676a..aff48c52541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "@fortawesome/fontawesome-free": "^7.3.1", "@iconify-json/lucide": "^1.2.123", "@lychee-org/layouts": "^0.1.0", - "@lychee-org/nested-set-checker-wasm": "^1.0.0", + "@lychee-org/nested-set-checker-wasm": "^2.0.0", "@lychee-org/zxcvbn-wasm": "^1.0.1", "@mollie/api-client": "^4.5.0", "@nuxt/ui": "^4.10.0", @@ -1311,9 +1311,9 @@ "license": "MIT" }, "node_modules/@lychee-org/nested-set-checker-wasm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@lychee-org/nested-set-checker-wasm/-/nested-set-checker-wasm-1.0.0.tgz", - "integrity": "sha512-uFhvBn+RCtFxlZyCM108ypqnkFOTHqmvjtetJ54ZQzainvBZ/39Vzdl5u5MzGVChkDSoNFhz8KlH/pJGCUPlmA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@lychee-org/nested-set-checker-wasm/-/nested-set-checker-wasm-2.0.0.tgz", + "integrity": "sha512-0mq1oPDCLKznPhXL4eEciaaTv/UM/m9u0K9+CFXVAu/LPjnGVXin/HnEK0chjGN9fTdfCALSf1mOwBwKkBGNeQ==", "license": "MIT" }, "node_modules/@lychee-org/zxcvbn-wasm": { diff --git a/package.json b/package.json index d3a3b4f5987..589da42bb4c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@fortawesome/fontawesome-free": "^7.3.1", "@iconify-json/lucide": "^1.2.123", "@lychee-org/layouts": "^0.1.0", - "@lychee-org/nested-set-checker-wasm": "^1.0.0", + "@lychee-org/nested-set-checker-wasm": "^2.0.0", "@lychee-org/zxcvbn-wasm": "^1.0.1", "@mollie/api-client": "^4.5.0", "@nuxt/ui": "^4.10.0", diff --git a/resources/js/lychee.d.ts b/resources/js/lychee.d.ts index 79e52cd077a..484711f7082 100644 --- a/resources/js/lychee.d.ts +++ b/resources/js/lychee.d.ts @@ -679,6 +679,7 @@ declare namespace App { is_album_header_landing_title_enabled: boolean; use_admin_dashboard: boolean; is_person_album_enabled: boolean; + is_struct_of_array_enabled: boolean; is_cover_id_flag_enabled: boolean; is_header_id_flag_enabled: boolean; is_highlighted_flag_enabled: boolean; @@ -1723,8 +1724,8 @@ declare namespace App { export type AlbumListResource = { ids: string[]; titles: string[]; - lft: number[]; - rgt: number[]; + _lft: number[]; + _rgt: number[]; cover_ids: (string | null)[]; parent_ids: (string | null)[] | null; bulk_edit: App.Http.Resources.V3.AlbumListBulkEditFieldsResource | null; diff --git a/resources/js/services/album-list-v3-service.ts b/resources/js/services/album-list-v3-service.ts new file mode 100644 index 00000000000..cad990851f1 --- /dev/null +++ b/resources/js/services/album-list-v3-service.ts @@ -0,0 +1,15 @@ +import axios, { type AxiosResponse } from "axios"; +import Constants from "./constants"; + +export type AlbumListV3Params = { + with_parent_id?: boolean; + for_bulk_edit?: boolean; +}; + +const AlbumListV3Service = { + getAlbums(params: AlbumListV3Params = {}): Promise> { + return axios.get(`${Constants.getApiUrlV3()}Albums`, { params: params, data: {} }); + }, +}; + +export default AlbumListV3Service; diff --git a/resources/js/services/constants.ts b/resources/js/services/constants.ts index 8d089f48750..02f2ae60efd 100644 --- a/resources/js/services/constants.ts +++ b/resources/js/services/constants.ts @@ -1,9 +1,13 @@ const Constants = { API_URL: "/api/v2/", + API_URL_V3: "/api/v3/", BASE_URL: document.querySelector("base")?.getAttribute("href") || "", getApiUrl(): string { return this.BASE_URL + this.API_URL; }, + getApiUrlV3(): string { + return this.BASE_URL + this.API_URL_V3; + }, }; export type PaginatedResponse = { diff --git a/resources/js/services/thumb-asset-service.ts b/resources/js/services/thumb-asset-service.ts new file mode 100644 index 00000000000..0ad4ab229eb --- /dev/null +++ b/resources/js/services/thumb-asset-service.ts @@ -0,0 +1,133 @@ +import axios from "axios"; +import Constants from "./constants"; + +type CacheEntry = { + promise: Promise; + controller: AbortController; + refCount: number; + settled: boolean; + // Set once `settled`; refreshed on every cache hit so idle (not just old) entries expire. + expiresAt: number | undefined; +}; + +const cache = new Map(); + +// Keeps memory bounded for long browsing sessions: each cached entry pins one +// `URL.createObjectURL()` blob. Both are deliberately generous since eviction only ever +// targets entries with `refCount <= 0` (nothing currently rendering them). +const MAX_CACHE_SIZE = 6000; // ~6000 * 4 KiB = ~24 MiB, plus some overhead for the `Map` and `CacheEntry` objects +const TTL_MS = 60 * 60 * 1000; // 1 hour, long enough to cover a typical browsing session but not so long that a user can leave the tab open for days and accumulate a huge memory footprint. + +function cacheKey(albumId: string, photoId: string, type: App.Enum.SizeVariantAssetType): string { + return `${albumId}:${photoId}:${type}`; +} + +// Revokes the object URL (once resolved) and drops the entry. Only call this for entries +// with `refCount <= 0` — revoking a URL still in use would break whatever is rendering it. +function evict(key: string, entry: CacheEntry): void { + cache.delete(key); + if (entry.settled) { + void entry.promise.then((url) => URL.revokeObjectURL(url)); + } +} + +function sweepExpired(now: number): void { + for (const [key, entry] of cache) { + if (entry.refCount <= 0 && entry.expiresAt !== undefined && entry.expiresAt <= now) { + evict(key, entry); + } + } +} + +// `cache` iterates in insertion order, and cache hits re-insert their entry (see `acquire`), +// so the front of the map is always the least-recently-used entry: plain LRU via `Map`. +function enforceCacheLimit(): void { + if (cache.size <= MAX_CACHE_SIZE) { + return; + } + for (const [key, entry] of cache) { + if (cache.size <= MAX_CACHE_SIZE) { + break; + } + if (entry.refCount <= 0) { + evict(key, entry); + } + } +} + +const ThumbAssetService = { + /** + * Resolves `GET /api/v3/Asset/{albumId}/{photoId}/{type}` to an object URL, de-duplicating + * concurrent/repeated callers for the same `(albumId, photoId, type)` onto one request and + * one cached object URL. + * + * Returns a `release()` alongside the promise instead of taking the caller's own + * `AbortSignal` directly: several `` instances can be simultaneously waiting on the + * same still-in-flight entry, so only aborting once every caller has released it (and only + * while it hasn't resolved yet) avoids one unmounting instance cancelling the request out + * from under a sibling that is still mounted. A resolved entry is not evicted by `release()` + * itself, but stays subject to the module's bounded LRU + TTL eviction (`URL.revokeObjectURL()` + * is called when an entry is evicted) once nothing is holding it (`refCount <= 0`). + */ + acquire(albumId: string, photoId: string, type: App.Enum.SizeVariantAssetType): { promise: Promise; release: () => void } { + const key = cacheKey(albumId, photoId, type); + const now = Date.now(); + sweepExpired(now); + + let entry = cache.get(key); + + if (entry !== undefined) { + // Bump recency for LRU, and slide the TTL forward since it's being reused. + cache.delete(key); + if (entry.settled) { + entry.expiresAt = now + TTL_MS; + } + cache.set(key, entry); + } else { + const controller = new AbortController(); + const promise = axios + .get(`${Constants.getApiUrlV3()}Asset/${albumId}/${photoId}/${type}`, { + responseType: "blob", + signal: controller.signal, + data: {}, + }) + .then((response) => { + if (entry !== undefined) { + entry.settled = true; + entry.expiresAt = Date.now() + TTL_MS; + } + return URL.createObjectURL(response.data as Blob); + }) + .catch((error: unknown) => { + if (cache.get(key) === entry) { + cache.delete(key); + } + throw error; + }); + + entry = { promise, controller, refCount: 0, settled: false, expiresAt: undefined }; + cache.set(key, entry); + enforceCacheLimit(); + } + + entry.refCount++; + const acquired = entry; + + return { + promise: acquired.promise, + release: () => { + acquired.refCount--; + if (acquired.refCount <= 0 && cache.get(key) === acquired) { + if (!acquired.settled) { + acquired.controller.abort(); + cache.delete(key); + } else if (acquired.expiresAt !== undefined && acquired.expiresAt <= Date.now()) { + evict(key, acquired); + } + } + }, + }; + }, +}; + +export default ThumbAssetService; diff --git a/resources/js/stores/AlbumListState.ts b/resources/js/stores/AlbumListState.ts new file mode 100644 index 00000000000..9df9691218d --- /dev/null +++ b/resources/js/stores/AlbumListState.ts @@ -0,0 +1,183 @@ +import { defineStore } from "pinia"; +import AlbumListV3Service from "@/services/album-list-v3-service"; + +export type AlbumListRow = { + id: string; + title: string; + _lft: number; + _rgt: number; + coverId: string | null; +}; + +export type AlbumTreeNode = AlbumListRow & { + depth: number; + children: AlbumTreeNode[]; +}; + +function toRows(ids: string[], titles: string[], _lft: number[], _rgt: number[], coverIds: (string | null)[]): AlbumListRow[] { + return ids.map((id, i) => ({ id, title: titles[i], _lft: _lft[i], _rgt: _rgt[i], coverId: coverIds[i] })); +} + +// Nested-set stack reconstruction: rows are visited in ascending `lft` order; the stack holds +// the chain of currently-open ancestors. A stack entry whose `rgt` is already behind the new +// row's `lft` has closed and is popped; whatever remains on top (if anything) is the parent. +function buildTree(rows: AlbumListRow[]): AlbumTreeNode[] { + const sorted = [...rows].sort((a, b) => a._lft - b._lft); + const roots: AlbumTreeNode[] = []; + const stack: AlbumTreeNode[] = []; + + for (const row of sorted) { + const node: AlbumTreeNode = { ...row, depth: 0, children: [] }; + + while (stack.length > 0 && stack[stack.length - 1]._rgt < node._lft) { + stack.pop(); + } + + const parent = stack[stack.length - 1] as AlbumTreeNode | undefined; + if (parent === undefined) { + roots.push(node); + } else { + node.depth = parent.depth + 1; + parent.children.push(node); + } + + stack.push(node); + } + + return roots; +} + +function findAncestors(rows: AlbumListRow[], target: AlbumListRow): AlbumListRow[] { + return rows.filter((r) => r.id !== target.id && r._lft < target._lft && r._rgt > target._rgt).sort((a, b) => a._lft - b._lft); +} + +export type AlbumListStore = ReturnType; + +export const useAlbumListStore = defineStore("album-list-store", { + state: () => ({ + ids: [] as string[], + titles: [] as string[], + _lft: [] as number[], + _rgt: [] as number[], + coverIds: [] as (string | null)[], + isLoaded: false as boolean, + error: undefined as unknown, + _loadPromise: undefined as Promise | undefined, + // Bumped by `invalidate()` so a request started before it can't commit stale data + // or leave `_loadPromise` pointing at a promise nobody will re-fetch through. + _loadGeneration: 0 as number, + }), + actions: { + /** + * Fetches the base-mode album list at most once per session (or since the last + * `invalidate()`); concurrent callers share the same in-flight request. + */ + ensureLoaded(): Promise { + if (this.isLoaded) { + return Promise.resolve(); + } + if (this._loadPromise !== undefined) { + return this._loadPromise; + } + + const generation = this._loadGeneration; + this.error = undefined; + const promise = AlbumListV3Service.getAlbums() + .then((response) => { + if (this._loadGeneration !== generation) { + return; + } + this.ids = response.data.ids; + this.titles = response.data.titles; + this._lft = response.data._lft; + this._rgt = response.data._rgt; + this.coverIds = response.data.cover_ids; + this.isLoaded = true; + }) + .catch((error: unknown) => { + if (this._loadGeneration !== generation) { + return; + } + this.error = error; + }) + .finally(() => { + if (this._loadGeneration !== generation) { + return; + } + this._loadPromise = undefined; + }); + + this._loadPromise = promise; + return promise; + }, + + invalidate() { + this.isLoaded = false; + this._loadGeneration++; + this._loadPromise = undefined; + }, + }, + getters: { + isLoading(state): boolean { + return state._loadPromise !== undefined; + }, + + rows(state): AlbumListRow[] { + return toRows(state.ids, state.titles, state._lft, state._rgt, state.coverIds); + }, + + tree(): AlbumTreeNode[] { + return buildTree(this.rows); + }, + + /** + * Union, over every id in `rootIds`, of that album's own id plus every descendant + * (`lft`/`rgt` range strictly contained within it). `rootIds` may hold one id + * (single-album Move) or several (multi-album Merge) uniformly. + */ + getExcludedTargetIds(): (rootIds: string[]) => Set { + const rows = this.rows; + return (rootIds: string[]): Set => { + const excluded = new Set(); + const roots = rows.filter((r) => rootIds.includes(r.id)); + for (const root of roots) { + excluded.add(root.id); + } + for (const row of rows) { + if (excluded.has(row.id)) { + continue; + } + if (roots.some((root) => row._lft > root._lft && row._rgt < root._rgt)) { + excluded.add(row.id); + } + } + return excluded; + }; + }, + + /** True iff no other album's range contains `albumId` — i.e. it has no ancestor. */ + isTopLevel(): (albumId: string) => boolean { + const rows = this.rows; + return (albumId: string): boolean => { + const target = rows.find((r) => r.id === albumId); + if (target === undefined) { + return true; + } + return !rows.some((r) => r.id !== albumId && r._lft < target._lft && r._rgt > target._rgt); + }; + }, + + /** Full ancestor-chain path, ancestor titles joined by `/` then the album's own title. */ + buildBreadcrumb(): (albumId: string) => string { + const rows = this.rows; + return (albumId: string): string => { + const target = rows.find((r) => r.id === albumId); + if (target === undefined) { + return ""; + } + const ancestors = findAncestors(rows, target); + return [...ancestors.map((a) => a.title), target.title].join("/"); + }; + }, + }, +}); diff --git a/resources/js/stores/LycheeState.ts b/resources/js/stores/LycheeState.ts index fc566f079eb..4222920b8d4 100644 --- a/resources/js/stores/LycheeState.ts +++ b/resources/js/stores/LycheeState.ts @@ -69,6 +69,7 @@ export const useLycheeStateStore = defineStore("lychee-store", { is_album_header_landing_title_enabled: false, use_admin_dashboard: true, + is_struct_of_array_enabled: false, // Download settings is_raw_download_enabled: false, @@ -287,6 +288,7 @@ export const useLycheeStateStore = defineStore("lychee-store", { this.default_album_protection = data.default_album_protection; this.expert_album_settings = data.expert_album_settings; this.use_admin_dashboard = data.use_admin_dashboard; + this.is_struct_of_array_enabled = data.is_struct_of_array_enabled; this.is_cover_id_flag_enabled = data.is_cover_id_flag_enabled; this.is_header_id_flag_enabled = data.is_header_id_flag_enabled; diff --git a/resources/js/v8/components/admin/AdminStatTile.vue b/resources/js/v8/components/admin/AdminStatTile.vue new file mode 100644 index 00000000000..d25ae658372 --- /dev/null +++ b/resources/js/v8/components/admin/AdminStatTile.vue @@ -0,0 +1,16 @@ + + + diff --git a/resources/js/v8/components/admin/AdminTileLink.vue b/resources/js/v8/components/admin/AdminTileLink.vue new file mode 100644 index 00000000000..6f838c00ec6 --- /dev/null +++ b/resources/js/v8/components/admin/AdminTileLink.vue @@ -0,0 +1,47 @@ + + + diff --git a/resources/js/v8/components/forms/album/AlbumDelete.vue b/resources/js/v8/components/forms/album/AlbumDelete.vue index 32953ff994c..f9e2f654fac 100644 --- a/resources/js/v8/components/forms/album/AlbumDelete.vue +++ b/resources/js/v8/components/forms/album/AlbumDelete.vue @@ -21,11 +21,13 @@ import AlbumService from "@/services/album-service"; import { sprintf } from "sprintf-js"; import { useAlbumStore } from "@/stores/AlbumState"; import { usePhotosStore } from "@/stores/PhotosState"; +import { useAlbumListStore } from "@/stores/AlbumListState"; import { computed, ref } from "vue"; import { trans } from "laravel-vue-i18n"; const albumStore = useAlbumStore(); const photosStore = usePhotosStore(); +const albumListStore = useAlbumListStore(); const router = useRouter(); @@ -50,6 +52,7 @@ function execute() { } AlbumService.delete([albumStore.album.id]).then(() => { + albumListStore.invalidate(); emits("deleted"); const isModelAlbum = albumStore.config?.is_model_album ?? false; const modelAlbum = albumStore.modelAlbum; diff --git a/resources/js/v8/components/forms/album/AlbumMove.vue b/resources/js/v8/components/forms/album/AlbumMove.vue index 20b8a6e2c8e..5a7b19729eb 100644 --- a/resources/js/v8/components/forms/album/AlbumMove.vue +++ b/resources/js/v8/components/forms/album/AlbumMove.vue @@ -30,6 +30,7 @@ import SearchTargetAlbum from "@/v8/components/forms/album/SearchTargetAlbum.vue import AlbumService from "@/services/album-service"; import { useAppToast } from "@/v8/composables/useAppToast"; import { useAlbumStore } from "@/stores/AlbumState"; +import { useAlbumListStore } from "@/stores/AlbumListState"; defineProps<{ legendIcon: string; @@ -39,6 +40,7 @@ defineProps<{ const toast = useAppToast(); const router = useRouter(); const albumStore = useAlbumStore(); +const albumListStore = useAlbumListStore(); const titleMovedTo = ref(undefined); const destination_id = ref(undefined); const error_no_target = ref(false); @@ -61,6 +63,7 @@ function execute() { AlbumService.move(destination_id.value, [albumId]).then(() => { AlbumService.clearCache(destination_id.value); AlbumService.clearCache(parentId); + albumListStore.invalidate(); albumStore.reset(); toast.add({ severity: "success", diff --git a/resources/js/v8/components/forms/album/AlbumVisibility.vue b/resources/js/v8/components/forms/album/AlbumVisibility.vue index 9a7f9d5f45e..b00292877ea 100644 --- a/resources/js/v8/components/forms/album/AlbumVisibility.vue +++ b/resources/js/v8/components/forms/album/AlbumVisibility.vue @@ -103,6 +103,7 @@ import { useLycheeStateStore } from "@/stores/LycheeState"; import { storeToRefs } from "pinia"; import SETag from "@/v8/components/icons/SETag.vue"; import { useAlbumStore } from "@/stores/AlbumState"; +import { useAlbumListStore } from "@/stores/AlbumListState"; defineProps<{ legendIcon: string; @@ -110,6 +111,7 @@ defineProps<{ }>(); const albumStore = useAlbumStore(); +const albumListStore = useAlbumListStore(); const toast = useAppToast(); @@ -143,6 +145,7 @@ function save() { AlbumService.updateProtectionPolicy(data).then(() => { toast.add({ severity: "success", summary: trans("toasts.success"), detail: trans("dialogs.visibility.visibility_updated"), life: 3000 }); AlbumService.clearCache(albumStore.albumId); + albumListStore.invalidate(); if (albumStore.config?.is_model_album) { AlbumService.clearCache(albumStore.modelAlbum?.parent_id); } else { diff --git a/resources/js/v8/components/forms/album/SearchTargetAlbum.vue b/resources/js/v8/components/forms/album/SearchTargetAlbum.vue index 8bf8978dc14..b61ddea3420 100644 --- a/resources/js/v8/components/forms/album/SearchTargetAlbum.vue +++ b/resources/js/v8/components/forms/album/SearchTargetAlbum.vue @@ -6,17 +6,36 @@ :placeholder="$t('dialogs.target_album.placeholder')" :loading="options === undefined" :items="options" + :virtualize="{ estimateSize: 32, overscan: 50 }" label-key="original" @update:model-value="selected" > + diff --git a/resources/js/v8/components/maintenance/FixTreeLine.vue b/resources/js/v8/components/maintenance/FixTreeLine.vue deleted file mode 100644 index 7845975dff7..00000000000 --- a/resources/js/v8/components/maintenance/FixTreeLine.vue +++ /dev/null @@ -1,160 +0,0 @@ - - diff --git a/resources/js/v8/components/modals/ImportFromServer.vue b/resources/js/v8/components/modals/ImportFromServer.vue index 366d8dca8a9..b7bb08bdaac 100644 --- a/resources/js/v8/components/modals/ImportFromServer.vue +++ b/resources/js/v8/components/modals/ImportFromServer.vue @@ -89,6 +89,7 @@ import AlbumService from "@/services/album-service"; import LycheeLoadingIcon from "@/v8/components/LycheeLoadingIcon.vue"; import { useAppToast } from "@/v8/composables/useAppToast"; import { useImportState } from "@/stores/ImportState"; +import { useAlbumListStore } from "@/stores/AlbumListState"; const open = defineModel("open", { default: false }); const emits = defineEmits<{ refresh: [] }>(); @@ -98,6 +99,7 @@ const router = useRouter(); const { getParentId } = usePhotoRoute(router); const importState = useImportState(); +const albumListStore = useAlbumListStore(); const directory = ref(""); const importing = ref(false); @@ -168,6 +170,7 @@ function submit() { toast.add({ severity: "success", summary: "Success", detail: "Import started successfully", life: 3000 }); // Clear cache for the parent album to ensure the new photos are displayed AlbumService.clearCache(); + albumListStore.invalidate(); importing.value = false; emits("refresh"); }); diff --git a/resources/js/v8/components/modals/WebauthnModal.vue b/resources/js/v8/components/modals/WebauthnModal.vue index a14837abdba..b73980afb35 100644 --- a/resources/js/v8/components/modals/WebauthnModal.vue +++ b/resources/js/v8/components/modals/WebauthnModal.vue @@ -37,6 +37,7 @@ import { useTogglablesStateStore } from "@/stores/ModalsState"; import AlbumService from "@/services/album-service"; import { storeToRefs } from "pinia"; import { useUserStore } from "@/stores/UserState"; +import { useAlbumListStore } from "@/stores/AlbumListState"; const toast = useAppToast(); const emits = defineEmits<{ @@ -45,6 +46,7 @@ const emits = defineEmits<{ const togglableStore = useTogglablesStateStore(); const userStore = useUserStore(); +const albumListStore = useAlbumListStore(); const isWebAuthnUnavailable = computed(() => WebAuthnService.isWebAuthnUnavailable()); const { is_webauthn_open } = storeToRefs(togglableStore); @@ -63,6 +65,7 @@ function login() { is_webauthn_open.value = false; userStore.setUser(undefined); AlbumService.clearCache(); + albumListStore.invalidate(); emits("logged-in"); }) .catch((e) => diff --git a/resources/js/v8/components/thumbs/Thumb.vue b/resources/js/v8/components/thumbs/Thumb.vue new file mode 100644 index 00000000000..61b42153660 --- /dev/null +++ b/resources/js/v8/components/thumbs/Thumb.vue @@ -0,0 +1,68 @@ + + diff --git a/resources/js/v8/composables/album/treeOperations.ts b/resources/js/v8/composables/album/treeOperations.ts index 60e9fa62913..5181ef9b3d4 100644 --- a/resources/js/v8/composables/album/treeOperations.ts +++ b/resources/js/v8/composables/album/treeOperations.ts @@ -10,21 +10,13 @@ import init, { decrementRgt as wasmDecrementRgt, getModifiedAlbums as wasmGetModifiedAlbums, setPanicHook, - type AlbumTree as WasmAlbumTree, - type AugmentedAlbum as WasmAugmentedAlbum, + type AlbumTree, + type AugmentedAlbumTree, + type ModifiedAlbums, type ErrorDescriptor, } from "@lychee-org/nested-set-checker-wasm"; -export type Augmented = { - prefix: string; - trimmedId: string; - trimmedParentId: string; - isDuplicate_rgt: boolean; - isDuplicate_lft: boolean; - isExpectedParentId: boolean; -}; - -export type AugmentedAlbum = App.Http.Resources.Diagnostics.AlbumTree & Augmented; +export type { AlbumTree, AugmentedAlbumTree, ModifiedAlbums }; let wasmReady: Promise | null = null; @@ -57,15 +49,25 @@ function formatError(e: ErrorDescriptor): string { return sprintf(trans(`fix-tree.errors.${e.kind}`), ...ERROR_TRANS_ARGS[e.kind](e)); } -export function useTreeOperations( - originalAlbums: Ref, - albums: Ref, - toast: ToastLike, -) { +// Builds the struct-of-arrays source `prepareAlbums` expects, sorted by `_lft`, from the +// current (possibly user-edited) `albums`. Only the per-field arrays are permuted by index +// here — no per-album object is ever materialized. +function sortedByLft(current: AugmentedAlbumTree): AlbumTree { + const order = Array.from(current.id.keys()).sort((a, b) => current._lft[a] - current._lft[b]); + return { + id: order.map((i) => current.id[i]), + title: order.map((i) => current.title[i]), + parent_id: order.map((i) => current.parent_id[i]), + _lft: Int32Array.from(order, (i) => current._lft[i]), + _rgt: Int32Array.from(order, (i) => current._rgt[i]), + }; +} + +export function useTreeOperations(originalAlbums: Ref, albums: Ref, toast: ToastLike) { const isValidated = ref(false); const errors = ref([]); - async function prepareAlbums(sourceAlbums?: App.Http.Resources.Diagnostics.AlbumTree[]) { + async function prepareAlbums(sourceAlbums?: AlbumTree) { // Use provided source, or fall back to originalAlbums for initial load const source = sourceAlbums ?? originalAlbums.value; if (source === undefined) { @@ -73,9 +75,9 @@ export function useTreeOperations( } await ensureWasm(); - const result = wasmPrepareAlbums(source as WasmAlbumTree[]); + const result = wasmPrepareAlbums(source); - albums.value = result.albums as AugmentedAlbum[]; + albums.value = result.albums; errors.value = result.errors.map(formatError); isValidated.value = result.isValid; } @@ -89,8 +91,7 @@ export function useTreeOperations( return; } // Sort current albums and revalidate without overwriting the baseline - const sortedAlbums = albums.value.slice().sort((a, b) => a._lft - b._lft); - void prepareAlbums(sortedAlbums).then(() => { + void prepareAlbums(sortedByLft(albums.value)).then(() => { errors.value.forEach((e) => toast.add({ severity: "error", summary: trans("toasts.error"), detail: e, life: 3000 })); }); } @@ -99,40 +100,35 @@ export function useTreeOperations( if (albums.value === undefined) { return; } - albums.value = wasmIncrementLft(albums.value as WasmAugmentedAlbum[], id) as AugmentedAlbum[]; + albums.value = wasmIncrementLft(albums.value, id); } function incrementRgt(id: string) { if (albums.value === undefined) { return; } - albums.value = wasmIncrementRgt(albums.value as WasmAugmentedAlbum[], id) as AugmentedAlbum[]; + albums.value = wasmIncrementRgt(albums.value, id); } function decrementLft(id: string) { if (albums.value === undefined) { return; } - albums.value = wasmDecrementLft(albums.value as WasmAugmentedAlbum[], id) as AugmentedAlbum[]; + albums.value = wasmDecrementLft(albums.value, id); } function decrementRgt(id: string) { if (albums.value === undefined) { return; } - albums.value = wasmDecrementRgt(albums.value as WasmAugmentedAlbum[], id) as AugmentedAlbum[]; + albums.value = wasmDecrementRgt(albums.value, id); } - function getModifiedAlbums(): { id: string; _lft: number; _rgt: number; parent_id: string | null }[] { + function getModifiedAlbums(): ModifiedAlbums { if (albums.value === undefined || originalAlbums.value === undefined) { - return []; + return { id: [], _lft: new Int32Array(), _rgt: new Int32Array(), parent_id: [] }; } - return wasmGetModifiedAlbums(albums.value as WasmAlbumTree[], originalAlbums.value as WasmAlbumTree[]) as { - id: string; - _lft: number; - _rgt: number; - parent_id: string | null; - }[]; + return wasmGetModifiedAlbums(albums.value, originalAlbums.value); } return { diff --git a/resources/js/v8/composables/useAdminTiles.ts b/resources/js/v8/composables/useAdminTiles.ts index ea542384514..e87a27fa7e4 100644 --- a/resources/js/v8/composables/useAdminTiles.ts +++ b/resources/js/v8/composables/useAdminTiles.ts @@ -31,7 +31,7 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left key: "settings", group: "core", label: "settings.title", - icon: "cog", + icon: "lucide:settings", to: "/admin/settings", isExternal: false, visible: computed(() => initData.value?.settings.can_edit ?? false), @@ -49,7 +49,7 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left key: "diagnostics", group: "monitoring", label: "diagnostics.title", - icon: "wrench", + icon: "lucide:stethoscope", to: "/diagnostics", isExternal: false, visible: computed(() => initData.value?.settings.can_see_diagnostics ?? false), @@ -187,7 +187,7 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left key: "maintenance", group: "core", label: "maintenance.title", - icon: "timer", + icon: "lucide:timer", to: "/admin/maintenance", isExternal: false, visible: computed(() => initData.value?.settings.can_edit ?? false), @@ -196,7 +196,7 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left key: "jobs", group: "monitoring", label: "left-menu.jobs", - icon: "project", + icon: "lucide:chart-gantt", to: "/admin/jobs", isExternal: false, visible: computed(() => initData.value?.settings.can_see_logs ?? false), @@ -206,7 +206,7 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left key: "logs", group: "monitoring", label: "left-menu.logs", - icon: "excerpt", + icon: "lucide:logs", to: "/Logs", isExternal: true, visible: computed(() => initData.value?.settings.can_see_logs ?? false), @@ -215,7 +215,7 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left key: "clockwork", group: "monitoring", label: "left-menu.clockwork", - icon: "telescope", + icon: "lucide:telescope", to: clockwork_url.value ?? "", isExternal: true, visible: computed(() => clockwork_url.value !== null && (initData.value?.settings.can_access_dev_tools ?? false)), diff --git a/resources/js/v8/menus/LeftMenu.vue b/resources/js/v8/menus/LeftMenu.vue index 2c1110dfdab..15cc322b300 100644 --- a/resources/js/v8/menus/LeftMenu.vue +++ b/resources/js/v8/menus/LeftMenu.vue @@ -93,6 +93,7 @@ import { usePhotosStore } from "@/stores/PhotosState"; import { useAlbumsStore } from "@/stores/AlbumsState"; import { useAlbumStore } from "@/stores/AlbumState"; import { usePhotoStore } from "@/stores/PhotoState"; +import { useAlbumListStore } from "@/stores/AlbumListState"; import { trans } from "laravel-vue-i18n"; import { storeToRefs } from "pinia"; @@ -103,6 +104,7 @@ const photosStore = usePhotosStore(); const albumsStore = useAlbumsStore(); const albumStore = useAlbumStore(); const photoStore = usePhotoStore(); +const albumListStore = useAlbumListStore(); const lycheeStore = useLycheeStateStore(); const { is_white_label_enabled, use_admin_dashboard } = storeToRefs(lycheeStore); @@ -168,6 +170,7 @@ function logout() { albumStore.reset(); userStore.setUser(undefined); AlbumService.clearCache(); + albumListStore.invalidate(); window.location.href = Constants.BASE_URL + "/home"; }); } diff --git a/resources/js/v8/views/BulkAlbumEdit.vue b/resources/js/v8/views/BulkAlbumEdit.vue index ae7627087bc..b433d30a3e4 100644 --- a/resources/js/v8/views/BulkAlbumEdit.vue +++ b/resources/js/v8/views/BulkAlbumEdit.vue @@ -29,17 +29,6 @@ class="flex-1 min-w-48" @update:model-value="onSearchInput" /> - - - - @@ -112,7 +101,7 @@ sticky :ui="{ base: 'table-fixed', td: 'px-4 py-0', tr: 'hover:bg-primary/5 border-none' }" :virtualize="{ estimateSize: 28, overscan: 50 }" - class="max-h-[65vh] text-sm" + class="max-h-[calc(100vh-var(--ui-header-height))] text-sm" > - - - -
- -
diff --git a/resources/js/v8/views/FixTree.vue b/resources/js/v8/views/FixTree.vue index b9d4a573ca9..b42025ac757 100644 --- a/resources/js/v8/views/FixTree.vue +++ b/resources/js/v8/views/FixTree.vue @@ -18,7 +18,6 @@ {{ $t("fix-tree.help.header") }}
    -
  • - +
    +
    + + + {{ isLTR() ? "└ " : "┘" }} + + + {{ albums!.title[index] }} + +
    +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    + {{ albums!.trimmedId[index] }} + + +
    +
    + {{ (albums!.parent_id[index] ?? "root").slice(0, 6) }} +
    +
    +
    + + + +