Commit Graph

14 Commits

Author SHA1 Message Date
Claude a8709b447a perf(i18n): load the English fallback lazily, off the startup critical path
`initI18n` runs in the client `init()` hook and blocks the first render. For
every non-English user it awaited TWO locale dictionaries back to back — the
active locale AND `en` (the fallback) — so first paint waited on two sequential
round-trips + JSON parses.

Now it awaits only the active locale, then warms `en` in the background
(non-blocking). `t()` only consults `dicts.en` for keys the active locale is
missing, and most call sites already pass an inline English fallback, so the
deferred `en` doesn't change what users see; when it arrives `dicts.en` is
reactive, so any key that fell through re-renders. English users are unchanged
(no second fetch was ever needed).

Net: non-English startup drops from two blocking locale fetches to one, halving
the i18n payload on the critical path (the server already serves these JSONs
brotli/gzip-compressed via the global CompressionLayer, so the wire cost was
already small — this removes the extra round-trip + parse from first paint).

Validated: new unit test (initI18n resolves while the en fetch is still pending,
en is kicked off in the background, and a key missing from the active locale
falls back once en lands) → 47 frontend tests green; npm run check; prod build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 16:27:58 +00:00
Claude afbc0ba515 perf(files): resolve breadcrumbs from a name cache, not N getFolder calls
Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path
segment (a depth-D folder = D requests, each no-store) purely to label the trail.

Add an id→name cache, populated wherever a name is already known:
- every listing names its children, so `cacheFolder` records them, and
- `getFolder` records the folder it fetched.

`buildCrumbs` now reads names from the cache and only fetches the ids it hasn't
seen. During normal step-by-step navigation each ancestor was named by its
parent's listing, so the breadcrumb resolves with ZERO extra requests; only a
cold deep-link fetches its unknown ancestors (still in parallel). Folder renames
update the cache immediately so the trail stays correct.

The cache is a small LRU (cap 1000 — names are tiny) and is independent of the
listing cache (names survive a listing invalidation).

Validated: 3 new unit tests (listing populates child names, getFolder records,
rename overwrites) → 46 frontend tests green; npm run check; headless render of
the real files route (list + grid) — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 16:12:20 +00:00
Claude 3125c866c7 perf(files): stale-while-revalidate folder listings with conditional ETag
Every folder navigation re-downloaded the full listing: `listFolder` cache-busted
with `?t=<ts>` + `Cache-Control: no-store`, so back/forward and re-entering a
folder always paid a full round-trip + payload.

Now the files browser caches listings in memory and serves SWR:
- On navigation it paints a previously-visited folder instantly from cache, then
  revalidates with `If-None-Match` (the backend ETag covers folders + files +
  favorite/share badges, so it's a faithful validator). Unchanged → 304 with an
  empty body; changed → 200 refreshes cache + UI.
- A generation token guards against a slow in-flight response clobbering a newer
  navigation; breadcrumbs now resolve independently so they never block the grid
  paint.
- Mutations (create/upload/rename/move/copy/delete, incl. the move dialog) go
  through `reload()`, which drops the cache and refetches fresh — no stale view
  after an action.

API layer (`folders.ts`):
- `fetchFolderListing(id, { etag?, forceRefresh? })` does the conditional fetch
  (200 → parsed listing + ETag, 304 → empty); `listFolder` stays as a
  non-conditional wrapper for the move-dialog tree.
- A small LRU (cap 40) cache with `getCachedFolder` / `cacheFolder` /
  `invalidateFolderCache`. `cache: 'no-store'` keeps the browser HTTP cache out
  of the way; revalidation is driven entirely by our own ETag.

Net: instant back/forward navigation, and an unchanged folder revalidates with a
0-byte 304 instead of re-downloading the whole listing. Validated: 7 new unit
tests (conditional If-None-Match + 304, LRU eviction/recency, invalidation),
npm run check, and a headless render of the real files route (list + grid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:39:08 +00:00
Claude 9ccaeef0ab perf(listing): return per-item is_favorite/is_shared, drop client badge fetches
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.

Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
  level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
  concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
  `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
  which already covers public links as 'token' grants — same membership the
  /grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).

Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
  badge sets straight from the listing and updates them optimistically on
  favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
  the single, authoritative, fetch-free source.

Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:21:01 +00:00
Claude 546dcef305 perf(frontend): load favorite/share badges once per session, not per navigation
The files browser re-fetched the first 200 favorites AND the first 200 outgoing
shares on every folder navigation (two round-trips each time) just to render the
star / shared badges — work that grew with how much the user browsed, for data
that barely changes.

Move both id sets into a session-scoped `badges` store: `ensureLoaded()` fetches
once (concurrent callers share one in-flight request) and every later navigation
reads from cache, so browsing costs zero extra requests. Mutations keep the cache
in sync optimistically:
- favorite toggle / batch-favorite → `setFavorite` (revert on failure),
- share creation → `markShared`, wired through a new optional `onshared` callback
  on ShareDialog (fired when a grant or public link is created). This also makes
  the shared badge appear immediately instead of only after re-navigating.

Net effect per session: badge fetches drop from O(navigations) × 2 to 2 total.
The 200-item ceiling is unchanged from before; the fully-correct fix is per-item
flags on the listing endpoint (a backend change, noted in the store).

Verified: new badges store unit tests (load-once, concurrent de-dupe, optimistic
favorite/share, reset) and a headless render of the real files route in list and
grid (virtualization intact, no runtime errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:00:14 +00:00
Claude 8794324c3d perf(frontend): virtualize grid views + the files browser (list & grid)
Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of
ResourceList (recent / favorites / shared / shared-with-me / trash / search) and
the main file browser (`files/[...path]`), in both list and grid layouts.

- VirtualList gains a real grid mode: its inner window carries the caller's grid
  class (`files-grid-view`) and lays out `columns` cards per windowed row. The
  row pitch is auto-measured (grid card height tracks column width via the 4/3
  aspect-ratio thumbnail) and re-measured on resize.
- `useVirtualWindow` now distinguishes scroll from resize and exposes a
  `resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when
  it can actually change.
- `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min`
  / gap so the windowed row count matches the browser's real wrapping exactly;
  shared by both grid callers.
- The files browser flattens folders-then-files into one discriminated `entries`
  list rendered through VirtualList (list: columns=1; grid: columns from width).
  Grouped (swimlane) views stay fully rendered, as before — they're bounded.

ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after:

  rows  | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
  ------+-------------+-----------+---------+--------------+------------
   1000 | 979→75 ms   | 19k→879   | 16→3 MB | 16→17 ms     | 0→0
   5000 | 4005→67 ms  | 95k→879   | 72→3 MB | 37→17 ms     | 26→0
  20000 |12015→65 ms  | 380k→879  |281→7 MB |197→17 ms     |1249→19

Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is
~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns,
4/3 thumbnail tiles, cards land at the expected indices). The files browser
shares the same VirtualList path (it can't be mounted headless — it depends on
$app routing/session — so it's validated via svelte-check + the production build
+ the shared, separately-benchmarked component).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:48:37 +00:00
Claude ccb85f53c0 perf(frontend): virtualize the photos timeline (square + justified)
The Photos "moments" grid rendered every tile into the DOM, so a 20k-photo
library mounted ~140k nodes / 20k <img> elements, held ~196MB JS heap, took
~3.5s to first paint and scrolled at ~6fps. It also ran the justified-layout
maths inside the template (recomputed on every reactive change) and generated
client-side video thumbnails for every off-screen video, not just visible ones.

Introduce `VirtualRows` — a variable-height, section-aware sibling of
`VirtualList` — and flatten the grouped timeline into one list of fixed-height
rows (a date header or a strip of explicitly-sized tiles) shared by both the
square and justified layouts. Only the rows near the viewport are mounted; a
prefix-sum offset table + binary search find the visible band, and a spacer
reserves the full height so the sticky header and load-more sentinel are
unchanged. The justified packing now runs once per groups/width/layout change
in a $derived, not per render.

To avoid duplicating the scroll-tracking logic across the two windowing
components, extract it into a `useVirtualWindow` composable (scroll-ancestor
detection + rAF-throttled aboveBy/viewportH signals); `VirtualList` is
refactored onto it with identical measured numbers.

Measured in headless Chromium (1280x900), synthetic photos, before/after:

  SQUARE      | mount→tiles | DOM nodes | <img> | JS heap | scroll frame
  ------------+-------------+-----------+-------+---------+-------------
   2000       | 416→94 ms   | 14k→629   |2000→96| 21→5 MB | 29→29 ms
   5000       | 916→114 ms  | 35k→629   |5000→96| 50→9 MB | 62→26 ms
  20000       | 3455→220 ms | 140k→629  |20k→96 |196→29 MB|152→33 ms

  JUSTIFIED 20000: mount 245 ms · DOM 315 · <img> 44 · heap 33 MB · ~60fps

Rendered DOM, mounted <img> count and heap are now flat (O(visible)) regardless
of library size; mount is ~16x faster and scroll jank drops from 413 to ≤24
frames. Off-screen video-thumbnail generation no longer fires for non-visible
tiles. Correctness verified by probing a deep scroll in both layouts (tiles
land within the viewport band; square cells equal-width, justified rows
aspect-preserving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:25:12 +00:00
Claude 5fdcf8cb56 perf(frontend): virtualize ResourceList rows (list view)
Large folders/collections rendered every row into the DOM, so memory and
scripting time grew linearly with item count. A 20k-row list took ~12.5s to
first paint, held ~380k DOM nodes / ~281MB JS heap, and scrolled at ~4fps.

Add a reusable, dependency-free `VirtualList` that windows rows against the
nearest scrollable ancestor (the existing `.content-area`), so the
single-scrollbar UX, sticky header and end-of-list sentinel are unchanged. It
reserves the full scroll height with a sized spacer and translates a small
window of rows; row height is auto-measured for the single-column case.

Wire it into ResourceList's flat list view (recent, favorites, shared,
shared-with-me, trash, search). Grouped sections and grid view are unchanged
for now and are the next callers of the same primitive.

Measured in headless Chromium (1280x900) with synthetic rows, before/after:

  rows  | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
  ------+-------------+-----------+---------+--------------+------------
   1000 | 632→70 ms   | 19k→351   | 16→2 MB | 17→17 ms     | 0→0
   5000 | 4401→54 ms  | 95k→351   | 72→3 MB | 47→17 ms     | 136→0
  20000 | 12577→56 ms | 380k→351  |281→6 MB |258→17 ms     | 1443→0

Rendered DOM and heap are now flat (O(visible)) regardless of dataset size;
scroll holds 60fps with zero jank. Correctness verified by probing a mid-list
scroll (rows land at the expected indices, positioned within the viewport).

eslint: disable core `no-undef` for `.svelte` (TypeScript/svelte-check already
resolve identifiers, including `<script generics>` type params it can't see).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:00:56 +00:00
Claude c2b53fcda9 fix(frontend): align sharing UI with the merged role-grants backend
The merge brought in main's ReBAC→role-grants migration, which changed the
grant contract the Svelte sharing UI (branched before it) was written
against: GrantDto dropped `permission` and now carries an explicit `role`
(owner/editor/viewer/commenter/contributor), and the "admin" role was
renamed "owner". Left unchanged, the share UI derived roles from a
now-absent `permission` field and showed every member as "viewer".

- grants.ts: ShareRole is now viewer|editor|owner; Grant carries `role`
  (not `permission`); `roleFromPermissions` → `displayRole`, which collapses
  the unexposed commenter→viewer and contributor→editor.
- ShareDialog.svelte: read each subject's role directly (role-grants emits
  one row per subject); role picker exposes Owner instead of Admin.
- shared/+page.svelte (My Shares): same owner rename; role badges run
  through displayRole so server-only roles render sensibly.

Create/update already POST `role`, so only the read/display path and the
role literal needed fixing. npm run check, test:unit (36) and build pass.
2026-06-19 13:04:32 +00:00
Claude 5494efea35 feat(frontend): port Places, People & photo tabs to the Svelte app
Bring the Photos/People/Places UI that main added (in the legacy vanilla
frontend) into the SvelteKit rewrite, wired to the now-merged backend
(/api/photos/geo, /api/people/*).

Photos page (routes/photos/+page.svelte):
- Moments | Places | People sub-tabs (the People tab appears only when the
  faces feature is enabled, via a /api/people capability probe), mirroring
  the vanilla photos sub-nav.
- Square ↔ justified layout toggle. Justified uses a Flickr-style
  row-packer over the width/height the photos list endpoint returns
  (PhotoItem), falling back to 1:1 when dimensions are missing.

New components:
- PhotoLightbox.svelte — the lightbox extracted from the photos page into a
  reusable component (items + bindable index, onDelete callback) so the
  grid, People and Places all share one implementation (no duplication).
- PlacesMap.svelte — MapLibre GL map with server-clustered markers; the
  vector basemap is optional (probed at /basemaps/basemap.pmtiles, themed
  fallback otherwise). Cluster click zooms in or opens the lightbox.
- PeopleView.svelte — identity-cluster grid → per-person photo grid, with
  rename via the in-app prompt dialog.

Supporting:
- api/endpoints/people.ts (+ peopleEnabled probe); photos.ts gains
  fetchPhotosGeo + GeoCluster + PhotoItem; fileThumbnailUrl takes a size.
- lib/vendor/maplibre.ts — minimal typings + lazy loader for the vendored
  MapLibre GL + pmtiles globals (kept any-free for ESLint).
- utils/media.ts — shared isVideo / photoTimestamp / minimalPhotoItem.
- Vendored maplibre-gl 5.24.0 + pmtiles 4.4.1 under static/vendors and an
  optional static/basemaps dir, matching the PR's vendored-asset pattern.
- New photos.tab_*/layout_*/map_* + people.* keys in en.json.

Verified: npm run check (svelte-check + eslint + stylelint + prettier),
npm run test:unit (36 pass), and npm run build all green.
2026-06-19 12:59:16 +00:00
Bradley Nelson 4ddadb5d0f update i18n 2026-06-17 22:52:06 -06:00
Bradley Nelson e6279da5ce svelte refactor 2026-06-17 22:30:56 -06:00
Bradley Nelson 89e14f8f9e visual continunity 2026-06-17 22:07:18 -06:00
Bradley Nelson daa3010458 init new frontend 2026-06-17 17:06:30 -06:00