8 Commits

Author SHA1 Message Date
H1K0 18f1dbc052 fix(frontend): restore grid position via URL anchor on return from viewer
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>
2026-06-10 16:30:26 +03:00
H1K0 a1ec25a441 perf(frontend): lazy-load file tags on scroll into view
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>
2026-06-10 15:18:57 +03:00
H1K0 89ba6bae82 fix(backend): enforce private-by-default visibility and pool-op ACL
Listings returned every row regardless of ownership: GET /files, /tags,
/pools and /categories exposed other users' private items (while the
single-item GET correctly returned 403), and the pool file operations
(GET /pools/:id, /pools/:id/files, add/remove/reorder) skipped ACL
entirely, so any authenticated user could read and rewrite anyone's
private pool.

- List queries now filter to rows the caller may see (public, owned, or
  granted can_view) via a shared SQL condition; admins bypass. The viewer
  identity is taken from the request context by the service and passed to
  the repository in the list params.
- Tag/Category/Pool single-item Get now enforce CanView (File already did).
- Pool Get/ListFiles require pool view; AddFiles/RemoveFiles/Reorder
  require pool edit.

Adds regression tests for private-by-default listing (hidden / public /
granted / admin) and for pool operations rejecting a non-owner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:07:17 +03:00
H1K0 2af3c481bb fix(frontend): redirect to /login when the session can't be refreshed
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>
2026-06-10 14:55:12 +03:00
H1K0 00f63697b0 fix(frontend): render nested EXIF values instead of [object Object]
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>
2026-06-10 14:55:12 +03:00
H1K0 2ef055a41a fix(backend): pool reorder no longer drops omitted members
Reorder did DELETE all pool memberships then re-inserted only the passed
file_ids, so a paginated client that sent just the loaded pages silently
removed every other file from the pool. Reorder now places the requested
files in order and appends any members the request omitted (in their
current order), so a partial reorder is safe and correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:53:26 +03:00
H1K0 ec96fced40 perf(backend): cache thumbnail, preview and content responses
These endpoints had no Cache-Control, so the browser re-downloaded every
thumbnail on each grid mount (the client fetches them with an auth header,
which also bypasses default image caching). Returning to the grid after
viewing a file re-fetched the whole visible page of thumbnails. Add
Cache-Control: private, max-age=3600. Content is immutable per file id from
the client's perspective (there is no replace-content UI); a future replace
flow should cache-bust via a versioned URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:50:32 +03:00
H1K0 5b973cf534 fix(frontend): resolve svelte-check type errors
- 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>
2026-06-10 14:49:17 +03:00
20 changed files with 597 additions and 55 deletions
@@ -116,6 +116,12 @@ func (r *CategoryRepo) List(ctx context.Context, params port.OffsetParams) (*dom
args = append(args, "%"+params.Search+"%") args = append(args, "%"+params.Search+"%")
n++ n++
} }
// Restrict to categories the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("c", objTypeCategory, params.ViewerID, n, args)
conditions = append(conditions, aclCond)
}
where := "" where := ""
if len(conditions) > 0 { if len(conditions) > 0 {
@@ -607,6 +607,13 @@ func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*dom
} }
} }
// Restrict to files the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("f", objTypeFile, params.ViewerID, n, args)
conds = append(conds, aclCond)
}
var orderBy string var orderBy string
if hasCursor { if hasCursor {
ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond( ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond(
+54 -4
View File
@@ -183,6 +183,12 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
args = append(args, "%"+params.Search+"%") args = append(args, "%"+params.Search+"%")
n++ n++
} }
// Restrict to pools the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("p", objTypePool, params.ViewerID, n, args)
conditions = append(conditions, aclCond)
}
where := "" where := ""
if len(conditions) > 0 { if len(conditions) > 0 {
@@ -656,10 +662,54 @@ func (r *PoolRepo) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []
// Reorder // Reorder
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reorder replaces the full ordered sequence with positions 1000, 2000, … // Reorder applies the requested order to the pool. Files actually in the pool
// Only file IDs already in the pool are allowed; unknown IDs are silently // are placed in the given order; any pool members the request omitted are kept
// skipped to avoid integrity violations. // and appended in their current order. This makes a partial request (e.g. a
// paginated client that only loaded the first pages) reorder the visible prefix
// without deleting the rest. Unknown IDs are ignored.
func (r *PoolRepo) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error { func (r *PoolRepo) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
q := connOrTx(ctx, r.pool) q := connOrTx(ctx, r.pool)
return r.reassignPositions(ctx, q, poolID, fileIDs)
// Current membership, in position order.
rows, err := q.Query(ctx,
`SELECT file_id FROM data.file_pool WHERE pool_id = $1 ORDER BY position ASC, file_id ASC`, poolID)
if err != nil {
return fmt.Errorf("PoolRepo.Reorder fetch: %w", err)
}
var current []uuid.UUID
for rows.Next() {
var fid uuid.UUID
if err := rows.Scan(&fid); err != nil {
rows.Close()
return fmt.Errorf("PoolRepo.Reorder scan: %w", err)
}
current = append(current, fid)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("PoolRepo.Reorder rows: %w", err)
}
inPool := make(map[uuid.UUID]bool, len(current))
for _, fid := range current {
inPool[fid] = true
}
ordered := make([]uuid.UUID, 0, len(current))
placed := make(map[uuid.UUID]bool, len(current))
for _, fid := range fileIDs {
if inPool[fid] && !placed[fid] {
ordered = append(ordered, fid)
placed[fid] = true
}
}
// Preserve any members the request did not mention.
for _, fid := range current {
if !placed[fid] {
ordered = append(ordered, fid)
placed[fid] = true
}
}
return r.reassignPositions(ctx, q, poolID, ordered)
} }
+26
View File
@@ -65,3 +65,29 @@ func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
} }
return pool return pool
} }
// Object type IDs as seeded in core.object_types (007_seed_data.sql).
const (
objTypeFile int16 = 1
objTypeTag int16 = 2
objTypeCategory int16 = 3
objTypePool int16 = 4
)
// aclVisibilityCond returns a SQL boolean fragment that is true when the viewer
// may see the row at <alias>.id of the given object type under the
// private-by-default model: the row is public, the viewer created it, or the
// viewer holds an explicit can_view grant. objectTypeID is a trusted constant
// and is inlined; viewerID is bound as $n (referenced twice). Returns the
// fragment, the next free parameter index, and the extended args.
//
// Callers skip this entirely for admins (who bypass ACL).
func aclVisibilityCond(alias string, objectTypeID int16, viewerID int16, n int, args []any) (string, int, []any) {
cond := fmt.Sprintf(
"(%[1]s.is_public OR %[1]s.creator_id = $%[2]d OR EXISTS ("+
"SELECT 1 FROM acl.permissions p "+
"WHERE p.object_type_id = %[3]d AND p.object_id = %[1]s.id "+
"AND p.user_id = $%[2]d AND p.can_view))",
alias, n, objectTypeID)
return cond, n + 1, append(args, viewerID)
}
+6
View File
@@ -169,6 +169,12 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
args = append(args, *categoryID) args = append(args, *categoryID)
n++ n++
} }
// Restrict to tags the viewer may see (private-by-default), unless admin.
if !params.ViewerIsAdmin {
var aclCond string
aclCond, n, args = aclVisibilityCond("t", objTypeTag, params.ViewerID, n, args)
conditions = append(conditions, aclCond)
}
where := "" where := ""
if len(conditions) > 0 { if len(conditions) > 0 {
+6
View File
@@ -49,6 +49,12 @@ type FileListParams struct {
Filter string // filter DSL expression Filter string // filter DSL expression
Search string // substring match on original_name Search string // substring match on original_name
Trash bool // if true, return only soft-deleted files Trash bool // if true, return only soft-deleted files
// Visibility — populated by the service from the request context. When
// ViewerIsAdmin is false the repository restricts results to files the
// viewer may see (public, owned, or explicitly granted).
ViewerID int16
ViewerIsAdmin bool
} }
// FilePage is the result of a cursor-based file listing. // FilePage is the result of a cursor-based file listing.
+3
View File
@@ -382,6 +382,7 @@ func (h *FileHandler) GetContent(c *gin.Context) {
defer res.Body.Close() defer res.Body.Close()
c.Header("Content-Type", res.MIMEType) c.Header("Content-Type", res.MIMEType)
c.Header("Cache-Control", "private, max-age=3600")
if res.OriginalName != nil { if res.OriginalName != nil {
c.Header("Content-Disposition", c.Header("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", *res.OriginalName)) fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
@@ -457,6 +458,7 @@ func (h *FileHandler) GetThumbnail(c *gin.Context) {
defer rc.Close() defer rc.Close()
c.Header("Content-Type", "image/jpeg") c.Header("Content-Type", "image/jpeg")
c.Header("Cache-Control", "private, max-age=3600")
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, rc) //nolint:errcheck io.Copy(c.Writer, rc) //nolint:errcheck
} }
@@ -479,6 +481,7 @@ func (h *FileHandler) GetPreview(c *gin.Context) {
defer rc.Close() defer rc.Close()
c.Header("Content-Type", "image/jpeg") c.Header("Content-Type", "image/jpeg")
c.Header("Cache-Control", "private, max-age=3600")
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, rc) //nolint:errcheck io.Copy(c.Writer, rc) //nolint:errcheck
} }
+115
View File
@@ -828,6 +828,121 @@ func TestBlockRevokesActiveSessions(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String()) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
} }
// fileListIDs returns the set of file IDs visible to token via GET /files.
func (h *harness) fileListIDs(token string) map[string]bool {
h.t.Helper()
resp := h.doJSON("GET", "/files", nil, token)
require.Equal(h.t, http.StatusOK, resp.StatusCode, resp.String())
var page map[string]any
resp.decode(h.t, &page)
ids := map[string]bool{}
if items, ok := page["items"].([]any); ok {
for _, it := range items {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"].(string); ok {
ids[id] = true
}
}
}
}
return ids
}
// TestPrivateByDefaultVisibility verifies that listings only return rows the
// caller may see: private files are hidden from non-owners, public files are
// visible to all, an explicit grant reveals a private file, and admins see all.
func TestPrivateByDefaultVisibility(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/users", map[string]any{"name": "alice", "password": "alicepass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
resp = h.doJSON("POST", "/users", map[string]any{"name": "bob", "password": "bobpass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var bob map[string]any
resp.decode(t, &bob)
bobID := bob["id"].(float64)
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
file := h.uploadJPEG(aliceToken, "alice-secret.jpg")
fileID := file["id"].(string)
// Owner and admin see it; the unrelated user does not.
assert.True(t, h.fileListIDs(aliceToken)[fileID], "owner should see own file")
assert.True(t, h.fileListIDs(adminToken)[fileID], "admin should see all files")
assert.False(t, h.fileListIDs(bobToken)[fileID], "private file must not appear for a non-owner")
// Making it public reveals it to everyone.
resp = h.doJSON("PATCH", "/files/"+fileID, map[string]any{"is_public": true}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.True(t, h.fileListIDs(bobToken)[fileID], "public file should be visible to all")
// Private again → hidden; an explicit view grant reveals it.
resp = h.doJSON("PATCH", "/files/"+fileID, map[string]any{"is_public": false}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.False(t, h.fileListIDs(bobToken)[fileID])
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
"permissions": []map[string]any{{"user_id": bobID, "can_view": true, "can_edit": false}},
}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.True(t, h.fileListIDs(bobToken)[fileID], "granted file should be visible in the listing")
}
// TestPoolOperationsRequireACL verifies that a non-owner cannot view or modify
// another user's private pool's contents.
func TestPoolOperationsRequireACL(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/users", map[string]any{"name": "alice", "password": "alicepass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
resp = h.doJSON("POST", "/users", map[string]any{"name": "bob", "password": "bobpass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
file := h.uploadJPEG(aliceToken, "f.jpg")
fileID := file["id"].(string)
resp = h.doJSON("POST", "/pools", map[string]any{"name": "alice pool", "is_public": false}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{"file_ids": []string{fileID}}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// Bob cannot view the pool, list its files, or mutate its membership.
for _, c := range []struct {
method, path string
body any
}{
{"GET", "/pools/" + poolID, nil},
{"GET", "/pools/" + poolID + "/files", nil},
{"POST", "/pools/" + poolID + "/files", map[string]any{"file_ids": []string{fileID}}},
{"POST", "/pools/" + poolID + "/files/remove", map[string]any{"file_ids": []string{fileID}}},
{"PUT", "/pools/" + poolID + "/files/reorder", map[string]any{"file_ids": []string{fileID}}},
} {
resp = h.doJSON(c.method, c.path, c.body, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "%s %s: %s", c.method, c.path, resp)
}
// The owner can still list the pool's files.
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, aliceToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test helpers // Test helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+7
View File
@@ -22,6 +22,13 @@ type OffsetParams struct {
Search string Search string
Offset int Offset int
Limit int Limit int
// Visibility — populated by the service from the request context. When
// ViewerIsAdmin is false the repository restricts results to rows the viewer
// may see (public, owned, or explicitly granted). Ignored by user listing,
// which is admin-only.
ViewerID int16
ViewerIsAdmin bool
} }
// PoolFileListParams holds parameters for listing files inside a pool. // PoolFileListParams holds parameters for listing files inside a pool.
+19 -4
View File
@@ -49,14 +49,27 @@ func NewCategoryService(
// CRUD // CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// List returns a paginated, optionally filtered list of categories. // List returns a paginated list of categories the caller may see.
func (s *CategoryService) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) { func (s *CategoryService) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.categories.List(ctx, params) return s.categories.List(ctx, params)
} }
// Get returns a category by ID. // Get returns a category by ID, enforcing view ACL.
func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (*domain.Category, error) { func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
return s.categories.GetByID(ctx, id) userID, isAdmin, _ := domain.UserFromContext(ctx)
c, err := s.categories.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, c.CreatorID, c.IsPublic, categoryObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return c, nil
} }
// Create inserts a new category record. // Create inserts a new category record.
@@ -158,7 +171,9 @@ func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
// Tags in category // Tags in category
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ListTags returns a paginated list of tags belonging to this category. // ListTags returns a paginated list of tags in this category that the caller
// may see.
func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) { func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.tags.ListByCategory(ctx, categoryID, params) return s.tags.ListByCategory(ctx, categoryID, params)
} }
+3 -1
View File
@@ -406,8 +406,10 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
return updated, nil return updated, nil
} }
// List delegates to FileRepo with the given params. // List delegates to FileRepo with the given params, restricting results to
// files the caller may see (unless they are an admin).
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) { func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.files.List(ctx, params) return s.files.List(ctx, params)
} }
+71 -7
View File
@@ -41,14 +41,63 @@ func NewPoolService(
// CRUD // CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// List returns a paginated list of pools. // List returns a paginated list of pools the caller may see.
func (s *PoolService) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) { func (s *PoolService) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.pools.List(ctx, params) return s.pools.List(ctx, params)
} }
// Get returns a pool by ID. // Get returns a pool by ID, enforcing view ACL.
func (s *PoolService) Get(ctx context.Context, id uuid.UUID) (*domain.Pool, error) { func (s *PoolService) Get(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
return s.pools.GetByID(ctx, id) userID, isAdmin, _ := domain.UserFromContext(ctx)
p, err := s.pools.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, p.CreatorID, p.IsPublic, poolObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return p, nil
}
// authorizeView returns nil if the caller may view the pool, else ErrForbidden
// (or ErrNotFound if the pool does not exist).
func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
p, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, p.CreatorID, p.IsPublic, poolObjectTypeID, poolID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return nil
}
// authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden
// (or ErrNotFound if the pool does not exist).
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
p, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, p.CreatorID, poolObjectTypeID, poolID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
return nil
} }
// Create inserts a new pool. // Create inserts a new pool.
@@ -146,13 +195,21 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
// Poolfile operations // Poolfile operations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ListFiles returns cursor-paginated files within a pool ordered by position. // ListFiles returns cursor-paginated files within a pool ordered by position,
// enforcing view ACL on the pool.
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) { func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
if err := s.authorizeView(ctx, poolID); err != nil {
return nil, err
}
return s.pools.ListFiles(ctx, poolID, params) return s.pools.ListFiles(ctx, poolID, params)
} }
// AddFiles adds files to a pool at the given position (nil = append). // AddFiles adds files to a pool at the given position (nil = append), enforcing
// edit ACL on the pool.
func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error { func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
return err
}
if err := s.pools.AddFiles(ctx, poolID, fileIDs, position); err != nil { if err := s.pools.AddFiles(ctx, poolID, fileIDs, position); err != nil {
return err return err
} }
@@ -161,8 +218,11 @@ func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []
return nil return nil
} }
// RemoveFiles removes files from a pool. // RemoveFiles removes files from a pool, enforcing edit ACL on the pool.
func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error { func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
return err
}
if err := s.pools.RemoveFiles(ctx, poolID, fileIDs); err != nil { if err := s.pools.RemoveFiles(ctx, poolID, fileIDs); err != nil {
return err return err
} }
@@ -171,7 +231,11 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs
return nil return nil
} }
// Reorder sets the full ordered sequence of file IDs within a pool. // Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
// ACL on the pool.
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error { func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
return err
}
return s.pools.Reorder(ctx, poolID, fileIDs) return s.pools.Reorder(ctx, poolID, fileIDs)
} }
+16 -3
View File
@@ -54,14 +54,27 @@ func NewTagService(
// Tag CRUD // Tag CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// List returns a paginated, optionally filtered list of tags. // List returns a paginated, optionally filtered list of tags the caller may see.
func (s *TagService) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) { func (s *TagService) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
return s.tags.List(ctx, params) return s.tags.List(ctx, params)
} }
// Get returns a tag by ID. // Get returns a tag by ID, enforcing view ACL.
func (s *TagService) Get(ctx context.Context, id uuid.UUID) (*domain.Tag, error) { func (s *TagService) Get(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
return s.tags.GetByID(ctx, id) userID, isAdmin, _ := domain.UserFromContext(ctx)
t, err := s.tags.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, t.CreatorID, t.IsPublic, tagObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
return t, nil
} }
// Create inserts a new tag record. // Create inserts a new tag record.
+12 -2
View File
@@ -1,8 +1,18 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth'; import { authStore } from '$lib/stores/auth';
const BASE = '/api/v1'; const BASE = '/api/v1';
/** Clear the session and bounce to the login screen. Called when the refresh
* token is missing or rejected, so an expired session doesn't strand the user
* on a page that only shows errors. */
function endSession(): void {
authStore.set({ accessToken: null, refreshToken: null, user: null });
if (browser) void goto('/login');
}
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public readonly status: number, public readonly status: number,
@@ -21,7 +31,7 @@ let refreshPromise: Promise<void> | null = null;
async function refreshTokens(): Promise<void> { async function refreshTokens(): Promise<void> {
const { refreshToken } = get(authStore); const { refreshToken } = get(authStore);
if (!refreshToken) { if (!refreshToken) {
authStore.set({ accessToken: null, refreshToken: null, user: null }); endSession();
throw new ApiError(401, 'unauthorized', 'Session expired'); throw new ApiError(401, 'unauthorized', 'Session expired');
} }
@@ -32,7 +42,7 @@ async function refreshTokens(): Promise<void> {
}); });
if (!res.ok) { if (!res.ok) {
authStore.set({ accessToken: null, refreshToken: null, user: null }); endSession();
throw new ApiError(401, 'unauthorized', 'Session expired'); throw new ApiError(401, 'unauthorized', 'Session expired');
} }
+51 -5
View File
@@ -1,3 +1,4 @@
import { browser } from '$app/environment';
import { api } from '$lib/api/client'; import { api } from '$lib/api/client';
import type { File, FileCursorPage } from '$lib/api/types'; import type { File, FileCursorPage } from '$lib/api/types';
@@ -9,13 +10,20 @@ export interface FilesQuery {
} }
/** /**
* A snapshot of the files grid, kept in memory so that opening a file and * A snapshot of the files grid, kept so that opening a file and returning
* returning restores the same list (and scroll position) instead of reloading * restores the same list (and scroll position) instead of reloading page 1 from
* page 1 from the top. The file viewer also reads this to derive prev/next and * the top. The file viewer also reads this to derive prev/next, to find the list
* extends it as the user pages past the loaded set. * URL to return to, and extends it as the user pages past the loaded set.
*
* Held in a module variable (survives client-side navigation) AND mirrored to
* sessionStorage (survives a full reload / deep navigation within the tab).
*/ */
export interface FilesSnapshot { export interface FilesSnapshot {
query: FilesQuery; query: FilesQuery;
/** Search string of the list URL this grid was viewed at (e.g. "?filter=x"),
* so the viewer returns to the exact same filtered list rather than bare
* /files — otherwise the filter is lost and the snapshot no longer matches. */
listSearch: string;
files: File[]; files: File[];
nextCursor: string | null; nextCursor: string | null;
hasMore: boolean; hasMore: boolean;
@@ -30,27 +38,63 @@ export function queryKey(q: FilesQuery): string {
return `${q.sort}|${q.order}|${q.filter ?? ''}`; return `${q.sort}|${q.order}|${q.filter ?? ''}`;
} }
const STORAGE_KEY = 'filesSnapshot';
let snapshot: FilesSnapshot | null = null; let snapshot: FilesSnapshot | null = null;
let hydrated = false;
let loading = false; let loading = false;
/** Lazily restore the snapshot from sessionStorage the first time it's read so
* the position survives a page reload, not just client-side navigation. */
function hydrate(): void {
if (hydrated) return;
hydrated = true;
if (!browser) return;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) snapshot = JSON.parse(raw) as FilesSnapshot;
} catch {
// Corrupt/missing — start fresh.
}
}
function persist(): void {
if (!browser) return;
try {
if (snapshot) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
else sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Quota or serialization failure — non-critical, in-memory copy still works.
}
}
/** Save (replace) the current grid snapshot. */ /** Save (replace) the current grid snapshot. */
export function saveFilesSnapshot(s: FilesSnapshot): void { export function saveFilesSnapshot(s: FilesSnapshot): void {
snapshot = s; snapshot = s;
hydrated = true;
persist();
} }
/** Read the snapshot without consuming it. */ /** Read the snapshot without consuming it. */
export function peekFilesSnapshot(): FilesSnapshot | null { export function peekFilesSnapshot(): FilesSnapshot | null {
hydrate();
return snapshot; return snapshot;
} }
/** Forget the snapshot (e.g. on logout). */ /** Forget the snapshot (e.g. on logout). */
export function clearFilesSnapshot(): void { export function clearFilesSnapshot(): void {
snapshot = null; snapshot = null;
hydrated = true;
persist();
} }
/** Record the file currently being viewed so back-navigation lands on it. */ /** Record the file currently being viewed so back-navigation lands on it. */
export function setLastOpened(id: string): void { export function setLastOpened(id: string): void {
if (snapshot) snapshot = { ...snapshot, lastOpenedId: id }; hydrate();
if (snapshot) {
snapshot = { ...snapshot, lastOpenedId: id };
persist();
}
} }
/** /**
@@ -61,6 +105,7 @@ export function setLastOpened(id: string): void {
* a load is already in flight. * a load is already in flight.
*/ */
export async function loadMoreIntoSnapshot(limit: number): Promise<void> { export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
hydrate();
if (!snapshot || !snapshot.hasMore || loading) return; if (!snapshot || !snapshot.hasMore || loading) return;
loading = true; loading = true;
try { try {
@@ -77,6 +122,7 @@ export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
nextCursor: res.next_cursor ?? null, nextCursor: res.next_cursor ?? null,
hasMore: !!res.next_cursor, hasMore: !!res.next_cursor,
}; };
persist();
} catch { } catch {
// Non-critical: leave the snapshot unchanged. // Non-critical: leave the snapshot unchanged.
} finally { } finally {
@@ -48,7 +48,8 @@
void loadTags(id, 0); void loadTags(id, 0);
}); });
async function loadTags(id: string, startOffset: number) { async function loadTags(id: string | undefined, startOffset: number) {
if (!id) return;
tagsLoading = true; tagsLoading = true;
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
+81 -14
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { afterNavigate, goto, replaceState } from '$app/navigation';
import { api } from '$lib/api/client'; import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client'; import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte'; import FileCard from '$lib/components/file/FileCard.svelte';
@@ -21,7 +21,6 @@
saveFilesSnapshot, saveFilesSnapshot,
peekFilesSnapshot, peekFilesSnapshot,
queryKey, queryKey,
type FilesSnapshot,
} from '$lib/stores/filesCache'; } from '$lib/stores/filesCache';
let scrollContainer = $state<HTMLElement | undefined>(); let scrollContainer = $state<HTMLElement | undefined>();
@@ -92,48 +91,113 @@
let filterOpen = $state(false); let filterOpen = $state(false);
let filterParam = $derived(page.url.searchParams.get('filter')); let filterParam = $derived(page.url.searchParams.get('filter'));
let anchorParam = $derived(page.url.searchParams.get('anchor'));
let activeTokens = $derived(parseDslFilter(filterParam)); let activeTokens = $derived(parseDslFilter(filterParam));
let sortState = $derived($fileSorting); let sortState = $derived($fileSorting);
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`); let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state(''); let prevKey = $state('');
// Restore the grid DATA on entry. Scroll restoration is handled separately in
// afterNavigate (below), which runs after SvelteKit's own scroll reset.
$effect(() => { $effect(() => {
const key = resetKey; const key = resetKey;
if (key === prevKey) return; if (key === prevKey) return;
const firstRun = prevKey === ''; const firstRun = prevKey === '';
prevKey = key; prevKey = key;
// On the first mount, restore the grid the user left when opening a file // On entry, restore the grid the user left when opening a file (same
// (same sort/order/filter) so back-navigation keeps their place. Any later // sort/order/filter) so back-navigation keeps their place. A later change
// change means the query itself changed → reset and reload from the top. // means the query itself changed → reset and reload from the top.
const snap = peekFilesSnapshot(); const snap = peekFilesSnapshot();
if (firstRun && snap && queryKey(snap.query) === key) { if (firstRun && snap && queryKey(snap.query) === key) {
files = snap.files; files = snap.files;
nextCursor = snap.nextCursor; nextCursor = snap.nextCursor;
hasMore = snap.hasMore; hasMore = snap.hasMore;
void tick().then(() => restoreScroll(snap));
} else { } else {
files = []; files = [];
nextCursor = null; nextCursor = null;
hasMore = true; hasMore = true;
error = ''; error = '';
// Deep link / reload carrying a position anchor but no cached grid:
// load a window starting at the anchor so we have something to scroll to.
if (firstRun && anchorParam) {
void loadAroundAnchor(anchorParam);
}
} }
}); });
// Scroll the grid so the last-opened file is centred; fall back to the saved // Scroll restoration runs here because afterNavigate fires AFTER SvelteKit has
// scroll offset if that card isn't present (e.g. nothing was opened). // applied its own scroll handling, so our position wins instead of being reset
function restoreScroll(snap: FilesSnapshot) { // to the top. The anchor (last-viewed file) is read from the URL.
if (!scrollContainer) return; afterNavigate((nav) => {
const idx = snap.lastOpenedId ? files.findIndex((f) => f.id === snap.lastOpenedId) : -1; const anchor = page.url.searchParams.get('anchor');
if (idx >= 0) { if (anchor) {
const card = scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`); scrollToFile(anchor);
consumeAnchor();
return;
}
// Plain entry/reload (no explicit anchor): fall back to the snapshot's
// last-opened file so a refresh still lands near where the user was.
if (nav.type === 'enter') {
scrollToFile(peekFilesSnapshot()?.lastOpenedId ?? null);
}
});
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
// whether the actual scroller is <main> or the window) and retries across
// frames because the cards may not be laid out yet right after a restore.
function scrollToFile(anchorId: string | null) {
if (!anchorId) return;
const attempt = (tries: number) => {
const idx = files.findIndex((f) => f.id === anchorId);
const card =
idx >= 0 && scrollContainer
? scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
: null;
if (card) { if (card) {
card.scrollIntoView({ block: 'center' }); card.scrollIntoView({ block: 'center' });
return; return;
} }
if (tries > 0) requestAnimationFrame(() => attempt(tries - 1));
};
requestAnimationFrame(() => attempt(10));
}
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or
// re-fire on later interactions. Shallow update — no navigation, no scroll.
function consumeAnchor() {
const url = new URL(page.url);
if (!url.searchParams.has('anchor')) return;
url.searchParams.delete('anchor');
replaceState(`${url.pathname}${url.search}`, page.state);
}
// Fallback for a deep link / hard reload that has an anchor but no cached grid:
// fetch a page anchored at that file so we can scroll to it.
async function loadAroundAnchor(anchor: string) {
loading = true;
error = '';
try {
const params = new URLSearchParams({
anchor,
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order,
});
if (filterParam) params.set('filter', filterParam);
const res = await api.get<FileCursorPage>(`/files?${params}`);
files = res.items ?? [];
nextCursor = res.next_cursor ?? null;
hasMore = !!res.next_cursor;
await tick();
scrollToFile(anchor);
consumeAnchor();
} catch (err) {
error = err instanceof ApiError ? err.message : 'Failed to load files';
} finally {
loading = false;
} }
scrollContainer.scrollTop = snap.scrollTop;
} }
async function loadMore() { async function loadMore() {
@@ -183,6 +247,9 @@
// and scroll position instead of reloading page 1 from the top. // and scroll position instead of reloading page 1 from the top.
saveFilesSnapshot({ saveFilesSnapshot({
query: { sort: sortState.sort, order: sortState.order, filter: filterParam }, query: { sort: sortState.sort, order: sortState.order, filter: filterParam },
// Only the filter — never the transient ?anchor — defines the list URL
// to return to.
listSearch: filterParam ? `?filter=${encodeURIComponent(filterParam)}` : '',
files, files,
nextCursor, nextCursor,
hasMore, hasMore,
+92 -12
View File
@@ -23,6 +23,14 @@
let saving = $state(false); let saving = $state(false);
let error = $state(''); let error = $state('');
// Tags are loaded lazily — the Tags section sits below a full-viewport
// preview, so fetching them on open just hammers the DB for data the user
// usually never scrolls to. We fetch only once the section comes into view.
let tagsVisible = $state(false);
let tagsLoading = $state(false);
let tagsLoadedFor = $state<string | null>(null);
let tagsLoaded = $derived(tagsLoadedFor === fileId);
// Editable fields (initialised on load) // Editable fields (initialised on load)
let notes = $state(''); let notes = $state('');
let contentDatetime = $state(''); let contentDatetime = $state('');
@@ -48,13 +56,11 @@
async function loadPage(id: string) { async function loadPage(id: string) {
loading = true; loading = true;
error = ''; error = '';
// Drop the previous file's tags; they reload lazily when scrolled to.
fileTags = [];
try { try {
const [fileData, tags] = await Promise.all([ const fileData = await api.get<File>(`/files/${id}`);
api.get<File>(`/files/${id}`),
api.get<Tag[]>(`/files/${id}/tags`),
]);
file = fileData; file = fileData;
fileTags = tags;
notes = fileData.notes ?? ''; notes = fileData.notes ?? '';
contentDatetime = fileData.content_datetime contentDatetime = fileData.content_datetime
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm ? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
@@ -152,10 +158,52 @@
} }
} }
// ---- Tags ---- // ---- Tags (lazy) ----
// Fetch the current file's tags the first time the Tags section is visible.
// Re-runs when fileId changes while the section is still on-screen (e.g.
// keyboard paging while scrolled down).
$effect(() => {
const id = fileId;
if (id && tagsVisible && tagsLoadedFor !== id && !tagsLoading) {
void loadTags(id);
}
});
async function loadTags(id: string) {
tagsLoading = true;
try {
const tags = await api.get<Tag[]>(`/files/${id}/tags`);
if (page.params.id !== id) return; // user navigated on; ignore
fileTags = tags;
tagsLoadedFor = id;
} catch {
// non-critical — a later scroll into view retries
} finally {
tagsLoading = false;
}
}
// Svelte action: flips tagsVisible while the Tags section is in (or near) the
// viewport. rootMargin pre-loads just before it scrolls fully into view.
function tagsSentinel(node: HTMLElement) {
const observer = new IntersectionObserver(
(entries) => {
tagsVisible = entries[0]?.isIntersecting ?? false;
},
{ rootMargin: '200px' },
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
},
};
}
async function addTag(tagId: string) { async function addTag(tagId: string) {
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`); const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
fileTags = updated; fileTags = updated;
tagsLoadedFor = fileId ?? null;
} }
async function removeTag(tagId: string) { async function removeTag(tagId: string) {
@@ -171,11 +219,23 @@
goto(`/files/${f.id}`); goto(`/files/${f.id}`);
} }
// Return to the list the user came from, passing the current file as an
// ?anchor=<id> so the grid scrolls back to it (the position is carried in the
// URL — survives reload and doesn't depend on hidden in-memory state).
// noScroll stops SvelteKit from jumping the list to the top first.
function backToList() {
const snap = peekFilesSnapshot();
const params = new URLSearchParams(snap?.listSearch ?? '');
if (fileId) params.set('anchor', fileId);
const qs = params.toString();
goto('/files' + (qs ? `?${qs}` : ''), { noScroll: true });
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft') navigateTo(prevFile); if (e.key === 'ArrowLeft') navigateTo(prevFile);
if (e.key === 'ArrowRight') navigateTo(nextFile); if (e.key === 'ArrowRight') navigateTo(nextFile);
if (e.key === 'Escape') goto('/files'); if (e.key === 'Escape') backToList();
} }
// ---- Helpers ---- // ---- Helpers ----
@@ -183,6 +243,14 @@
if (!iso) return '—'; if (!iso) return '—';
return new Date(iso).toLocaleString(); return new Date(iso).toLocaleString();
} }
// EXIF values may be nested arrays/objects (e.g. rationals, GPS); render those
// as JSON instead of the useless "[object Object]".
function formatExifValue(val: unknown): string {
if (val === null || val === undefined) return '—';
if (typeof val === 'object') return JSON.stringify(val);
return String(val);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -196,7 +264,7 @@
<div class="viewer-page"> <div class="viewer-page">
<!-- Top bar --> <!-- Top bar -->
<div class="top-bar"> <div class="top-bar">
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files"> <button class="back-btn" onclick={backToList} aria-label="Back to files">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
@@ -299,10 +367,14 @@
{saving ? 'Saving…' : 'Save changes'} {saving ? 'Saving…' : 'Save changes'}
</button> </button>
<!-- Tags --> <!-- Tags (loaded lazily on scroll) -->
<section class="section"> <section class="section" use:tagsSentinel>
<div class="field-label">Tags</div> <div class="field-label">Tags</div>
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} /> {#if tagsLoaded}
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
{:else}
<p class="tags-loading">Loading tags…</p>
{/if}
</section> </section>
<!-- EXIF --> <!-- EXIF -->
@@ -312,7 +384,7 @@
<dl class="exif"> <dl class="exif">
{#each exifEntries as [key, val]} {#each exifEntries as [key, val]}
<dt>{key}</dt> <dt>{key}</dt>
<dd>{String(val)}</dd> <dd>{formatExifValue(val)}</dd>
{/each} {/each}
</dl> </dl>
</section> </section>
@@ -577,6 +649,14 @@
cursor: default; cursor: default;
} }
/* ---- Tags ---- */
.tags-loading {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
opacity: 0.7;
}
/* ---- EXIF ---- */ /* ---- EXIF ---- */
.exif { .exif {
display: grid; display: grid;
+1 -1
View File
@@ -172,7 +172,7 @@
<!-- Tag rules --> <!-- Tag rules -->
<section class="section"> <section class="section">
<h2 class="section-title">Implied tags</h2> <h2 class="section-title">Implied tags</h2>
<TagRuleEditor {tagId} {rules} onRulesChange={(r) => (rules = r)} /> <TagRuleEditor tagId={tagId ?? ''} {rules} onRulesChange={(r) => (rules = r)} />
</section> </section>
{/if} {/if}
+19 -1
View File
@@ -114,6 +114,24 @@ function mockThumbSvg(id: string): string {
</svg>`; </svg>`;
} }
type MockFile = {
id: string;
original_name: string;
mime_type: string;
mime_extension: string;
content_datetime: string;
notes: string | null;
metadata: unknown;
exif: Record<string, unknown>;
phash: number | null;
creator_id: number;
creator_name: string;
is_public: boolean;
is_deleted: boolean;
created_at: string;
position?: number;
};
// Trash — pre-seeded with a few deleted files // Trash — pre-seeded with a few deleted files
const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => { const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
const mimes = ['image/jpeg', 'image/png', 'image/webp']; const mimes = ['image/jpeg', 'image/png', 'image/webp'];
@@ -139,7 +157,7 @@ const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
}; };
}); });
const MOCK_FILES = Array.from({ length: 75 }, (_, i) => { const MOCK_FILES: MockFile[] = Array.from({ length: 75 }, (_, i) => {
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4']; const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
const exts = ['jpg', 'png', 'webp', 'mp4' ]; const exts = ['jpg', 'png', 'webp', 'mp4' ];
const mi = i % mimes.length; const mi = i % mimes.length;