Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18f1dbc052 | |||
| a1ec25a441 | |||
| 89ba6bae82 | |||
| 2af3c481bb | |||
| 00f63697b0 | |||
| 2ef055a41a | |||
| ec96fced40 | |||
| 5b973cf534 |
@@ -116,6 +116,12 @@ func (r *CategoryRepo) List(ctx context.Context, params port.OffsetParams) (*dom
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
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 := ""
|
||||
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
|
||||
if hasCursor {
|
||||
ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond(
|
||||
|
||||
@@ -183,6 +183,12 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
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 := ""
|
||||
if len(conditions) > 0 {
|
||||
@@ -656,10 +662,54 @@ func (r *PoolRepo) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []
|
||||
// Reorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reorder replaces the full ordered sequence with positions 1000, 2000, …
|
||||
// Only file IDs already in the pool are allowed; unknown IDs are silently
|
||||
// skipped to avoid integrity violations.
|
||||
// Reorder applies the requested order to the pool. Files actually in the pool
|
||||
// are placed in the given order; any pool members the request omitted are kept
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
@@ -65,3 +65,29 @@ func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -169,6 +169,12 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
|
||||
args = append(args, *categoryID)
|
||||
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 := ""
|
||||
if len(conditions) > 0 {
|
||||
|
||||
@@ -49,6 +49,12 @@ type FileListParams struct {
|
||||
Filter string // filter DSL expression
|
||||
Search string // substring match on original_name
|
||||
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.
|
||||
|
||||
@@ -382,6 +382,7 @@ func (h *FileHandler) GetContent(c *gin.Context) {
|
||||
defer res.Body.Close()
|
||||
|
||||
c.Header("Content-Type", res.MIMEType)
|
||||
c.Header("Cache-Control", "private, max-age=3600")
|
||||
if res.OriginalName != nil {
|
||||
c.Header("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
|
||||
@@ -457,6 +458,7 @@ func (h *FileHandler) GetThumbnail(c *gin.Context) {
|
||||
defer rc.Close()
|
||||
|
||||
c.Header("Content-Type", "image/jpeg")
|
||||
c.Header("Cache-Control", "private, max-age=3600")
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, rc) //nolint:errcheck
|
||||
}
|
||||
@@ -479,6 +481,7 @@ func (h *FileHandler) GetPreview(c *gin.Context) {
|
||||
defer rc.Close()
|
||||
|
||||
c.Header("Content-Type", "image/jpeg")
|
||||
c.Header("Cache-Control", "private, max-age=3600")
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, rc) //nolint:errcheck
|
||||
}
|
||||
|
||||
@@ -828,6 +828,121 @@ func TestBlockRevokesActiveSessions(t *testing.T) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,13 @@ type OffsetParams struct {
|
||||
Search string
|
||||
Offset 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.
|
||||
|
||||
@@ -49,14 +49,27 @@ func NewCategoryService(
|
||||
// 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) {
|
||||
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
|
||||
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) {
|
||||
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.
|
||||
@@ -158,7 +171,9 @@ func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
// 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) {
|
||||
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
|
||||
return s.tags.ListByCategory(ctx, categoryID, params)
|
||||
}
|
||||
@@ -406,8 +406,10 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
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) {
|
||||
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
|
||||
return s.files.List(ctx, params)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,14 +41,63 @@ func NewPoolService(
|
||||
// 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) {
|
||||
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
|
||||
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) {
|
||||
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.
|
||||
@@ -146,13 +195,21 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
// Pool–file 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) {
|
||||
if err := s.authorizeView(ctx, poolID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
if err := s.authorizeEdit(ctx, poolID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.pools.AddFiles(ctx, poolID, fileIDs, position); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,8 +218,11 @@ func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []
|
||||
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 {
|
||||
if err := s.authorizeEdit(ctx, poolID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.pools.RemoveFiles(ctx, poolID, fileIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -171,7 +231,11 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs
|
||||
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 {
|
||||
if err := s.authorizeEdit(ctx, poolID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pools.Reorder(ctx, poolID, fileIDs)
|
||||
}
|
||||
@@ -54,14 +54,27 @@ func NewTagService(
|
||||
// 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) {
|
||||
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
|
||||
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) {
|
||||
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.
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
@@ -21,7 +31,7 @@ let refreshPromise: Promise<void> | null = null;
|
||||
async function refreshTokens(): Promise<void> {
|
||||
const { refreshToken } = get(authStore);
|
||||
if (!refreshToken) {
|
||||
authStore.set({ accessToken: null, refreshToken: null, user: null });
|
||||
endSession();
|
||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||
}
|
||||
|
||||
@@ -32,7 +42,7 @@ async function refreshTokens(): Promise<void> {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
authStore.set({ accessToken: null, refreshToken: null, user: null });
|
||||
endSession();
|
||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { api } from '$lib/api/client';
|
||||
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
|
||||
* returning restores the same list (and scroll position) instead of reloading
|
||||
* page 1 from the top. The file viewer also reads this to derive prev/next and
|
||||
* extends it as the user pages past the loaded set.
|
||||
* A snapshot of the files grid, kept so that opening a file and returning
|
||||
* restores the same list (and scroll position) instead of reloading page 1 from
|
||||
* the top. The file viewer also reads this to derive prev/next, to find the list
|
||||
* 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 {
|
||||
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[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
@@ -30,27 +38,63 @@ export function queryKey(q: FilesQuery): string {
|
||||
return `${q.sort}|${q.order}|${q.filter ?? ''}`;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'filesSnapshot';
|
||||
|
||||
let snapshot: FilesSnapshot | null = null;
|
||||
let hydrated = 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. */
|
||||
export function saveFilesSnapshot(s: FilesSnapshot): void {
|
||||
snapshot = s;
|
||||
hydrated = true;
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Read the snapshot without consuming it. */
|
||||
export function peekFilesSnapshot(): FilesSnapshot | null {
|
||||
hydrate();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Forget the snapshot (e.g. on logout). */
|
||||
export function clearFilesSnapshot(): void {
|
||||
snapshot = null;
|
||||
hydrated = true;
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Record the file currently being viewed so back-navigation lands on it. */
|
||||
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.
|
||||
*/
|
||||
export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
|
||||
hydrate();
|
||||
if (!snapshot || !snapshot.hasMore || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
@@ -77,6 +122,7 @@ export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
|
||||
nextCursor: res.next_cursor ?? null,
|
||||
hasMore: !!res.next_cursor,
|
||||
};
|
||||
persist();
|
||||
} catch {
|
||||
// Non-critical: leave the snapshot unchanged.
|
||||
} finally {
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
void loadTags(id, 0);
|
||||
});
|
||||
|
||||
async function loadTags(id: string, startOffset: number) {
|
||||
async function loadTags(id: string | undefined, startOffset: number) {
|
||||
if (!id) return;
|
||||
tagsLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { afterNavigate, goto, replaceState } from '$app/navigation';
|
||||
import { api } from '$lib/api/client';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||
@@ -21,7 +21,6 @@
|
||||
saveFilesSnapshot,
|
||||
peekFilesSnapshot,
|
||||
queryKey,
|
||||
type FilesSnapshot,
|
||||
} from '$lib/stores/filesCache';
|
||||
|
||||
let scrollContainer = $state<HTMLElement | undefined>();
|
||||
@@ -92,48 +91,113 @@
|
||||
let filterOpen = $state(false);
|
||||
|
||||
let filterParam = $derived(page.url.searchParams.get('filter'));
|
||||
let anchorParam = $derived(page.url.searchParams.get('anchor'));
|
||||
let activeTokens = $derived(parseDslFilter(filterParam));
|
||||
let sortState = $derived($fileSorting);
|
||||
|
||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
||||
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(() => {
|
||||
const key = resetKey;
|
||||
if (key === prevKey) return;
|
||||
const firstRun = prevKey === '';
|
||||
prevKey = key;
|
||||
|
||||
// On the first mount, restore the grid the user left when opening a file
|
||||
// (same sort/order/filter) so back-navigation keeps their place. Any later
|
||||
// change means the query itself changed → reset and reload from the top.
|
||||
// On entry, restore the grid the user left when opening a file (same
|
||||
// sort/order/filter) so back-navigation keeps their place. A later change
|
||||
// means the query itself changed → reset and reload from the top.
|
||||
const snap = peekFilesSnapshot();
|
||||
if (firstRun && snap && queryKey(snap.query) === key) {
|
||||
files = snap.files;
|
||||
nextCursor = snap.nextCursor;
|
||||
hasMore = snap.hasMore;
|
||||
void tick().then(() => restoreScroll(snap));
|
||||
} else {
|
||||
files = [];
|
||||
nextCursor = null;
|
||||
hasMore = true;
|
||||
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 offset if that card isn't present (e.g. nothing was opened).
|
||||
function restoreScroll(snap: FilesSnapshot) {
|
||||
if (!scrollContainer) return;
|
||||
const idx = snap.lastOpenedId ? files.findIndex((f) => f.id === snap.lastOpenedId) : -1;
|
||||
if (idx >= 0) {
|
||||
const card = scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`);
|
||||
// Scroll restoration runs here because afterNavigate fires AFTER SvelteKit has
|
||||
// applied its own scroll handling, so our position wins instead of being reset
|
||||
// to the top. The anchor (last-viewed file) is read from the URL.
|
||||
afterNavigate((nav) => {
|
||||
const anchor = page.url.searchParams.get('anchor');
|
||||
if (anchor) {
|
||||
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) {
|
||||
card.scrollIntoView({ block: 'center' });
|
||||
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() {
|
||||
@@ -183,6 +247,9 @@
|
||||
// and scroll position instead of reloading page 1 from the top.
|
||||
saveFilesSnapshot({
|
||||
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,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
let saving = $state(false);
|
||||
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)
|
||||
let notes = $state('');
|
||||
let contentDatetime = $state('');
|
||||
@@ -48,13 +56,11 @@
|
||||
async function loadPage(id: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
// Drop the previous file's tags; they reload lazily when scrolled to.
|
||||
fileTags = [];
|
||||
try {
|
||||
const [fileData, tags] = await Promise.all([
|
||||
api.get<File>(`/files/${id}`),
|
||||
api.get<Tag[]>(`/files/${id}/tags`),
|
||||
]);
|
||||
const fileData = await api.get<File>(`/files/${id}`);
|
||||
file = fileData;
|
||||
fileTags = tags;
|
||||
notes = fileData.notes ?? '';
|
||||
contentDatetime = fileData.content_datetime
|
||||
? 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) {
|
||||
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
|
||||
fileTags = updated;
|
||||
tagsLoadedFor = fileId ?? null;
|
||||
}
|
||||
|
||||
async function removeTag(tagId: string) {
|
||||
@@ -171,11 +219,23 @@
|
||||
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) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'ArrowLeft') navigateTo(prevFile);
|
||||
if (e.key === 'ArrowRight') navigateTo(nextFile);
|
||||
if (e.key === 'Escape') goto('/files');
|
||||
if (e.key === 'Escape') backToList();
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -183,6 +243,14 @@
|
||||
if (!iso) return '—';
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -196,7 +264,7 @@
|
||||
<div class="viewer-page">
|
||||
<!-- 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">
|
||||
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
@@ -299,10 +367,14 @@
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
|
||||
<!-- Tags -->
|
||||
<section class="section">
|
||||
<!-- Tags (loaded lazily on scroll) -->
|
||||
<section class="section" use:tagsSentinel>
|
||||
<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>
|
||||
|
||||
<!-- EXIF -->
|
||||
@@ -312,7 +384,7 @@
|
||||
<dl class="exif">
|
||||
{#each exifEntries as [key, val]}
|
||||
<dt>{key}</dt>
|
||||
<dd>{String(val)}</dd>
|
||||
<dd>{formatExifValue(val)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
@@ -577,6 +649,14 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- Tags ---- */
|
||||
.tags-loading {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---- EXIF ---- */
|
||||
.exif {
|
||||
display: grid;
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
<!-- Tag rules -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Implied tags</h2>
|
||||
<TagRuleEditor {tagId} {rules} onRulesChange={(r) => (rules = r)} />
|
||||
<TagRuleEditor tagId={tagId ?? ''} {rules} onRulesChange={(r) => (rules = r)} />
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
|
||||
@@ -114,6 +114,24 @@ function mockThumbSvg(id: string): string {
|
||||
</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
|
||||
const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
|
||||
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 exts = ['jpg', 'png', 'webp', 'mp4' ];
|
||||
const mi = i % mimes.length;
|
||||
|
||||
Reference in New Issue
Block a user