diff --git a/docs/specs/4-architecture/features/036-photo-page-url/plan.md b/docs/specs/4-architecture/features/036-photo-page-url/plan.md new file mode 100644 index 00000000000..fa9066e9ca4 --- /dev/null +++ b/docs/specs/4-architecture/features/036-photo-page-url/plan.md @@ -0,0 +1,114 @@ +# Feature Plan 036 – Photo Page URL + +_Linked specification:_ `docs/specs/4-architecture/features/036-photo-page-url/spec.md` +_Status:_ Complete +_Last updated:_ 2026-04-12 + +> 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). + +## Vision & Success Criteria + +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. + +## Scope Alignment + +- **In scope:** Frontend-only changes to `PhotosState`, `AlbumState`, `photoRoute`, `Album.vue`, and `AlbumPanel.vue`. Model albums and tag albums. +- **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. + +## Dependencies & Interfaces + +- `AlbumService.getPhotos(album_id, page)` — existing paginated endpoint, no changes needed. +- `PhotosState` — extended with `photoPageMap`, `prependPhotos`, and page-aware `setPhotos`/`appendPhotos`. +- `AlbumState` — extended with `photos_min_page`, `prepend` mode in `loadPhotos`, and `startPage` in `load()`. +- Vue Router query params — `?page=N` is read and written via `route.query.page` / `router.replace()`. + +## Assumptions & Risks + +- **Assumptions:** The existing `Album::photos` API endpoint supports arbitrary page numbers; out-of-range pages return an empty result without error. +- **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. +- **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. + +## Implementation Drift Gate + +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. + +## Increment Map + +1. **I1 – PhotosState: photoPageMap + prependPhotos** + - _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. + - _Preconditions:_ None. + - _Steps:_ + - Add `photoPageMap: Record` state field; clear it in `reset()`. + - Add `recordPhotoPages(photos, page)` helper. + - Add `page` parameter to `setPhotos` and `appendPhotos`; call `recordPhotoPages` in each. + - Implement `prependPhotos(photos, isTimeline, page)` with boundary-link repairs (non-timeline) and timeline group merging (timeline mode). + - _Commands:_ `npm run build` + - _Exit:_ Build passes; `photoPageMap` is populated after each load call. + +2. **I2 – AlbumState: prepend support + startPage in load()** + - _Goal:_ Teach `loadPhotos` about a `prepend` mode, and allow `load()` to jump to a specific start page and load earlier pages in background. + - _Preconditions:_ I1 complete. + - _Steps:_ + - Add `photos_min_page: number` to state; reset to 1 in `reset()`. + - Add `prepend: boolean = false` parameter to `loadPhotos`; route to `prependPhotos` and skip `photos_loading` flag for prepend calls. + - Change `load()` signature to `load(startPage: number = 1)`. + - 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)`. + - _Commands:_ `npm run build` + - _Exit:_ Build passes; album with `startPage=3` loads page 3 first, then page 2 and 1 in background. + +3. **I3 – photoRoute: include ?page=N in photo URLs** + - _Goal:_ All photo route objects generated by `photoRoute()` include `?page=N` when the photo's page is known from `photoPageMap`. + - _Preconditions:_ I1 complete. + - _Steps:_ + - Import `usePhotosStore` in `photoRoute.ts`. + - Look up `photosStore.photoPageMap[photoId]`; add `query: { page: String(page) }` to album and flow-album routes when `page` is defined. + - _Commands:_ `npm run build` + - _Exit:_ Clicking a photo in the album view navigates to a URL that includes `?page=N`. + +4. **I4 – Album.vue: read ?page from route + reactive photo watcher** + - _Goal:_ When navigating to an album+photo URL with `?page=N`, pass `N` to `albumStore.load()` so the target page is fetched first. + - _Preconditions:_ I2 complete. + - _Steps:_ + - Add `getStartPage()` helper that parses `route.query.page` (defaults to 1 for absent or invalid values). + - Pass `getStartPage()` to `albumStore.load()` in the `load()` function. + - 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. + - _Commands:_ `npm run build` + - _Exit:_ Direct link with `?page=3` opens the photo panel; direct link without `?page` continues to work for page-1 photos. + +5. **I5 – AlbumPanel.vue: update URL query after pagination events** + - _Goal:_ Keep `?page=N` in the URL in sync with the currently visible pagination window so copied links always include the correct page. + - _Preconditions:_ I3 complete. + - _Steps:_ + - Replace `@load-more="albumStore.loadMorePhotos()"` with `@load-more="loadMorePhotosAndUpdateUrl()"`. + - Implement `loadMorePhotosAndUpdateUrl()`: awaits `albumStore.loadMorePhotos()`, then calls `router.replace()` to update `?page=N`. + - Change `goToPhotosPage(page)` to also call `router.replace()` after the page load resolves. + - _Commands:_ `npm run build` + - _Exit:_ Scrolling to the next page of photos updates the URL to `?page=N`. + +## Scenario Tracking + +| Scenario ID | Increment / Task reference | Notes | +|---|---|---| +| S-036-01 | I2, I4 | `?page=N` → target page loaded first → photo displayed | +| S-036-02 | I4 | Missing `?page` defaults to page 1; page-1 photos work; others still limited | +| S-036-03 | I5 | Infinite scroll / load-more updates URL | +| S-036-04 | I3 | Clicking photo thumb includes `?page=N` from `photoPageMap` | +| S-036-05 | I3 | Same-page navigation keeps same `?page` value | +| S-036-06 | I3 | Cross-page navigation updates `?page` | + +## Analysis Gate + +Analysis complete on 2026-04-12. No open questions were identified as blocking implementation. One deferred item is captured in Follow-ups. + +## Exit Criteria + +- [x] `npm run build` passes with no TypeScript errors. +- [x] Visiting `/gallery/:albumId/:photoId?page=N` opens the photo panel for a photo on page N of a large album. +- [x] Scrolling down in the album view updates the URL `?page` query parameter. +- [x] Clicking a photo in the album view navigates to a URL with the correct `?page`. +- [x] All existing PHP tests continue to pass (`php artisan test`). + +## Follow-ups / Backlog + +- **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. +- **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. diff --git a/docs/specs/4-architecture/features/036-photo-page-url/spec.md b/docs/specs/4-architecture/features/036-photo-page-url/spec.md new file mode 100644 index 00000000000..35fd29cbac2 --- /dev/null +++ b/docs/specs/4-architecture/features/036-photo-page-url/spec.md @@ -0,0 +1,70 @@ +# Feature 036 – Photo Page URL + +| Field | Value | +|-------|-------| +| Status | Draft | +| Last updated | 2026-04-12 | +| Owners | LycheeOrg | +| Linked plan | `docs/specs/4-architecture/features/036-photo-page-url/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/036-photo-page-url/tasks.md` | +| Roadmap entry | – | + +> Guardrail: This specification is the single normative source of truth for the feature. + +## Overview + +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. + +## Goals + +- Photo direct links (e.g. `/gallery/:albumId/:photoId?page=3`) always open the correct photo. +- The `?page=N` query parameter is included in all photo URLs generated by the frontend. +- When a user scrolls down (triggering pagination), the URL updates to reflect the current page. +- When switching between photos with next/previous, the URL updates if the page changes. +- Previously loaded pages are loaded in background after displaying the target photo, so navigation links are eventually complete. + +## Non-Goals + +- A backend endpoint to look up which page a given photo is on (deferred; existing links without `?page` will not automatically resolve the page). +- 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). +- Restructuring the photos store as an array of arrays (a `photoPageMap` record is sufficient). + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|---|---|---|---|---|---|---| +| 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 | +| 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 | +| 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 | +| FR-036-04 | URL updates when more photos are loaded via pagination | After `loadMorePhotos()` resolves, the URL is updated via `router.replace()` to `?page=` | URL reflects the latest loaded page | – | – | Problem statement | +| 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 | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|---|---|---|---|---|---| +| 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 | +| 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 | + +## UI / Interaction Mock-ups + +No dedicated UI changes. URL bar updates automatically. + +``` +Direct link: /gallery/albumId/photoId?page=3 + ↓ (loads page 3 first) +Photo panel opens immediately + ↓ (background: loads pages 2, 1) +Navigation links become complete +``` + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|---|---| +| S-036-01 | User visits `/gallery/albumId/photoId?page=3` → photo panel opens showing the correct photo | +| 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) | +| S-036-03 | User scrolls in album view causing page 2 to load → URL updates to `?page=2` | +| S-036-04 | User clicks on a photo from page 2 in album view → URL is `/gallery/albumId/photoId?page=2` | +| S-036-05 | User navigates next/previous within the same page → URL `?page` does not change (page is the same) | +| S-036-06 | User navigates next/previous across a page boundary → URL `?page` updates to the adjacent page | diff --git a/docs/specs/4-architecture/features/036-photo-page-url/tasks.md b/docs/specs/4-architecture/features/036-photo-page-url/tasks.md new file mode 100644 index 00000000000..afc59368fa2 --- /dev/null +++ b/docs/specs/4-architecture/features/036-photo-page-url/tasks.md @@ -0,0 +1,74 @@ +# Feature 036 Tasks – Photo Page URL + +_Status: Complete_ +_Last updated: 2026-04-12_ + +> 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). +> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. +> When referencing requirements, keep feature IDs (`FR-`), non-goal IDs, and scenario IDs (`S-036-`) inside the same parentheses immediately after the task title. + +## Checklist + +- [x] T-036-01 – Add `photoPageMap` state and `recordPhotoPages` helper to `PhotosState` (FR-036-05). + _Intent:_ Extend the photos store with a map from photo ID to page number, cleared on `reset()` and populated by all photo-loading actions. + _Verification commands:_ + - `npm run build` + _Notes:_ `photoPageMap` is a flat `Record`. It is not persisted between sessions — it is rebuilt each time photos are loaded. + +- [x] T-036-02 – Add `page` parameter to `setPhotos` and `appendPhotos` (FR-036-05, S-036-04). + _Intent:_ Ensure the page number is recorded in `photoPageMap` whenever photos are set or appended, so `photoRoute()` can retrieve it. + _Verification commands:_ + - `npm run build` + _Notes:_ Default value of `page = 1` maintains backwards compatibility with all existing callers. + +- [x] T-036-03 – Implement `prependPhotos` in `PhotosState` (FR-036-03, S-036-01). + _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. + _Verification commands:_ + - `npm run build` + _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. + +- [x] T-036-04 – Add `photos_min_page` state and `prepend` mode to `loadPhotos` in `AlbumState` (FR-036-03). + _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. + _Verification commands:_ + - `npm run build` + _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. + +- [x] T-036-05 – Add `startPage` parameter to `load()` in `AlbumState` (FR-036-02, S-036-01). + _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`. + _Verification commands:_ + - `npm run build` + _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. + +- [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). + _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. + _Verification commands:_ + - `npm run build` + _Notes:_ Search, tag, and timeline routes are left unchanged (deferred — see plan Follow-ups). + +- [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). + _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. + _Verification commands:_ + - `npm run build` + _Notes:_ The `refresh()` path intentionally does not pass a `startPage` (it always reloads from page 1 because refresh is used after mutations, not navigation). + +- [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). + _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. + _Verification commands:_ + - `npm run build` + _Notes:_ The guard `!photoStore.isLoaded` prevents redundant calls once the photo is already displayed. + +- [x] T-036-09 – Update `AlbumPanel.vue` to refresh URL after pagination events (FR-036-04, S-036-03). + _Intent:_ Replace the inline `albumStore.loadMorePhotos()` call with an `async loadMorePhotosAndUpdateUrl()` function that also calls `router.replace()`. Update `goToPhotosPage()` similarly. + _Verification commands:_ + - `npm run build` + _Notes:_ `router.replace()` preserves the existing route params and merges into existing query params, so the `photoId` in the URL is not lost. + +- [x] T-036-10 – Create spec, plan, and tasks files (documentation). + _Intent:_ Ensure the specification pipeline is complete for feature 036. + _Verification commands:_ _(none — documentation only)_ + _Notes:_ Spec at `docs/specs/4-architecture/features/036-photo-page-url/spec.md`, plan at `plan.md`, tasks at `tasks.md` (this file). + +## Notes / TODOs + +- **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`. +- **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. diff --git a/resources/js/composables/album/scrollable.ts b/resources/js/composables/album/scrollable.ts index 3e256bf4476..82b68c9177f 100644 --- a/resources/js/composables/album/scrollable.ts +++ b/resources/js/composables/album/scrollable.ts @@ -16,8 +16,8 @@ export function useScrollable(toggleableStore: TogglablesStateStore, path: Ref setScroll(_v, iter + 1), 100); } else { toggleableStore.recoverAndResetScrollThumb(thumbPhotoElement); } diff --git a/resources/js/composables/photo/photoRoute.ts b/resources/js/composables/photo/photoRoute.ts index 80d43165ce0..a001714c8f9 100644 --- a/resources/js/composables/photo/photoRoute.ts +++ b/resources/js/composables/photo/photoRoute.ts @@ -1,11 +1,17 @@ import { ALL } from "@/config/constants"; import { Router } from "vue-router"; +import { usePhotosStore } from "@/stores/PhotosState"; export function usePhotoRoute(router: Router) { function getParentId(): string | undefined { return router.currentRoute.value.params.albumId as string | undefined; } + /** + * Build the route object for a given photo. + * For album and flow routes the ?page=N query param is included when the + * photo's page is known, so direct links always open the correct page. + */ function photoRoute(photoId: string) { const currentRoute = router.currentRoute.value.name as string; const albumId = getParentId(); @@ -19,15 +25,20 @@ export function usePhotoRoute(router: Router) { return { name: "tag", params: { tagId, photoId } }; } + const photosStore = usePhotosStore(); + const page = photosStore.photoPageMap[photoId]; + // Only include ?page=N when the stored value is a valid positive integer + const pageQuery = page !== undefined && Number.isInteger(page) && page >= 1 ? { page: String(page) } : {}; + if (currentRoute.startsWith("flow")) { - return { name: "flow-album", params: { albumId: albumId ?? ALL, photoId: photoId } }; + return { name: "flow-album", params: { albumId: albumId ?? ALL, photoId: photoId }, query: pageQuery }; } if (currentRoute.startsWith("timeline")) { return { name: "timeline", params: { date: router.currentRoute.value.params.date as string, photoId: photoId } }; } - return { name: "album", params: { albumId: albumId ?? ALL, photoId: photoId } }; + return { name: "album", params: { albumId: albumId ?? ALL, photoId: photoId }, query: pageQuery }; } return { getParentId, photoRoute }; diff --git a/resources/js/stores/AlbumState.ts b/resources/js/stores/AlbumState.ts index b972bafd539..af32ee3c38b 100644 --- a/resources/js/stores/AlbumState.ts +++ b/resources/js/stores/AlbumState.ts @@ -34,6 +34,8 @@ export const useAlbumStore = defineStore("album-store", { photos_per_page: 0, photos_total: 0, photos_loading: false as boolean, + /** Earliest page that has been loaded into the photos store (used for background prepend tracking). */ + photos_min_page: 1, // New pagination state for albums (via /Album::albums endpoint) albums_current_page: 1, @@ -63,6 +65,7 @@ export const useAlbumStore = defineStore("album-store", { this.photos_per_page = 0; this.photos_total = 0; this.photos_loading = false; + this.photos_min_page = 1; this.albums_current_page = 1; this.albums_last_page = 0; this.albums_per_page = 0; @@ -202,12 +205,13 @@ export const useAlbumStore = defineStore("album-store", { * * @param page - Page number to load (1-indexed) * @param append - If true, merge with existing photos; if false, replace them + * @param prepend - If true, insert photos at the beginning (for background loading of previous pages) * * Handles timeline mode: When append=true and timeline is enabled, * PhotosState.appendPhotos() intelligently merges photos into existing * timeline groups rather than creating duplicate date headers. */ - loadPhotos(page: number = 1, append: boolean = false): Promise { + loadPhotos(page: number = 1, append: boolean = false, prepend: boolean = false): Promise { const photosState = usePhotosStore(); if (this.albumId === ALL || this.albumId === undefined) { @@ -216,7 +220,11 @@ export const useAlbumStore = defineStore("album-store", { // Capture current album ID to detect navigation during loading const requestedAlbumId = this.albumId; - this.photos_loading = true; + // Background prepend operations don't show a loading indicator to + // avoid flickering and to not block the loadMorePhotos guard. + if (!prepend) { + this.photos_loading = true; + } // Extract tag filter params from state const tag_ids = this.active_tag_filter?.tag_ids ?? null; @@ -228,26 +236,40 @@ export const useAlbumStore = defineStore("album-store", { if (this.albumId !== requestedAlbumId) { return; } + // prependPhotos inserts before existing photos for prepend=true (background loading of previous pages) // appendPhotos handles timeline merging for append=true // setPhotos replaces all photos and rebuilds timeline for append=false - if (append) { - photosState.appendPhotos(data.data.photos, this.config?.is_photo_timeline_enabled ?? false); + if (prepend) { + photosState.prependPhotos(data.data.photos, this.config?.is_photo_timeline_enabled ?? false, page); + // Track the earliest page that has been prepended + if (page < this.photos_min_page) { + this.photos_min_page = page; + } + } else if (append) { + photosState.appendPhotos(data.data.photos, this.config?.is_photo_timeline_enabled ?? false, page); + this.photos_current_page = data.data.current_page; + this.photos_last_page = data.data.last_page; + this.photos_per_page = data.data.per_page; + this.photos_total = data.data.total; } else { - photosState.setPhotos(data.data.photos, this.config?.is_photo_timeline_enabled ?? false); + photosState.setPhotos(data.data.photos, this.config?.is_photo_timeline_enabled ?? false, page); + this.photos_current_page = data.data.current_page; + this.photos_last_page = data.data.last_page; + this.photos_per_page = data.data.per_page; + this.photos_total = data.data.total; + this.photos_min_page = page; } if (useLycheeStateStore().is_debug_enabled) { console.debug(`photos: ${photosState.photos.length}/${data.data.total}`); } - this.photos_current_page = data.data.current_page; - this.photos_last_page = data.data.last_page; - this.photos_per_page = data.data.per_page; - this.photos_total = data.data.total; }) .catch((error) => { console.error(error); }) .finally(() => { - this.photos_loading = false; + if (!prepend) { + this.photos_loading = false; + } }); }, @@ -313,7 +335,14 @@ export const useAlbumStore = defineStore("album-store", { return this.loadPhotos(1, false); }, - async load(): Promise { + /** + * Load the album metadata and first batch of photos. + * + * @param startPage - The page to load first. When provided (>1), that page is loaded + * immediately so a directly linked photo can be displayed, then + * pages 1…startPage-1 are loaded in the background (prepended). + */ + async load(startPage: number = 1): Promise { const togglableState = useTogglablesStateStore(); const photosState = usePhotosStore(); const albumsStore = useAlbumsStore(); @@ -349,7 +378,14 @@ export const useAlbumStore = defineStore("album-store", { this.config = data.data.config; layoutStore.layout = data.data.config.photo_layout; - const loader = [this.loadPhotos(1, false)]; + // Clamp startPage to a valid range (guard against bad query params) + const resolvedStart = startPage > 1 ? startPage : 1; + + // Load the target page first so a directly linked photo can be displayed + // immediately. Previous pages are prepended in background afterwards. + await this.loadPhotos(resolvedStart, false); + + const loader: Promise[] = []; if (data.data.config.is_model_album) { this.modelAlbum = data.data.resource as App.Http.Resources.Models.HeadAlbumResource; @@ -359,7 +395,19 @@ export const useAlbumStore = defineStore("album-store", { } else { this.smartAlbum = data.data.resource as App.Http.Resources.Models.HeadSmartAlbumResource; } + await Promise.all(loader); + + // Fire off background loading of immediately preceding pages (prepend). + // Capped at the 5 most-recent previous pages to avoid issuing too many + // concurrent requests when jumping to a high page number (e.g. page 50). + // These are intentionally NOT awaited so the photo panel can render + // while earlier pages stream in, without blocking albumStore.load(). + const backgroundPagesLimit = 5; + const firstBackgroundPage = Math.max(1, resolvedStart - backgroundPagesLimit); + for (let p = resolvedStart - 1; p >= firstBackgroundPage; p--) { + void this.loadPhotos(p, false, true); + } }) .catch((error) => { if (this._loadingAlbumId !== requestedAlbumId) { diff --git a/resources/js/stores/ModalsState.ts b/resources/js/stores/ModalsState.ts index 34fdef98881..a4c0b224b7b 100644 --- a/resources/js/stores/ModalsState.ts +++ b/resources/js/stores/ModalsState.ts @@ -110,7 +110,7 @@ export const useTogglablesStateStore = defineStore("togglables-store", { // only scroll it into view if it's currently invisible: if (!isVisible) { - thumbElem.scrollIntoView({ behavior: "smooth", block: "nearest" }); + thumbElem.scrollIntoView({ behavior: "smooth", block: "center" }); } } diff --git a/resources/js/stores/PhotosState.ts b/resources/js/stores/PhotosState.ts index c78f692715e..85ac0689bbe 100644 --- a/resources/js/stores/PhotosState.ts +++ b/resources/js/stores/PhotosState.ts @@ -11,16 +11,37 @@ export const usePhotosStore = defineStore("photos-store", { photos: [] as App.Http.Resources.Models.PhotoResource[], photosTimeline: undefined as SplitData[] | undefined, photoRatingFilter: null as PhotoRatingFilter, + /** + * Maps each loaded photo ID to the page number it was loaded from. + * Used by photoRoute() to include ?page=N in photo URLs so direct links + * open the correct page of a paginated album. + */ + photoPageMap: {} as Record, }), actions: { reset() { this.photos = []; this.photosTimeline = undefined; this.photoRatingFilter = null; + this.photoPageMap = {}; }, setPhotoRatingFilter(rating: PhotoRatingFilter) { this.photoRatingFilter = rating; }, + /** + * Recompute the `iter` offset on every SplitData chunk so that + * `chunk.iter + localIndex` always equals the item's index in the + * merged flat `photos` array. Must be called after any mutation of + * `photosTimeline` (append, prepend, or per-group data change). + */ + rebuildIterOffsets() { + if (!this.photosTimeline) return; + let offset = 0; + for (const group of this.photosTimeline) { + group.iter = offset; + offset += group.data.length; + } + }, /** * Rebuild navigation links for all photos based on their current order. * This ensures next_photo_id and previous_photo_id are always correct, @@ -36,7 +57,15 @@ export const usePhotosStore = defineStore("photos-store", { currentPhoto.next_photo_id = nextPhoto?.id ?? null; } }, - setPhotos(photos: App.Http.Resources.Models.PhotoResource[], isTimeline: boolean) { + /** + * Record the page number for a batch of photos in photoPageMap. + */ + recordPhotoPages(photos: App.Http.Resources.Models.PhotoResource[], page: number) { + photos.forEach((p) => { + this.photoPageMap[p.id] = page; + }); + }, + setPhotos(photos: App.Http.Resources.Models.PhotoResource[], isTimeline: boolean, page: number = 1) { if (isTimeline) { this.photosTimeline = spliter( photos, @@ -51,6 +80,7 @@ export const usePhotosStore = defineStore("photos-store", { this.photos = photos; this.photosTimeline = undefined; } + this.recordPhotoPages(photos, page); }, /** * Append new photos to the existing collection. @@ -60,7 +90,7 @@ export const usePhotosStore = defineStore("photos-store", { * the last photo of the existing collection and the first photo of the new batch. * Without this fix, navigating between photos would break at page boundaries. */ - appendPhotos(photos: App.Http.Resources.Models.PhotoResource[], isTimeline: boolean) { + appendPhotos(photos: App.Http.Resources.Models.PhotoResource[], isTimeline: boolean, page: number = 1) { if (isTimeline) { // Append new photos to timeline and re-merge const newTimelinePhotos = spliter( @@ -83,6 +113,8 @@ export const usePhotosStore = defineStore("photos-store", { this.photosTimeline = newTimelinePhotos; } this.photos = merge(this.photosTimeline); + // Rebuild iter offsets after merging so verifyOrder() stays consistent + this.rebuildIterOffsets(); // Rebuild all navigation links after timeline merge since photos were reordered this.rebuildNavigationLinks(); } else { @@ -102,6 +134,74 @@ export const usePhotosStore = defineStore("photos-store", { firstNewPhoto.previous_photo_id = lastOldPhoto.id; } } + this.recordPhotoPages(photos, page); + }, + /** + * Prepend new photos to the beginning of the existing collection. + * Used when loading previous pages in background after jumping directly to a later page. + * Handles both timeline and non-timeline modes. + * + * Critical: Fixes navigation links (next_photo_id/previous_photo_id) between + * the last prepended photo and the first existing photo. + */ + prependPhotos(photos: App.Http.Resources.Models.PhotoResource[], isTimeline: boolean, page: number) { + if (isTimeline) { + const newTimelinePhotos = spliter( + photos, + (p: App.Http.Resources.Models.PhotoResource) => p.timeline?.time_date ?? "", + (p: App.Http.Resources.Models.PhotoResource) => p.timeline?.format ?? "Others", + ); + // Prepend new timeline groups or merge into existing ones + if (this.photosTimeline) { + // Collect non-matching groups so they can be spliced in one operation. + // Individual unshift() calls would reverse their relative order, so we + // accumulate them first and prepend as a single batch. + const prependGroups: SplitData[] = []; + for (const newGroup of newTimelinePhotos) { + const existingGroup = this.photosTimeline.find((g) => g.header === newGroup.header); + if (existingGroup) { + existingGroup.data = [...newGroup.data, ...existingGroup.data]; + } else { + prependGroups.push(newGroup); + } + } + if (prependGroups.length > 0) { + this.photosTimeline.splice(0, 0, ...prependGroups); + } + } else { + this.photosTimeline = newTimelinePhotos; + } + this.photos = merge(this.photosTimeline); + // Rebuild iter offsets after merging so verifyOrder() stays consistent + this.rebuildIterOffsets(); + // Rebuild all navigation links after timeline merge since photos were reordered + this.rebuildNavigationLinks(); + } else { + const oldPhotoCount = this.photos.length; + // Prepend photos to the beginning of the array + this.photos = [...photos, ...this.photos]; + + // Fix navigation links within the prepended batch + for (let i = 0; i < photos.length - 1; i++) { + this.photos[i].next_photo_id = this.photos[i + 1].id; + this.photos[i + 1].previous_photo_id = this.photos[i].id; + } + // Fix navigation links at the boundary between prepended and existing photos. + // (Timeline mode uses rebuildNavigationLinks() for a full rebuild instead.) + if (photos.length > 0 && oldPhotoCount > 0) { + const lastPrependedPhoto = this.photos[photos.length - 1]; + const firstExistingPhoto = this.photos[photos.length]; + + // Connect the last prepended photo to the first existing photo + lastPrependedPhoto.next_photo_id = firstExistingPhoto.id; + // Connect the first existing photo back to the last prepended photo + firstExistingPhoto.previous_photo_id = lastPrependedPhoto.id; + } + if (photos.length > 0) { + this.photos[0].previous_photo_id = null; + } + } + this.recordPhotoPages(photos, page); }, }, getters: { diff --git a/resources/js/views/gallery-panels/Album.vue b/resources/js/views/gallery-panels/Album.vue index f8c7be82abf..0c3aa4e6c2b 100644 --- a/resources/js/views/gallery-panels/Album.vue +++ b/resources/js/views/gallery-panels/Album.vue @@ -219,8 +219,22 @@ const photosStore = usePhotosStore(); const layoutStore = useLayoutStore(); const catalogStore = useCatalogStore(); +/** + * Read the ?page=N query parameter from the current route. + * Returns 1 (first page) when the param is absent or invalid. + */ +function getStartPage(): number { + const p = route.query.page; + if (typeof p === "string") { + const parsed = parseInt(p, 10); + return isNaN(parsed) || parsed < 1 ? 1 : parsed; + } + return 1; +} + async function load() { - await Promise.allSettled([layoutStore.load(), lycheeStore.load(), userStore.load(), albumStore.load()]); + const startPage = getStartPage(); + await Promise.allSettled([layoutStore.load(), lycheeStore.load(), userStore.load(), albumStore.load(startPage)]); catalogStore.albumId = albumId.value; catalogStore.load(); orderManagement.load(); @@ -470,8 +484,23 @@ onMounted(async () => { albumId.value = props.albumId; photoId.value = props.photoId; + // For direct-access URLs (e.g. /album/xxx/photo/yyy?page=2), remember which + // photo to scroll back to so that closing the photo panel restores the + // correct thumbnail position. + if (photoId.value !== undefined) { + togglableStore.rememberScrollThumb(photoId.value); + } + await load(); - setScroll(); + + // Only restore scroll position when no photo is open. + // When a photo IS open, scroll_photo_id must remain set so the route watcher + // can use it to scroll the album back to the correct thumbnail once the user + // closes the photo. Calling setScroll() here while the photo is visible would + // consume (reset) scroll_photo_id prematurely. + if (photoId.value === undefined) { + setScroll(); + } // if #upload is in the URL, open the upload modal if (window.location.hash === "#upload") { @@ -518,6 +547,12 @@ watch( photoStore.load(); // TODO: Consider loading the next page if the photo is getting close to the end of the currently loaded photos. + + // When going back from a photo to the album (photoId becomes undefined), + // scroll the album panel to the previously viewed photo thumbnail. + if (photoId.value === undefined) { + setScroll(); + } return; }