Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
2.9 KiB
TypeScript
80 lines
2.9 KiB
TypeScript
/**
|
|
* Fires `onHit` whenever the attached element scrolls near the bottom of
|
|
* its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from
|
|
* `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel
|
|
* div with a rootMargin equal to ~4 viewport heights, so the next page is
|
|
* fetched well before the user actually reaches the end.
|
|
*
|
|
* Usage: attach to a sentinel <div /> placed at the bottom of the scroll
|
|
* area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`).
|
|
*
|
|
* <div use:nearBottom={{ onHit: fetchNextPage, enabled: canFetch }} />
|
|
*/
|
|
export interface NearBottomParams {
|
|
onHit: () => void;
|
|
/** When false the observer ignores intersections (use for the
|
|
* hasNextPage + !isFetchingNextPage gate). */
|
|
enabled?: boolean;
|
|
/** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`;
|
|
* we default to the same. Caller can pass a number for tests. */
|
|
preloadPx?: number;
|
|
/** Optional scroll root (defaults to the viewport). Pass the
|
|
* scrolling ancestor when the page itself doesn't scroll, which is
|
|
* our case — the timeline scrolls inside `<main>`. */
|
|
root?: Element | null;
|
|
}
|
|
|
|
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
|
let current: NearBottomParams = params;
|
|
let io: IntersectionObserver | null = null;
|
|
// IntersectionObserver only emits on state changes. With a 4-viewport
|
|
// preload zone, the sentinel typically stays continuously intersecting
|
|
// across a whole fetchNextPage cycle: enabled flips false (fetching),
|
|
// the IO callback runs but no-ops, enabled flips back true — and no new
|
|
// event is emitted because the intersection state never changed. We'd
|
|
// stall mid-pagination. Remember the last reported intersection so the
|
|
// next `enabled` rising edge can re-fire manually.
|
|
let lastIntersecting = false;
|
|
|
|
function buildObserver(p: NearBottomParams) {
|
|
io?.disconnect();
|
|
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
|
io = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const e of entries) {
|
|
lastIntersecting = e.isIntersecting;
|
|
}
|
|
if (lastIntersecting && current.enabled) current.onHit();
|
|
},
|
|
{
|
|
root: p.root ?? null,
|
|
// Inflate the root's bottom edge so we trip well before
|
|
// the sentinel actually enters the viewport.
|
|
rootMargin: `0px 0px ${preload}px 0px`
|
|
}
|
|
);
|
|
io.observe(node);
|
|
}
|
|
|
|
buildObserver(current);
|
|
|
|
return {
|
|
update(next: NearBottomParams) {
|
|
const rootChanged = next.root !== current.root;
|
|
const preloadChanged = next.preloadPx !== current.preloadPx;
|
|
const enabledRose = !current.enabled && !!next.enabled;
|
|
current = next;
|
|
if (rootChanged || preloadChanged) {
|
|
buildObserver(current);
|
|
return;
|
|
}
|
|
// `enabled` rising while the sentinel is still in the preload
|
|
// zone — no IO event coming, so fire manually.
|
|
if (enabledRose && lastIntersecting) current.onHit();
|
|
},
|
|
destroy() {
|
|
io?.disconnect();
|
|
}
|
|
};
|
|
}
|