Skip to content

Commit 4b32048

Browse files
authored
feat(036): fix direct photo links in large paginated albums via ?page=N (#4294)
1 parent 41d64b3 commit 4b32048

9 files changed

Lines changed: 477 additions & 22 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Feature Plan 036 – Photo Page URL
2+
3+
_Linked specification:_ `docs/specs/4-architecture/features/036-photo-page-url/spec.md`
4+
_Status:_ Complete
5+
_Last updated:_ 2026-04-12
6+
7+
> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant. Log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md).
8+
9+
## Vision & Success Criteria
10+
11+
Direct links to photos in large (paginated) albums reliably open the correct photo instead of silently falling back to the album view. Success is measured by S-036-01: visiting `/gallery/:albumId/:photoId?page=N` always renders the photo panel for the correct photo.
12+
13+
## Scope Alignment
14+
15+
- **In scope:** Frontend-only changes to `PhotosState`, `AlbumState`, `photoRoute`, `Album.vue`, and `AlbumPanel.vue`. Model albums and tag albums.
16+
- **Out of scope:** Backend endpoint to look up the page of a photo by ID (deferred — see Follow-ups). Smart albums, search, tag, and timeline photo views (same pattern can be applied later). Restructuring the photos store as an array of arrays.
17+
18+
## Dependencies & Interfaces
19+
20+
- `AlbumService.getPhotos(album_id, page)` — existing paginated endpoint, no changes needed.
21+
- `PhotosState` — extended with `photoPageMap`, `prependPhotos`, and page-aware `setPhotos`/`appendPhotos`.
22+
- `AlbumState` — extended with `photos_min_page`, `prepend` mode in `loadPhotos`, and `startPage` in `load()`.
23+
- Vue Router query params — `?page=N` is read and written via `route.query.page` / `router.replace()`.
24+
25+
## Assumptions & Risks
26+
27+
- **Assumptions:** The existing `Album::photos` API endpoint supports arbitrary page numbers; out-of-range pages return an empty result without error.
28+
- **Risks:** Background prepend calls run concurrently with each other. Because `photos_loading` is skipped for prepend calls, the UI guard (`loadMorePhotos` guard) won't block during background prepends — this is intentional and acceptable.
29+
- **Risks:** The `?page=N` in old/external links that were generated before this feature will be absent. Those links still fail for photos on page > 1; a backend endpoint (deferred) would resolve this.
30+
31+
## Implementation Drift Gate
32+
33+
Run `npm run build` after each increment; confirm no TypeScript or bundle errors. Run the existing PHP test suite (`php artisan test`) to confirm no regressions in backend behaviour.
34+
35+
## Increment Map
36+
37+
1. **I1 – PhotosState: photoPageMap + prependPhotos**
38+
- _Goal:_ Extend the photos store to track which page each photo was loaded from, and add a `prependPhotos` method for inserting earlier pages before the currently loaded window.
39+
- _Preconditions:_ None.
40+
- _Steps:_
41+
- Add `photoPageMap: Record<string, number>` state field; clear it in `reset()`.
42+
- Add `recordPhotoPages(photos, page)` helper.
43+
- Add `page` parameter to `setPhotos` and `appendPhotos`; call `recordPhotoPages` in each.
44+
- Implement `prependPhotos(photos, isTimeline, page)` with boundary-link repairs (non-timeline) and timeline group merging (timeline mode).
45+
- _Commands:_ `npm run build`
46+
- _Exit:_ Build passes; `photoPageMap` is populated after each load call.
47+
48+
2. **I2 – AlbumState: prepend support + startPage in load()**
49+
- _Goal:_ Teach `loadPhotos` about a `prepend` mode, and allow `load()` to jump to a specific start page and load earlier pages in background.
50+
- _Preconditions:_ I1 complete.
51+
- _Steps:_
52+
- Add `photos_min_page: number` to state; reset to 1 in `reset()`.
53+
- Add `prepend: boolean = false` parameter to `loadPhotos`; route to `prependPhotos` and skip `photos_loading` flag for prepend calls.
54+
- Change `load()` signature to `load(startPage: number = 1)`.
55+
- In `load()`, `await loadPhotos(resolvedStart, false)` first, then fire-and-forget prepend calls for pages `resolvedStart-1` down to 1 via `void this.loadPhotos(p, false, true)`.
56+
- _Commands:_ `npm run build`
57+
- _Exit:_ Build passes; album with `startPage=3` loads page 3 first, then page 2 and 1 in background.
58+
59+
3. **I3 – photoRoute: include ?page=N in photo URLs**
60+
- _Goal:_ All photo route objects generated by `photoRoute()` include `?page=N` when the photo's page is known from `photoPageMap`.
61+
- _Preconditions:_ I1 complete.
62+
- _Steps:_
63+
- Import `usePhotosStore` in `photoRoute.ts`.
64+
- Look up `photosStore.photoPageMap[photoId]`; add `query: { page: String(page) }` to album and flow-album routes when `page` is defined.
65+
- _Commands:_ `npm run build`
66+
- _Exit:_ Clicking a photo in the album view navigates to a URL that includes `?page=N`.
67+
68+
4. **I4 – Album.vue: read ?page from route + reactive photo watcher**
69+
- _Goal:_ When navigating to an album+photo URL with `?page=N`, pass `N` to `albumStore.load()` so the target page is fetched first.
70+
- _Preconditions:_ I2 complete.
71+
- _Steps:_
72+
- Add `getStartPage()` helper that parses `route.query.page` (defaults to 1 for absent or invalid values).
73+
- Pass `getStartPage()` to `albumStore.load()` in the `load()` function.
74+
- Add a `watch(() => photosStore.photos, ...)` that retries `photoStore.load()` whenever photos change and the photo is not yet loaded — handles background-prepend scenarios where the photo arrives after the initial `load()` call.
75+
- _Commands:_ `npm run build`
76+
- _Exit:_ Direct link with `?page=3` opens the photo panel; direct link without `?page` continues to work for page-1 photos.
77+
78+
5. **I5 – AlbumPanel.vue: update URL query after pagination events**
79+
- _Goal:_ Keep `?page=N` in the URL in sync with the currently visible pagination window so copied links always include the correct page.
80+
- _Preconditions:_ I3 complete.
81+
- _Steps:_
82+
- Replace `@load-more="albumStore.loadMorePhotos()"` with `@load-more="loadMorePhotosAndUpdateUrl()"`.
83+
- Implement `loadMorePhotosAndUpdateUrl()`: awaits `albumStore.loadMorePhotos()`, then calls `router.replace()` to update `?page=N`.
84+
- Change `goToPhotosPage(page)` to also call `router.replace()` after the page load resolves.
85+
- _Commands:_ `npm run build`
86+
- _Exit:_ Scrolling to the next page of photos updates the URL to `?page=N`.
87+
88+
## Scenario Tracking
89+
90+
| Scenario ID | Increment / Task reference | Notes |
91+
|---|---|---|
92+
| S-036-01 | I2, I4 | `?page=N` → target page loaded first → photo displayed |
93+
| S-036-02 | I4 | Missing `?page` defaults to page 1; page-1 photos work; others still limited |
94+
| S-036-03 | I5 | Infinite scroll / load-more updates URL |
95+
| S-036-04 | I3 | Clicking photo thumb includes `?page=N` from `photoPageMap` |
96+
| S-036-05 | I3 | Same-page navigation keeps same `?page` value |
97+
| S-036-06 | I3 | Cross-page navigation updates `?page` |
98+
99+
## Analysis Gate
100+
101+
Analysis complete on 2026-04-12. No open questions were identified as blocking implementation. One deferred item is captured in Follow-ups.
102+
103+
## Exit Criteria
104+
105+
- [x] `npm run build` passes with no TypeScript errors.
106+
- [x] Visiting `/gallery/:albumId/:photoId?page=N` opens the photo panel for a photo on page N of a large album.
107+
- [x] Scrolling down in the album view updates the URL `?page` query parameter.
108+
- [x] Clicking a photo in the album view navigates to a URL with the correct `?page`.
109+
- [x] All existing PHP tests continue to pass (`php artisan test`).
110+
111+
## Follow-ups / Backlog
112+
113+
- **Backend endpoint for photo page lookup:** A `Album::photoPage` endpoint (given `album_id` + `photo_id` returns `{ page: N }`) would allow old/external links without `?page` to resolve the correct page automatically. Deferred to a future increment.
114+
- **Same-pattern application to Search, Tag, Timeline views:** Currently only album and flow-album routes include `?page`. The same `photoPageMap` approach can be extended to those views.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Feature 036 – Photo Page URL
2+
3+
| Field | Value |
4+
|-------|-------|
5+
| Status | Draft |
6+
| Last updated | 2026-04-12 |
7+
| Owners | LycheeOrg |
8+
| Linked plan | `docs/specs/4-architecture/features/036-photo-page-url/plan.md` |
9+
| Linked tasks | `docs/specs/4-architecture/features/036-photo-page-url/tasks.md` |
10+
| Roadmap entry ||
11+
12+
> Guardrail: This specification is the single normative source of truth for the feature.
13+
14+
## Overview
15+
16+
When using a direct link to view a specific image in a large album (e.g. 538 photos), the image is not always displayed — instead only the album view appears. This occurs because album photos are paginated and the frontend only loads page 1 by default. If the target photo is on page 3, `photoStore.load()` cannot find it in the loaded photos and leaves the photo panel invisible. The fix adds a `?page=N` query parameter to photo URLs so the frontend knows which page to load first when following a direct link.
17+
18+
## Goals
19+
20+
- Photo direct links (e.g. `/gallery/:albumId/:photoId?page=3`) always open the correct photo.
21+
- The `?page=N` query parameter is included in all photo URLs generated by the frontend.
22+
- When a user scrolls down (triggering pagination), the URL updates to reflect the current page.
23+
- When switching between photos with next/previous, the URL updates if the page changes.
24+
- Previously loaded pages are loaded in background after displaying the target photo, so navigation links are eventually complete.
25+
26+
## Non-Goals
27+
28+
- A backend endpoint to look up which page a given photo is on (deferred; existing links without `?page` will not automatically resolve the page).
29+
- Changes to smart albums, search, tag, or timeline views (initial scope is model albums and tag albums only; the same pattern is reusable for other views).
30+
- Restructuring the photos store as an array of arrays (a `photoPageMap` record is sufficient).
31+
32+
## Functional Requirements
33+
34+
| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source |
35+
|---|---|---|---|---|---|---|
36+
| FR-036-01 | Photo URLs include `?page=N` when the page is known | `photoRoute()` returns a route object with `query: { page: N }` for any photo whose page is tracked in `photoPageMap` | Page number matches the page from which the photo was loaded | When page is unknown (e.g. first load, photo on page 1), no `?page` param is added (defaults to page 1) || Problem statement |
37+
| FR-036-02 | Direct link with `?page=N` loads that page first | `Album.vue` reads `route.query.page`, passes it to `albumStore.load(startPage)`, which calls `loadPhotos(startPage, false)` before loading other pages | `photoStore.load()` finds the photo and renders the panel | `startPage` out of range or non-numeric → defaults to 1 || Problem statement |
38+
| FR-036-03 | Previous pages load in background after target page | After loading page N, `AlbumState.load()` fires off `loadPhotos(p, true, true)` for pages N-1 down to 1 without awaiting | Photos from pages 1…N-1 are prepended to the store; navigation links at page boundaries are fixed | Background errors are logged and silently ignored || Problem statement |
39+
| FR-036-04 | URL updates when more photos are loaded via pagination | After `loadMorePhotos()` resolves, the URL is updated via `router.replace()` to `?page=<photos_current_page>` | URL reflects the latest loaded page ||| Problem statement |
40+
| FR-036-05 | `photoPageMap` tracks which page each photo belongs to | On `setPhotos`, `appendPhotos`, and `prependPhotos`, the `photoPageMap` record is populated for each photo | Map is cleared on `reset()` ||| Problem statement |
41+
42+
## Non-Functional Requirements
43+
44+
| ID | Requirement | Driver | Measurement | Dependencies | Source |
45+
|---|---|---|---|---|---|
46+
| NFR-036-01 | Photo display latency when following a direct link is not significantly worse than the baseline | UX — user must see the photo quickly | Photo appears after at most 2 API calls (head + target page) | `loadPhotos` must be awaited before `photoStore.load()` | Problem statement |
47+
| NFR-036-02 | Background prepend operations must not block the UI | UX — photo panel must remain interactive while previous pages load | Prepend calls are fire-and-forget (no `await`) | `prependPhotos` must be non-destructive | Problem statement |
48+
49+
## UI / Interaction Mock-ups
50+
51+
No dedicated UI changes. URL bar updates automatically.
52+
53+
```
54+
Direct link: /gallery/albumId/photoId?page=3
55+
↓ (loads page 3 first)
56+
Photo panel opens immediately
57+
↓ (background: loads pages 2, 1)
58+
Navigation links become complete
59+
```
60+
61+
## Branch & Scenario Matrix
62+
63+
| Scenario ID | Description / Expected outcome |
64+
|---|---|
65+
| S-036-01 | User visits `/gallery/albumId/photoId?page=3` → photo panel opens showing the correct photo |
66+
| S-036-02 | User visits `/gallery/albumId/photoId` (no `?page`) → falls back to page 1; if photo is on page 1 it shows; otherwise not (existing limitation) |
67+
| S-036-03 | User scrolls in album view causing page 2 to load → URL updates to `?page=2` |
68+
| S-036-04 | User clicks on a photo from page 2 in album view → URL is `/gallery/albumId/photoId?page=2` |
69+
| S-036-05 | User navigates next/previous within the same page → URL `?page` does not change (page is the same) |
70+
| S-036-06 | User navigates next/previous across a page boundary → URL `?page` updates to the adjacent page |
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Feature 036 Tasks – Photo Page URL
2+
3+
_Status: Complete_
4+
_Last updated: 2026-04-12_
5+
6+
> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes).
7+
> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions.
8+
> When referencing requirements, keep feature IDs (`FR-`), non-goal IDs, and scenario IDs (`S-036-`) inside the same parentheses immediately after the task title.
9+
10+
## Checklist
11+
12+
- [x] T-036-01 – Add `photoPageMap` state and `recordPhotoPages` helper to `PhotosState` (FR-036-05).
13+
_Intent:_ Extend the photos store with a map from photo ID to page number, cleared on `reset()` and populated by all photo-loading actions.
14+
_Verification commands:_
15+
- `npm run build`
16+
_Notes:_ `photoPageMap` is a flat `Record<string, number>`. It is not persisted between sessions — it is rebuilt each time photos are loaded.
17+
18+
- [x] T-036-02 – Add `page` parameter to `setPhotos` and `appendPhotos` (FR-036-05, S-036-04).
19+
_Intent:_ Ensure the page number is recorded in `photoPageMap` whenever photos are set or appended, so `photoRoute()` can retrieve it.
20+
_Verification commands:_
21+
- `npm run build`
22+
_Notes:_ Default value of `page = 1` maintains backwards compatibility with all existing callers.
23+
24+
- [x] T-036-03 – Implement `prependPhotos` in `PhotosState` (FR-036-03, S-036-01).
25+
_Intent:_ Add a method to insert a page of photos at the beginning of the existing collection, fixing boundary navigation links and handling timeline mode.
26+
_Verification commands:_
27+
- `npm run build`
28+
_Notes:_ For timeline mode, new groups are either merged into existing groups (same header) or `unshift`-ed to the front. Boundary links between the last prepended photo and the first existing photo are repaired.
29+
30+
- [x] T-036-04 – Add `photos_min_page` state and `prepend` mode to `loadPhotos` in `AlbumState` (FR-036-03).
31+
_Intent:_ Track the earliest loaded page and route `loadPhotos(page, false, true)` calls to `prependPhotos`. Prepend calls skip the `photos_loading` flag so they don't block the load-more guard.
32+
_Verification commands:_
33+
- `npm run build`
34+
_Notes:_ `photos_min_page` is reset to 1 (the start page) in `reset()` and in the non-prepend, non-append branch of `loadPhotos`. The pagination state (`photos_current_page` etc.) is only updated for non-prepend calls.
35+
36+
- [x] T-036-05 – Add `startPage` parameter to `load()` in `AlbumState` (FR-036-02, S-036-01).
37+
_Intent:_ When `startPage > 1`, load that page first (awaited) so the photo panel can render immediately; then fire-and-forget background prepend calls for pages `startPage-1` down to `1`.
38+
_Verification commands:_
39+
- `npm run build`
40+
_Notes:_ Prepend calls are `void this.loadPhotos(p, false, true)` — intentionally not added to the `loader` array so `albumStore.load()` resolves as soon as the target page and sub-albums are loaded, not when all previous pages are fetched.
41+
42+
- [x] T-036-06 – Update `photoRoute.ts` to include `?page=N` query param (FR-036-01, S-036-04, S-036-05, S-036-06).
43+
_Intent:_ When generating a route for a photo, look up `photosStore.photoPageMap[photoId]` and attach `query: { page: String(page) }` to album and flow-album routes.
44+
_Verification commands:_
45+
- `npm run build`
46+
_Notes:_ Search, tag, and timeline routes are left unchanged (deferred — see plan Follow-ups).
47+
48+
- [x] T-036-07 – Read `?page` from route query in `Album.vue` and pass to `albumStore.load()` (FR-036-02, S-036-01, S-036-02).
49+
_Intent:_ Add `getStartPage()` helper that safely parses `route.query.page` (returns 1 for absent/invalid). Pass the result to `albumStore.load(startPage)` in the `load()` function.
50+
_Verification commands:_
51+
- `npm run build`
52+
_Notes:_ The `refresh()` path intentionally does not pass a `startPage` (it always reloads from page 1 because refresh is used after mutations, not navigation).
53+
54+
- [x] T-036-08 – Add reactive `watch` on `photosStore.photos` in `Album.vue` to retry `photoStore.load()` (FR-036-02, FR-036-03, S-036-01).
55+
_Intent:_ When background prepend calls add photos to the store, the photo panel must reactively try again to find the photo. Without this watch, a photo on page N is displayed only after `albumStore.load()` resolves — but if `?page` was slightly wrong (or the photo arrived via a prepend), the photo panel would remain hidden.
56+
_Verification commands:_
57+
- `npm run build`
58+
_Notes:_ The guard `!photoStore.isLoaded` prevents redundant calls once the photo is already displayed.
59+
60+
- [x] T-036-09 – Update `AlbumPanel.vue` to refresh URL after pagination events (FR-036-04, S-036-03).
61+
_Intent:_ Replace the inline `albumStore.loadMorePhotos()` call with an `async loadMorePhotosAndUpdateUrl()` function that also calls `router.replace()`. Update `goToPhotosPage()` similarly.
62+
_Verification commands:_
63+
- `npm run build`
64+
_Notes:_ `router.replace()` preserves the existing route params and merges into existing query params, so the `photoId` in the URL is not lost.
65+
66+
- [x] T-036-10 – Create spec, plan, and tasks files (documentation).
67+
_Intent:_ Ensure the specification pipeline is complete for feature 036.
68+
_Verification commands:_ _(none — documentation only)_
69+
_Notes:_ Spec at `docs/specs/4-architecture/features/036-photo-page-url/spec.md`, plan at `plan.md`, tasks at `tasks.md` (this file).
70+
71+
## Notes / TODOs
72+
73+
- **Deferred:** A `Album::photoPage` backend endpoint would allow old/external URLs without `?page` to auto-resolve the correct page. This is a follow-up item in `plan.md`.
74+
- **Timeline prepend ordering:** The timeline `prependPhotos` uses `unshift` for new groups not matching an existing header. The correct visual order of prepended timeline groups depends on the album sort order; for typical date-descending albums (newest first, page 1), prepending an earlier page (older photos, higher page N) will insert the group at the front of the timeline array. This is correct for date-descending sort but may need revisiting for ascending sort.

0 commit comments

Comments
 (0)