The service worker and web manifest were already in place; close the remaining
gaps against the PWA spec:
- pwa.ts: resetPwa() cleared caches and unregistered the service worker but
never reloaded, despite its docstring. Add the hard reload so the page
re-fetches everything from the network after a clear.
- service-worker.ts: pre-cache the SPA entry HTML ('/') alongside build/files
so the shell — and the offline navigation fallback — works from the very
first visit, not just after a navigation has been seen by the runtime cache.
- settings: resetPwa now reloads, so the post-clear success toast (which told
the user to reload manually) is unreachable and misleading. Drop the dead
pwaSuccess state and toast; keep the disabled "Clearing…" button state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a file in a pool did a full goto('/files/<id>') to the standalone
viewer route, whose close button always routes to /files — so returning from
a file viewed inside a pool dropped the user on the global files list instead
of back in the pool.
Open the viewer as an overlay over the still-mounted pool grid via shallow
routing, mirroring the files grid: pushState keeps the pool URL (the overlay
is driven by page.state.fileId), and the back button / close does
history.back(), returning to the pool with its list and scroll intact.
Neighbours follow the pool's own order, paging in more pool files near the
end, and closing scrolls the grid back to the last-viewed file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IntersectionObserver fired only on enter/leave transitions, so a scroll
that ended with the sentinel already in range (scrolling straight to the
bottom) produced no callback and nothing loaded — the user had to scroll up
and back down to force a fresh transition, loading one chunk per cycle.
Replace the observer with a capture-phase window scroll listener (capture is
required since scroll events don't bubble; it catches scrolls from the grid's
nested <main> as well as the document), rAF-throttled, re-checking the
sentinel's viewport position on every scroll. Keep the re-check on load
completion / mount for short pages and already-in-range first renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning to the grid at a deep position (deep link / hard reload to a
file, then back → /files?anchor=<id>) used to load only a tiny forward
window at the anchor. Now the grid fills the viewport around the anchor
and pages in both directions as the user scrolls.
- loadAroundAnchor fetches a window centred on the anchor and pre-fills a
few pages each way sequentially, then centres on the anchor once. Doing
the initial fill explicitly (rather than via the sentinels) keeps the
pages contiguous and leaves the sentinels out of range, so there's no
mount-time load storm.
- loading starts true when the URL carries an ?anchor, so the child
InfiniteScroll sentinels (whose effects run before this page's reset
effect on mount) can't fire a stray page-1 loadMore that interleaves
with loadAroundAnchor.
- loadPrev pages backward (direction=backward) and prepends, then shifts
the scroller down by the added height via flushSync (no paint between
prepend and correction) so the viewport stays visually fixed.
- InfiniteScroll gains an `edge` prop; a top instance (shown only when
hasPrev) drives upward loading. Both loaders share the `loading` guard.
- Mock: honour direction=backward and emit prev_cursor; the Go backend
already supports backward keyset pagination.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lazy load fetched the entire list at once: every list's loader had a
"fill the viewport" recursion gated on
scrollContainer.scrollHeight <= clientHeight, but <main> is not the
scroller (the window/body is), so that condition is always true and it
recursed through every page (with a 10-item window, ~all pages fired at
once).
Move the filling logic into InfiniteScroll and base it on the sentinel's
viewport rect instead: load while the sentinel is within 300px of the
viewport bottom, re-checked synchronously after each load. This works
regardless of which element scrolls and loads only enough pages to reach
past the viewport.
Drop the per-page recursion (and now-unused scrollContainer refs / tick
imports) from the files, trash, tags, categories and pools lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 mock files fit in a single 100-item page, so infinite scroll never
fired. 500 yields 5 cursor pages for testing lazy loading and the
overlay viewer paging past the loaded set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer was a separate /files/[id] route, so returning tore down and
reloaded the whole grid. Now opening a file uses SvelteKit shallow
routing (pushState + page.state.fileId): the list stays mounted and the
viewer renders as a full-screen overlay on top of it, like Immich. The
URL still becomes /files/<id> and the back button (or Escape/close)
dismisses the overlay via history.back(), revealing the untouched grid —
no reload — then scrolls it to the last-viewed file instantly.
- Extract the viewer UI/logic into a reusable FileViewer component
(file fetch, preview, lazy tags, save, prev/next, keyboard).
- List: neighbours come straight from its own files[]; paging past the
loaded set pulls the next page by cursor (prefetch near the end).
- Paging uses replaceState so one back press returns to the grid.
- /files/[id] remains as a thin standalone fallback for deep links /
hard reloads, resolving neighbours via the anchor API and returning
to the grid with ?anchor=<id>.
- Remove the now-unused filesCache snapshot store (the list is never
unmounted, so there's nothing to snapshot/restore).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These three lists used a manual "Load more" button while files and trash
already lazy-loaded on scroll. Wire them to the shared InfiniteScroll
component for consistent behaviour: the offset-based load() now also runs
a viewport-fill pass (keep paging until the content overflows so the
sentinel sits below the fold), and the button + its now-unused spinner
CSS are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning from the file viewer left the grid scrolled to the top: the
position lived only in volatile module state and was never carried
anywhere, and the scroll restore ran before SvelteKit's own scroll reset
(on goto) clobbered it back to the top — worsened by the body, not
<main>, being the effective scroller, so scrollTop restoration was inert.
- The viewer's back/Escape now return to /files?anchor=<currentId> with
noScroll, carrying the position in the URL (survives reload, no longer
depends on hidden in-memory state).
- The list restores grid DATA from the snapshot as before, but scrolls in
afterNavigate — which runs AFTER SvelteKit's scroll handling — using
scrollIntoView so it works whether <main> or the window scrolls. The
?anchor is consumed (stripped via shallow replaceState) once applied.
- Deep link / hard reload with an anchor but no cached grid falls back to
loading a page anchored at that file, then scrolling to it.
- Snapshot is mirrored to sessionStorage so a refresh still restores.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer fetched /files/:id/tags eagerly alongside the file on
open, even though the Tags section sits below a full-viewport preview
and is usually never seen — needless DB load per file open.
Defer the tags fetch until the Tags section scrolls into view via an
IntersectionObserver (200px rootMargin pre-load). Re-fetches when paging
to another file while the section stays on-screen; shows a "Loading
tags…" placeholder until loaded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a failed token refresh the client cleared the auth store and threw, but
nothing navigated away, so an expired session left the user on a page that
only showed errors. Redirect to /login when the refresh token is missing or
rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EXIF values can be arrays/objects (rationals, GPS, etc.); String(val) showed
"[object Object]". Render object/array values as JSON.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- vite-mock-plugin: define the missing MockFile type and annotate the
MOCK_FILES / MOCK_TRASH arrays so restore (unshift) type-checks.
- categories/[id], tags/[id]: page.params.id is string | undefined under
noUncheckedIndexedAccess — guard loadTags and default the TagRuleEditor
tagId so the routes type-check.
svelte-check now reports 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root route rendered the default SvelteKit placeholder page. Redirect /
to /files; the layout guard still sends unauthenticated users to /login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The add-files search built a {n~%text%} filter token, but the filter DSL
only supports t=, m= and m~, so the backend rejected it as an unknown token
and no results appeared. Use the search query param (ILIKE on name) instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a file now snapshots the grid (loaded pages, cursor, scroll offset,
opened id) into a shared store, and the viewer derives prev/next from that
list instead of a separate anchored request. Returning to the grid restores
the cached list and scroll-centres the last-viewed file rather than
reloading page 1 from the top.
This also fixes two issues:
- The viewer's "previous" arrow never appeared: the backend anchor window
is forward-inclusive, so the anchor was always item 0 and prev was null.
Neighbors now come from the cached list, so paging is symmetric.
- Paging forward in the viewer prefetches further pages into the snapshot,
so navigation continues past the initially loaded set and the grid still
restores correctly.
A deep link straight to a file (empty cache) falls back to the anchored
API window as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add --color-nav-bg and --color-nav-active CSS variables
- Dark: semi-transparent purple-dark tone (rgba 52,50,73 / 0.45 bg)
- Light: light semi-transparent background, accent-tinted active highlight
- Footer background and active nav item now use variables instead of
hardcoded dark values
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Copy all icon PNGs from docs/reference (android, apple, ms, favicon sizes)
- Copy favicon.ico and browserconfig.xml
- Manifest: full icon set (36–310px), background/theme #312F45
- app.html: favicon links, full apple-touch-icon set, MS tile metas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New /files/trash page: same grid as files view, deleted files only
- Tap selects (no detail page for deleted files), long-press drag-selects
- Trash selection bar: Restore (bulk) and Delete permanently (bulk, confirmed)
- Trash icon added to files header, navigates to /files/trash
- Mock: MOCK_TRASH with 6 pre-seeded files; bulk/delete now moves to trash;
handlers for POST /files/{id}/restore and DELETE /files/{id}/permanent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Layout guard redirecting non-admins to /files
- User list page with create form and delete confirmation
- User detail page with role/permission toggles and delete
- Audit log page with filters (user, action, object type, ID, date range)
- Mock data: 5 test users, 80 audit entries, full CRUD handlers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After each batch, check if the scroll container is still shorter than
the viewport (scrollHeight <= clientHeight) and keep loading until the
scrollbar appears or there are no more files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add appSettings store (localStorage-backed) with two settings:
fileLoadLimit (default 100) and tagRuleApplyToExisting (default false)
- Settings page: new Behaviour section with numeric input for files per
page (10–500) and an on/off toggle for retroactive tag rule application
- files/+page.svelte: derive LIMIT from appSettings.fileLoadLimit so
changes take effect immediately without reload
- TagRuleEditor: pass apply_to_existing from appSettings when activating
a rule via PATCH (only sent on activation, not deactivation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Profile editor: name and optional password change with confirm field,
saves via PATCH /users/me and updates auth store
- Appearance: theme toggle button (dark/light) with sun/moon icon
- App cache: PWA reset — unregisters service workers and clears caches
- Sessions: list active sessions with parsed user agent, start date,
expiry, current badge, and terminate button per session
- Add mock handlers: PATCH /users/me, DELETE /auth/sessions/{id}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add BulkTagEditor component: loads common/partial tags via
POST /files/bulk/common-tags and applies changes via POST /files/bulk/tags
- Common tags shown solid with × to remove from all files
- Partial tags shown with dashed border and ~ indicator; clicking promotes
to common (adds to files that are missing it)
- Wire "Edit tags" button in SelectionBar to a bottom sheet with the editor
- Add mock handlers for /files/bulk/common-tags and /files/bulk/tags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /pools list page with search, sort, load-more pagination
- Add /pools/new create form (name, notes, public toggle)
- Add /pools/[id] detail page: metadata editing, ordered file grid,
drag-to-reorder, filter bar, file selection/removal, add-files overlay
- Add pool sort store (poolSorting) to sorting.ts
- Wire "Add to pool" button in SelectionBar: bottom-sheet pool picker
loads pool list, user picks one, selected files are POSTed to pool
- Add full pool mock API handlers in vite-mock-plugin.ts (CRUD + file
management: add, remove, reorder with cursor pagination)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /categories: list with colored pills, search + clear, sort/order controls
- /categories/new: create form with name, color picker, notes, is_public
- /categories/[id]: edit form + tags-in-category section with load more
- sorting.ts: add categorySorting store (name/color/created, persisted)
- mock: category CRUD, GET /categories/{id}/tags, search/sort/offset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Toggle button (filled/hollow circle) on each rule row
- Inactive rules dim to 45% opacity
- Toggle via delete + recreate with new is_active value
- Mock: track is_active per rule (Map instead of Set)
- Show all available tags by default in add-rule picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- selection.ts: store with select/deselect/toggle/enter/exit, derived count and active
- FileCard: long-press (400ms) enters selection mode, shows check overlay, blocks context menu
- Header: Select/Cancel button toggles selection mode
- SelectionBar: floating bar above navbar with count, Edit tags, Add to pool, Delete
- Shift+click range-selects between last and current index (desktop)
- Touch drag-to-select/deselect after long-press; non-passive touchmove blocks scroll only during drag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a Vite dev-only middleware that intercepts /api/v1/* requests
and returns mock responses for auth, users, files, tags, categories,
and pools. Login with any username and password "password".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds +layout.ts auth guard (redirects to /login when no token).
Adds bottom navbar with inline SVGs for Categories/Tags/Files/Pools/
Settings, active-route highlight (#343249), muted-to-bright color
transition. Adds theme store (dark/light, persisted to localStorage,
applies data-theme attribute). Hides navbar on /login route.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the base fetch wrapper (client.ts) with JWT auth headers,
automatic token refresh on 401 with request deduplication, and
typed ApiError. Adds auth.ts with login/logout/refresh/listSessions/
terminateSession. Adds authStore (stores/auth.ts) persisted to
localStorage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SvelteKit SPA mode with adapter-static (index.html fallback)
- Tailwind CSS v4 via @tailwindcss/vite with custom color palette
- CSS custom properties for dark/light theme (dark is default)
- Epilogue variable font with preload
- openapi-typescript generates src/lib/api/schema.ts from openapi.yaml
- Friendly domain type aliases in src/lib/api/types.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>