Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions docs/specs/4-architecture/open-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon

| Question ID | Feature | Priority | Summary | Status | Opened | Updated |
|-------------|---------|----------|---------|--------|--------|---------|
| ~~Q-065-05~~ | 065 – Photo Listing Struct-of-Arrays Frontend Adoption | High | Should "is the SoA photo path active for this view" be one centralized flag consulted by the fetch dispatcher, render dispatcher, on-demand `details` fetch, and drag-select — with the `details` fetch explicitly skipped when false — or independently re-derived/tolerated at each call site? | Resolved (Option A — one centralized `isPhotoSoaActive` getter on `AlbumState.ts`; user: "Q-065-5: A") | 2026-09-06 | 2026-09-06 |
| ~~Q-065-06~~ | 065 – Photo Listing Struct-of-Arrays Frontend Adoption | Medium | `AdaptedPhotoTile` (tier 2-derived) lacks `face_count` (hover face-prefetch) and file size (list-mode metadata row) — both read by the v2 tiles this feature forks. Accept as regressions (mirroring NG11's blur-up precedent), or request a Feature 064 amendment adding one or both fields to `ratios`? | Resolved (Option A — both accepted as documented regressions, added to NG11; user: "Q-065-6: A") | 2026-09-06 | 2026-09-06 |
| ~~Q-064-01~~ | 064 – Photo Listing Struct-of-Arrays | High | `details` tier scoping mechanism for large albums — required `bucket_id` only vs. dual-mode `bucket_id`/`photo_ids[]` vs. plain pagination | Resolved (Option A — dual-mode confirmed; see Q-064-04 for the final, asymmetric cap design) | 2026-09-05 | 2026-09-05 |
| ~~Q-064-02~~ | 064 – Photo Listing Struct-of-Arrays | Medium | Should per-photo permission signals be folded into the `details`/`ratios` tiers (`owner_id` only) or served by a dedicated fourth `/rights` endpoint, mirroring Feature 061's separate album rights tier? | Resolved (Option A — fold into `ratios`/`details` as `owner_id` only, no dedicated endpoint; user confirmed 2026-09-05, "Q64-2: A") | 2026-09-05 | 2026-09-05 |
| ~~Q-064-03~~ | 064 – Photo Listing Struct-of-Arrays | Medium | Does `ratios`' "group by `bucket_id`, reproduce `buckets`' counts" correlation contract hold when `bucketable: false` (`OWNER_ID`-sorted albums)? | Resolved (Option A — the correlation guarantee is explicitly scoped to `bucketable: true` only; not about bucketing by ratio at all, just the one excluded `OWNER_ID` sort column's edge-case behavior) | 2026-09-05 | 2026-09-05 |
Expand Down Expand Up @@ -175,6 +177,26 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon

## Question Details

### ~~Q-065-05~~ · Centralized "SoA path active" flag + on-demand `details` fetch gating ✅ RESOLVED

**Status:** Resolved — **Option A**
**Feature:** 065 – Photo Listing Struct-of-Arrays Frontend Adoption
**Resolved:** 2026-09-06

**Resolution:** User confirmed Option A ("Q-065-5: A"). `AlbumState.ts` gains one centralized, computed `isPhotoSoaActive` getter (`is_struct_of_array_enabled` AND regular-`Album` parent AND no active tag/person filter), read by every SoA-photo-path consumer — the fetch dispatcher (`loadPhotosAuto()`), the render dispatcher, the on-demand `details` fetch (lightbox open, edit-dialog open), and drag-select — instead of each re-deriving the same condition independently. `loadPhotoDetails()` is now explicitly gated: when `isPhotoSoaActive` is false, the lightbox/dialog reads directly from the already-loaded, already-complete v2 `PhotoResource` object instead of calling `loadPhotoDetails()` at all — eliminating both the 404 risk (Feature 064's endpoints 404 for non-`Album` parents) and the wasted round trip (tag/person-filtered views, where v2 already has full data). Encoded in FR-065-02/09/13/14/16, G1, and a new Appendix Decision Card (Q-065-05) in `spec.md` itself.

---

### ~~Q-065-06~~ · `AdaptedPhotoTile` lacks `face_count` / file-size fields the forked v2 tiles read ✅ RESOLVED

**Status:** Resolved — **Option A**
**Feature:** 065 – Photo Listing Struct-of-Arrays Frontend Adoption
**Resolved:** 2026-09-06

**Resolution:** User confirmed Option A ("Q-065-6: A"). Both gaps are accepted, documented regressions on the SoA virtualized path, added to NG11 alongside the existing blur-up-placeholder regression: (1) hover-triggered face-recognition prefetch becomes a no-op, since `face_count` exists only in the bounded `details` tier, not `ratios`, and fetching `details` eagerly per rendered tile would defeat G5's on-demand-only discipline; (2) `PhotoListItemVirtual.vue`'s metadata row omits the file-size chip, for the same reason. No Feature 064 amendment is requested. Encoded in NG11, FR-065-04, FR-065-10.

---

### ~~Q-064-01~~ · `details` tier scoping mechanism for large albums ✅ RESOLVED

**Status:** Resolved — **Option A**
Expand Down
3 changes: 2 additions & 1 deletion docs/specs/4-architecture/roadmap.md

Large diffs are not rendered by default.

71 changes: 70 additions & 1 deletion resources/js/composables/album/dragAndSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { computeAlbumTileGeometry, resolveBreakpoint } from "@/v8/composables/al
import { buildVirtualAlbumRows, LIST_ROW_HEIGHT } from "@/v8/composables/album/virtualAlbumRows";
import { aspectRatioCssToNumber } from "@/v8/utils/aspectRatioNumber";
import { filterBucketedTiles } from "@/v8/utils/albumBucketBoundaries";
import { computeVisiblePhotoLayout, type PhotoLayoutMode } from "@/v8/composables/photo/analyticPhotoLayout";
import { useLayoutStore } from "@/stores/LayoutState";

const { canInteractAlbum, canInteractPhoto } = useAlbumActions();

Expand Down Expand Up @@ -45,6 +47,7 @@ export function useDragAndSelect(

const lycheeStore = useLycheeStateStore();
const albumStore = useAlbumStore();
const layoutStore = useLayoutStore();

const cache = {
max_height: 0,
Expand Down Expand Up @@ -149,7 +152,7 @@ export function useDragAndSelect(

cache.max_height = get_max_height();
cache.max_width = get_max_width();
cache.photo_boxes = getBoxes("data-photo-id");
cache.photo_boxes = albumStore.isPhotoSoaActive ? getPhotoBoxesV3() : getBoxes("data-photo-id");
cache.album_boxes = lycheeStore.is_struct_of_array_enabled ? getAlbumBoxesV3() : getBoxes("data-album-id");
// We use slice to Copy the array: https://stackoverflow.com/questions/7486085/copy-array-by-value
// Otherwise that would be a reference to the original array and we would modify it.
Expand Down Expand Up @@ -315,6 +318,72 @@ export function useDragAndSelect(
return boxes;
}

/**
* Flag-on (`isPhotoSoaActive`) replacement for `getBoxes("data-photo-id")`
* — a single, mode-agnostic function covering all five layout modes
* (FR-065-16), mirroring `getAlbumBoxesV3()`'s own precedent exactly:
* re-derives every photo's box analytically via
* `computeVisiblePhotoLayout()` (the same shared function
* `PhotoGridVirtual.vue` renders from — including its rating-filter-aware
* boundary recompute, Q-065-05/FR-065-19), anchored via `getBounding()` on
* the mounted `[data-photo-grid-root]` marker element (always in the DOM
* even when individual tiles are virtualized away). No DOM-query fallback
* for any mode — box geometry is always known the moment tier 2 resolves.
*/
function getPhotoBoxesV3(): Bounding[] {
const gridRootEl = document.querySelector<HTMLElement>("[data-photo-grid-root]");
if (gridRootEl === null) {
return [];
}

const mode = layoutStore.layout as PhotoLayoutMode;
const containerWidth = gridRootEl.getBoundingClientRect().width;
const config = layoutStore.config;
const target =
config === undefined
? 0
: mode === "justified"
? config.photo_layout_justified_row_height
: mode === "square"
? config.photo_layout_square_column_width
: mode === "masonry"
? config.photo_layout_masonry_column_width
: mode === "grid"
? config.photo_layout_grid_column_width
: 0;
const gap = config === undefined ? 0 : config.photo_layout_gap;

const ratingFilterActive = photosStore.photoRatingFilter !== null;
const filteredPhotoIds = ratingFilterActive ? new Set(photosStore.filteredPhotos.map((p) => p.id)) : null;

const { positioned } = computeVisiblePhotoLayout(
mode,
photosStore.photos,
albumStore.photoRatiosV3,
albumStore.photoBoundariesV3,
albumStore.photoBucketableV3,
ratingFilterActive,
filteredPhotoIds,
containerWidth,
target,
gap,
);

const gridBox = getBounding(gridRootEl, "root");

return positioned.map(({ photo, box }) => {
const top = gridBox.top + box.top;
const left = gridBox.left + box.left;
return {
id: photo.id,
top: top,
left: left,
right: left + box.width,
bottom: top + box.height,
};
});
}

function applySelection() {
// We do nothing if the position is not set
if (position.value === undefined) return;
Expand Down
7 changes: 7 additions & 0 deletions resources/js/services/album-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ const AlbumService = {
axiosWithCache.storage.remove(`album_v3_children_buckets_${album_id}`);
axiosWithCache.storage.remove(`album_v3_children_${album_id}`);
axiosWithCache.storage.remove(`album_v3_children_rights_${album_id}`);
// Clear v3 photo-listing caches (Feature 065) — mirrors the
// subalbum-children entries above. `photo_v3_details_*`'s
// per-request digest suffix isn't enumerable here (mirrors
// `getPhotos()`'s own filtered-request cache-key comment above) —
// left to expire naturally via TTL, an accepted limitation.
axiosWithCache.storage.remove(`photo_v3_buckets_${album_id}`);
axiosWithCache.storage.remove(`photo_v3_ratios_${album_id}`);
}
},

Expand Down
137 changes: 137 additions & 0 deletions resources/js/services/photo-children-v3-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import axios, { type AxiosResponse } from "axios";
import { AxiosCacheInstance } from "axios-cache-interceptor";
import Constants from "./constants";

/**
* Response body of `GET /api/v3/Albums/{album_id}/Photos/buckets`.
* Mirrors `App\Http\Resources\V3\PhotoBucketResource` — not yet generated
* into `lychee.d.ts` (Feature 064 shipped without a `php artisan
* typescript:transform` run in a dev environment), so hand-transcribed here
* from the PHP source directly. Replace with the generated
* `App.Http.Resources.V3.PhotoBucketResource` type once that command has
* been run.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix me.

*/
export type PhotoBucketResource = {
bucket_ids: string[];
counts: number[];
labels: string[];
bucketable: boolean;
};

/**
* Response body of `GET /api/v3/Albums/{album_id}/Photos` — mirrors
* `App\Http\Resources\V3\PhotoRatioResource`. Conditionally-present fields
* are simply absent from the parsed JSON object when the server omitted
* them (`Optional::create()`), so they're typed optional here rather than
* nullable.
*/
export type PhotoRatioResource = {
ids: string[];
titles: string[];
types: string[];
bucket_ids: string[];
ratios: number[];
owner_ids: number[];
is_highlighteds: boolean[];
is_validateds: boolean[];
is_videos: boolean[];
is_raws: boolean[];
is_live_photos: boolean[];
taken_ats: (string | null)[];
created_ats: string[];
taken_at_orig_tzs: (string | null)[];
rating_avgs?: number[];
rating_users?: (number | null)[];
thumb_infos?: (string | null)[];
tags?: string[][];
};

/**
* Response body of `GET /api/v3/Albums/{album_id}/Photos/details` — mirrors
* `App\Http\Resources\V3\PhotoDetailResource`.
*/
export type PhotoDetailResource = {
ids: string[];
descriptions: (string | null)[];
tags: string[][];
rating_avgs: (number | null)[];
licenses: string[];
owner_ids: number[];
nsfw_statuses: (string | null)[];
checksums: string[];
original_checksums: string[];
updated_ats: string[];
live_photo_checksums: (string | null)[];
live_photo_content_ids: (string | null)[];
live_photo_urls: (string | null)[];
face_counts: number[];
palette: (App.Http.Resources.Models.ColourPaletteResource | null)[];
size_variants: (App.Http.Resources.Models.SizeVariantsResouce | null)[];
statistics: (App.Http.Resources.Models.PhotoStatisticsResource | null)[];
makes?: (string | null)[];
models?: (string | null)[];
lenses?: (string | null)[];
apertures?: (string | null)[];
shutters?: (string | null)[];
focals?: (string | null)[];
isos?: (string | null)[];
latitudes?: (number | null)[];
longitudes?: (number | null)[];
altitudes?: (number | null)[];
locations?: (string | null)[];
};

export type PhotoDetailsScope = { bucketId: string } | { photoIds: string[] };

/**
* Consumes Feature 064's three `GET /api/v3/Albums/{album_id}/Photos*`
* endpoints. Cached the same way `AlbumChildrenV3Service`'s calls are — via
* `axios-cache-interceptor`'s enumerable `id`s — rather than a bespoke
* store-level cache.
*/
const PhotoChildrenV3Service = {
getBuckets(album_id: string): Promise<AxiosResponse<PhotoBucketResource>> {
const requester = axios as unknown as AxiosCacheInstance;
return requester.get(`${Constants.getApiUrlV3()}Albums/${album_id}/Photos/buckets`, {
data: {},
id: `photo_v3_buckets_${album_id}`,
});
},

getRatios(album_id: string): Promise<AxiosResponse<PhotoRatioResource>> {
const requester = axios as unknown as AxiosCacheInstance;
return requester.get(`${Constants.getApiUrlV3()}Albums/${album_id}/Photos`, {
data: {},
id: `photo_v3_ratios_${album_id}`,
});
},

/**
* `scope` is exactly-one-of `{bucketId}` (uncapped, no truncation) or
* `{photoIds}` (capped at 300 as input by the backend, 422 above) —
* mirrors `GetPhotoDetailsRequest`'s own mutually-exclusive validation.
* This feature only ever calls it in `{photoIds}` mode (Q-065-03), a
* handful of ids at a time.
*/
getDetails(album_id: string, scope: PhotoDetailsScope): Promise<AxiosResponse<PhotoDetailResource>> {
const requester = axios as unknown as AxiosCacheInstance;

let param = "";
let cacheDigest = "";
if ("bucketId" in scope) {
param = `?bucket_id=${encodeURIComponent(scope.bucketId)}`;
cacheDigest = `bucket_${scope.bucketId}`;
} else {
const sortedIds = [...scope.photoIds].sort();
param = `?${sortedIds.map((id) => `photo_ids[]=${encodeURIComponent(id)}`).join("&")}`;
cacheDigest = `ids_${sortedIds.join(",")}`;
}

return requester.get(`${Constants.getApiUrlV3()}Albums/${album_id}/Photos/details${param}`, {
data: {},
id: `photo_v3_details_${album_id}_${cacheDigest}`,
});
},
};

export default PhotoChildrenV3Service;
Loading