style(project): format Go with gofmt, set up Prettier for the frontend
Run gofmt -w across the backend, normalising the manually-aligned := blocks to the gofmt standard. No code behaviour changes. Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit default config (tabs, single quotes) so formatting is reproducible, then run it over the whole tree. Add format / format:check npm scripts and a .prettierignore (build output, generated schema.ts, static assets). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+23
-23
@@ -59,17 +59,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
userRepo := postgres.NewUserRepo(pool)
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
sessionRepo := postgres.NewSessionRepo(pool)
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
fileRepo := postgres.NewFileRepo(pool)
|
||||||
mimeRepo := postgres.NewMimeRepo(pool)
|
mimeRepo := postgres.NewMimeRepo(pool)
|
||||||
aclRepo := postgres.NewACLRepo(pool)
|
aclRepo := postgres.NewACLRepo(pool)
|
||||||
auditRepo := postgres.NewAuditRepo(pool)
|
auditRepo := postgres.NewAuditRepo(pool)
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
tagRepo := postgres.NewTagRepo(pool)
|
||||||
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
||||||
categoryRepo := postgres.NewCategoryRepo(pool)
|
categoryRepo := postgres.NewCategoryRepo(pool)
|
||||||
poolRepo := postgres.NewPoolRepo(pool)
|
poolRepo := postgres.NewPoolRepo(pool)
|
||||||
transactor := postgres.NewTransactor(pool)
|
transactor := postgres.NewTransactor(pool)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
authSvc := service.NewAuthService(
|
authSvc := service.NewAuthService(
|
||||||
@@ -79,12 +79,12 @@ func main() {
|
|||||||
cfg.JWTAccessTTL,
|
cfg.JWTAccessTTL,
|
||||||
cfg.JWTRefreshTTL,
|
cfg.JWTRefreshTTL,
|
||||||
)
|
)
|
||||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
||||||
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
||||||
fileSvc := service.NewFileService(
|
fileSvc := service.NewFileService(
|
||||||
fileRepo,
|
fileRepo,
|
||||||
mimeRepo,
|
mimeRepo,
|
||||||
diskStorage,
|
diskStorage,
|
||||||
@@ -103,15 +103,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
poolHandler := handler.NewPoolHandler(poolSvc)
|
||||||
userHandler := handler.NewUserHandler(userSvc)
|
userHandler := handler.NewUserHandler(userSvc)
|
||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
aclHandler := handler.NewACLHandler(aclSvc)
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
auditHandler := handler.NewAuditHandler(auditSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(
|
r := handler.NewRouter(
|
||||||
authMiddleware, authHandler,
|
authMiddleware, authHandler,
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func makeCursor(r fileRow, sort, order string) fileCursor {
|
|||||||
}
|
}
|
||||||
case "mime":
|
case "mime":
|
||||||
val = r.MIMEType
|
val = r.MIMEType
|
||||||
// "created": val is empty; f.id is the sort key.
|
// "created": val is empty; f.id is the sort key.
|
||||||
}
|
}
|
||||||
return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val}
|
return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val}
|
||||||
}
|
}
|
||||||
@@ -569,7 +569,7 @@ func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*dom
|
|||||||
cursorVal = av.OriginalName
|
cursorVal = av.OriginalName
|
||||||
case "mime":
|
case "mime":
|
||||||
cursorVal = av.MIMEType
|
cursorVal = av.MIMEType
|
||||||
// "created": cursorVal stays ""; cursorID is the sort key.
|
// "created": cursorVal stays ""; cursorID is the sort key.
|
||||||
}
|
}
|
||||||
hasCursor = true
|
hasCursor = true
|
||||||
isAnchor = true
|
isAnchor = true
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
type filterTokenKind int
|
type filterTokenKind int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ftkAnd filterTokenKind = iota
|
ftkAnd filterTokenKind = iota
|
||||||
ftkOr
|
ftkOr
|
||||||
ftkNot
|
ftkNot
|
||||||
ftkLParen
|
ftkLParen
|
||||||
@@ -44,9 +44,9 @@ type filterNode interface {
|
|||||||
toSQL(n int, args []any) (string, int, []any)
|
toSQL(n int, args []any) (string, int, []any)
|
||||||
}
|
}
|
||||||
|
|
||||||
type andNode struct{ left, right filterNode }
|
type andNode struct{ left, right filterNode }
|
||||||
type orNode struct{ left, right filterNode }
|
type orNode struct{ left, right filterNode }
|
||||||
type notNode struct{ child filterNode }
|
type notNode struct{ child filterNode }
|
||||||
type leafNode struct{ tok filterToken }
|
type leafNode struct{ tok filterToken }
|
||||||
|
|
||||||
func (a *andNode) toSQL(n int, args []any) (string, int, []any) {
|
func (a *andNode) toSQL(n int, args []any) (string, int, []any) {
|
||||||
|
|||||||
@@ -23,16 +23,16 @@ import (
|
|||||||
|
|
||||||
type tagRow struct {
|
type tagRow struct {
|
||||||
ID uuid.UUID `db:"id"`
|
ID uuid.UUID `db:"id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Notes *string `db:"notes"`
|
Notes *string `db:"notes"`
|
||||||
Color *string `db:"color"`
|
Color *string `db:"color"`
|
||||||
CategoryID *uuid.UUID `db:"category_id"`
|
CategoryID *uuid.UUID `db:"category_id"`
|
||||||
CategoryName *string `db:"category_name"`
|
CategoryName *string `db:"category_name"`
|
||||||
CategoryColor *string `db:"category_color"`
|
CategoryColor *string `db:"category_color"`
|
||||||
Metadata []byte `db:"metadata"`
|
Metadata []byte `db:"metadata"`
|
||||||
CreatorID int16 `db:"creator_id"`
|
CreatorID int16 `db:"creator_id"`
|
||||||
CreatorName string `db:"creator_name"`
|
CreatorName string `db:"creator_name"`
|
||||||
IsPublic bool `db:"is_public"`
|
IsPublic bool `db:"is_public"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tagRowWithTotal struct {
|
type tagRowWithTotal struct {
|
||||||
@@ -43,8 +43,8 @@ type tagRowWithTotal struct {
|
|||||||
type tagRuleRow struct {
|
type tagRuleRow struct {
|
||||||
WhenTagID uuid.UUID `db:"when_tag_id"`
|
WhenTagID uuid.UUID `db:"when_tag_id"`
|
||||||
ThenTagID uuid.UUID `db:"then_tag_id"`
|
ThenTagID uuid.UUID `db:"then_tag_id"`
|
||||||
ThenTagName string `db:"then_tag_name"`
|
ThenTagName string `db:"then_tag_name"`
|
||||||
IsActive bool `db:"is_active"`
|
IsActive bool `db:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ type ObjectType struct {
|
|||||||
|
|
||||||
// Permission represents a per-object access entry for a user.
|
// Permission represents a per-object access entry for a user.
|
||||||
type Permission struct {
|
type Permission struct {
|
||||||
UserID int16
|
UserID int16
|
||||||
UserName string // denormalized
|
UserName string // denormalized
|
||||||
ObjectTypeID int16
|
ObjectTypeID int16
|
||||||
ObjectID uuid.UUID
|
ObjectID uuid.UUID
|
||||||
CanView bool
|
CanView bool
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ type ActionType struct {
|
|||||||
|
|
||||||
// AuditEntry is a single audit log record.
|
// AuditEntry is a single audit log record.
|
||||||
type AuditEntry struct {
|
type AuditEntry struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int16
|
UserID int16
|
||||||
UserName string // denormalized
|
UserName string // denormalized
|
||||||
Action string // action type name, e.g. "file_create"
|
Action string // action type name, e.g. "file_create"
|
||||||
ObjectType *string
|
ObjectType *string
|
||||||
ObjectID *uuid.UUID
|
ObjectID *uuid.UUID
|
||||||
Details json.RawMessage
|
Details json.RawMessage
|
||||||
PerformedAt time.Time
|
PerformedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditPage is an offset-based page of audit log entries.
|
// AuditPage is an offset-based page of audit log entries.
|
||||||
|
|||||||
@@ -89,16 +89,16 @@ type fileJSON struct {
|
|||||||
|
|
||||||
func toTagJSON(t domain.Tag) tagJSON {
|
func toTagJSON(t domain.Tag) tagJSON {
|
||||||
j := tagJSON{
|
j := tagJSON{
|
||||||
ID: t.ID.String(),
|
ID: t.ID.String(),
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Notes: t.Notes,
|
Notes: t.Notes,
|
||||||
Color: t.Color,
|
Color: t.Color,
|
||||||
CategoryName: t.CategoryName,
|
CategoryName: t.CategoryName,
|
||||||
CategoryColor: t.CategoryColor,
|
CategoryColor: t.CategoryColor,
|
||||||
CreatorID: t.CreatorID,
|
CreatorID: t.CreatorID,
|
||||||
CreatorName: t.CreatorName,
|
CreatorName: t.CreatorName,
|
||||||
IsPublic: t.IsPublic,
|
IsPublic: t.IsPublic,
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
if t.CategoryID != nil {
|
if t.CategoryID != nil {
|
||||||
s := t.CategoryID.String()
|
s := t.CategoryID.String()
|
||||||
|
|||||||
@@ -106,11 +106,11 @@ func (h *TagHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *TagHandler) Create(c *gin.Context) {
|
func (h *TagHandler) Create(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
CategoryID *string `json:"category_id"`
|
CategoryID *string `json:"category_id"`
|
||||||
IsPublic *bool `json:"is_public"`
|
IsPublic *bool `json:"is_public"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
respondError(c, domain.ErrValidation)
|
respondError(c, domain.ErrValidation)
|
||||||
|
|||||||
@@ -111,42 +111,42 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// --- Repositories --------------------------------------------------------
|
// --- Repositories --------------------------------------------------------
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
userRepo := postgres.NewUserRepo(pool)
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
sessionRepo := postgres.NewSessionRepo(pool)
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
fileRepo := postgres.NewFileRepo(pool)
|
||||||
mimeRepo := postgres.NewMimeRepo(pool)
|
mimeRepo := postgres.NewMimeRepo(pool)
|
||||||
aclRepo := postgres.NewACLRepo(pool)
|
aclRepo := postgres.NewACLRepo(pool)
|
||||||
auditRepo := postgres.NewAuditRepo(pool)
|
auditRepo := postgres.NewAuditRepo(pool)
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
tagRepo := postgres.NewTagRepo(pool)
|
||||||
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
||||||
categoryRepo := postgres.NewCategoryRepo(pool)
|
categoryRepo := postgres.NewCategoryRepo(pool)
|
||||||
poolRepo := postgres.NewPoolRepo(pool)
|
poolRepo := postgres.NewPoolRepo(pool)
|
||||||
transactor := postgres.NewTransactor(pool)
|
transactor := postgres.NewTransactor(pool)
|
||||||
|
|
||||||
// --- Services ------------------------------------------------------------
|
// --- Services ------------------------------------------------------------
|
||||||
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
|
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
|
||||||
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
||||||
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
|
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
|
||||||
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
|
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
|
||||||
|
|
||||||
// Bootstrap the admin account the suite logs in with (replaces the old
|
// Bootstrap the admin account the suite logs in with (replaces the old
|
||||||
// hardcoded seed credentials).
|
// hardcoded seed credentials).
|
||||||
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
|
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
|
||||||
|
|
||||||
// --- Handlers ------------------------------------------------------------
|
// --- Handlers ------------------------------------------------------------
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
poolHandler := handler.NewPoolHandler(poolSvc)
|
||||||
userHandler := handler.NewUserHandler(userSvc)
|
userHandler := handler.NewUserHandler(userSvc)
|
||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
aclHandler := handler.NewACLHandler(aclSvc)
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
auditHandler := handler.NewAuditHandler(auditSvc)
|
||||||
|
|
||||||
r := handler.NewRouter(
|
r := handler.NewRouter(
|
||||||
authMiddleware, authHandler,
|
authMiddleware, authHandler,
|
||||||
@@ -289,7 +289,7 @@ func TestFullFlow(t *testing.T) {
|
|||||||
// 3. Log in as alice
|
// 3. Log in as alice
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
aliceToken := h.login("alice", "alicepass")
|
aliceToken := h.login("alice", "alicepass")
|
||||||
bobToken := h.login("bob", "bobpass")
|
bobToken := h.login("bob", "bobpass")
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 4. Alice uploads a private JPEG
|
// 4. Alice uploads a private JPEG
|
||||||
@@ -619,7 +619,7 @@ func TestTagRuleActivateApplyToExisting(t *testing.T) {
|
|||||||
|
|
||||||
// Activate A→B WITHOUT apply_to_existing — existing file must not change.
|
// Activate A→B WITHOUT apply_to_existing — existing file must not change.
|
||||||
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"apply_to_existing": false,
|
"apply_to_existing": false,
|
||||||
}, tok)
|
}, tok)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
@@ -634,7 +634,7 @@ func TestTagRuleActivateApplyToExisting(t *testing.T) {
|
|||||||
// Activate A→B WITH apply_to_existing=true.
|
// Activate A→B WITH apply_to_existing=true.
|
||||||
// Expectation: file gets B directly, and C transitively via the active B→C rule.
|
// Expectation: file gets B directly, and C transitively via the active B→C rule.
|
||||||
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"apply_to_existing": true,
|
"apply_to_existing": true,
|
||||||
}, tok)
|
}, tok)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
"tanabata/backend/internal/port"
|
"tanabata/backend/internal/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
const categoryObjectType = "category"
|
const categoryObjectType = "category"
|
||||||
const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types
|
const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types
|
||||||
|
|
||||||
// CategoryParams holds the fields for creating or patching a category.
|
// CategoryParams holds the fields for creating or patching a category.
|
||||||
type CategoryParams struct {
|
type CategoryParams struct {
|
||||||
Name string
|
Name string
|
||||||
Notes *string
|
Notes *string
|
||||||
Color *string // nil = no change; pointer to empty string = clear
|
Color *string // nil = no change; pointer to empty string = clear
|
||||||
Metadata json.RawMessage
|
Metadata json.RawMessage
|
||||||
IsPublic *bool
|
IsPublic *bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"tanabata/backend/internal/port"
|
"tanabata/backend/internal/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
const poolObjectType = "pool"
|
const poolObjectType = "pool"
|
||||||
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
||||||
|
|
||||||
// PoolParams holds the fields for creating or patching a pool.
|
// PoolParams holds the fields for creating or patching a pool.
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"tanabata/backend/internal/port"
|
"tanabata/backend/internal/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tagObjectType = "tag"
|
const tagObjectType = "tag"
|
||||||
const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types
|
const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types
|
||||||
|
|
||||||
// TagParams holds the fields for creating or patching a tag.
|
// TagParams holds the fields for creating or patching a tag.
|
||||||
type TagParams struct {
|
type TagParams struct {
|
||||||
Name string
|
Name string
|
||||||
Notes *string
|
Notes *string
|
||||||
Color *string // nil = no change; pointer to empty string = clear
|
Color *string // nil = no change; pointer to empty string = clear
|
||||||
CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign
|
CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign
|
||||||
Metadata json.RawMessage
|
Metadata json.RawMessage
|
||||||
IsPublic *bool
|
IsPublic *bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
_ "golang.org/x/image/webp" // register WebP decoder
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
_ "image/gif" // register GIF decoder
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/gif" // register GIF decoder
|
_ "image/png" // register PNG decoder
|
||||||
_ "image/png" // register PNG decoder
|
|
||||||
_ "golang.org/x/image/webp" // register WebP decoder
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Build output and dependencies
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Generated from openapi.yaml — formatting would be overwritten on regen
|
||||||
|
src/lib/api/schema.ts
|
||||||
|
|
||||||
|
# Static assets (fonts, icons, manifest, robots)
|
||||||
|
static
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+32
@@ -15,6 +15,8 @@
|
|||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"prettier": "^3.8.4",
|
||||||
|
"prettier-plugin-svelte": "^4.1.0",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
@@ -2210,6 +2212,36 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier-plugin-svelte": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"svelte": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"prettier": "^3.8.4",
|
||||||
|
"prettier-plugin-svelte": "^4.1.0",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
|||||||
+30
-30
@@ -1,42 +1,42 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-bg-primary: #312F45;
|
--color-bg-primary: #312f45;
|
||||||
--color-bg-secondary: #181721;
|
--color-bg-secondary: #181721;
|
||||||
--color-bg-elevated: #111118;
|
--color-bg-elevated: #111118;
|
||||||
--color-accent: #9592B5;
|
--color-accent: #9592b5;
|
||||||
--color-accent-hover: #7D7AA4;
|
--color-accent-hover: #7d7aa4;
|
||||||
--color-text-primary: #f0f0f0;
|
--color-text-primary: #f0f0f0;
|
||||||
--color-text-muted: #9999AD;
|
--color-text-muted: #9999ad;
|
||||||
--color-danger: #DB6060;
|
--color-danger: #db6060;
|
||||||
--color-info: #4DC7ED;
|
--color-info: #4dc7ed;
|
||||||
--color-warning: #F5E872;
|
--color-warning: #f5e872;
|
||||||
--color-tag-default: #444455;
|
--color-tag-default: #444455;
|
||||||
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
||||||
--color-nav-active: rgba(52, 50, 73, 0.72);
|
--color-nav-active: rgba(52, 50, 73, 0.72);
|
||||||
|
|
||||||
--font-sans: 'Epilogue', sans-serif;
|
--font-sans: 'Epilogue', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
:root[data-theme='light'] {
|
||||||
/* Muted, faintly lavender-tinted surfaces — not a glaring near-white, the same
|
/* Muted, faintly lavender-tinted surfaces — not a glaring near-white, the same
|
||||||
way the dark theme's background isn't pure black. Page sits on the dimmest
|
way the dark theme's background isn't pure black. Page sits on the dimmest
|
||||||
surface; sheets are brighter to pop, chips a touch darker for definition. */
|
surface; sheets are brighter to pop, chips a touch darker for definition. */
|
||||||
--color-bg-primary: #e4e2ec;
|
--color-bg-primary: #e4e2ec;
|
||||||
--color-bg-secondary: #f2f1f6;
|
--color-bg-secondary: #f2f1f6;
|
||||||
--color-bg-elevated: #d8d6e2;
|
--color-bg-elevated: #d8d6e2;
|
||||||
--color-accent: #6B68A0;
|
--color-accent: #6b68a0;
|
||||||
--color-accent-hover: #5A578F;
|
--color-accent-hover: #5a578f;
|
||||||
--color-text-primary: #111118;
|
--color-text-primary: #111118;
|
||||||
--color-text-muted: #555566;
|
--color-text-muted: #555566;
|
||||||
--color-tag-default: #cbcad9;
|
--color-tag-default: #cbcad9;
|
||||||
--color-nav-bg: rgba(228, 226, 236, 0.85);
|
--color-nav-bg: rgba(228, 226, 236, 0.85);
|
||||||
--color-nav-active: rgba(90, 87, 143, 0.22);
|
--color-nav-active: rgba(90, 87, 143, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Epilogue';
|
font-family: 'Epilogue';
|
||||||
src: url('/fonts/Epilogue-VariableFont_wght.ttf') format('truetype');
|
src: url('/fonts/Epilogue-VariableFont_wght.ttf') format('truetype');
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -16,16 +16,22 @@
|
|||||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png" />
|
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="/images/apple-icon-57x57.png" />
|
<link rel="apple-touch-icon" sizes="57x57" href="/images/apple-icon-57x57.png" />
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="/images/apple-icon-60x60.png" />
|
<link rel="apple-touch-icon" sizes="60x60" href="/images/apple-icon-60x60.png" />
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="/images/apple-icon-72x72.png" />
|
<link rel="apple-touch-icon" sizes="72x72" href="/images/apple-icon-72x72.png" />
|
||||||
<link rel="apple-touch-icon" sizes="76x76" href="/images/apple-icon-76x76.png" />
|
<link rel="apple-touch-icon" sizes="76x76" href="/images/apple-icon-76x76.png" />
|
||||||
<link rel="apple-touch-icon" sizes="114x114" href="/images/apple-icon-114x114.png" />
|
<link rel="apple-touch-icon" sizes="114x114" href="/images/apple-icon-114x114.png" />
|
||||||
<link rel="apple-touch-icon" sizes="120x120" href="/images/apple-icon-120x120.png" />
|
<link rel="apple-touch-icon" sizes="120x120" href="/images/apple-icon-120x120.png" />
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="/images/apple-icon-144x144.png" />
|
<link rel="apple-touch-icon" sizes="144x144" href="/images/apple-icon-144x144.png" />
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/images/apple-icon-152x152.png" />
|
<link rel="apple-touch-icon" sizes="152x152" href="/images/apple-icon-152x152.png" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-icon-180x180.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-icon-180x180.png" />
|
||||||
<link rel="preload" href="/fonts/Epilogue-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin="anonymous" />
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/Epilogue-VariableFont_wght.ttf"
|
||||||
|
as="font"
|
||||||
|
type="font/ttf"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function login(name: string, password: string): Promise<void> {
|
|||||||
authStore.update((s) => ({
|
authStore.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
accessToken: tokens.access_token ?? null,
|
accessToken: tokens.access_token ?? null,
|
||||||
refreshToken: tokens.refresh_token ?? null,
|
refreshToken: tokens.refresh_token ?? null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export async function refresh(): Promise<void> {
|
|||||||
authStore.update((s) => ({
|
authStore.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
accessToken: tokens.access_token ?? null,
|
accessToken: tokens.access_token ?? null,
|
||||||
refreshToken: tokens.refresh_token ?? null,
|
refreshToken: tokens.refresh_token ?? null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class ApiError extends Error {
|
|||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
public readonly code: string,
|
public readonly code: string,
|
||||||
message: string,
|
message: string,
|
||||||
public readonly details?: Array<{ field?: string; message?: string }>,
|
public readonly details?: Array<{ field?: string; message?: string }>
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
@@ -38,7 +38,7 @@ async function refreshTokens(): Promise<void> {
|
|||||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
body: JSON.stringify({ refresh_token: refreshToken })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -50,7 +50,7 @@ async function refreshTokens(): Promise<void> {
|
|||||||
authStore.update((s) => ({
|
authStore.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
accessToken: data.access_token ?? null,
|
accessToken: data.access_token ?? null,
|
||||||
refreshToken: data.refresh_token ?? null,
|
refreshToken: data.refresh_token ?? null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ function buildHeaders(init: RequestInit | undefined, accessToken: string | null)
|
|||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let res = await fetch(BASE + path, {
|
let res = await fetch(BASE + path, {
|
||||||
...init,
|
...init,
|
||||||
headers: buildHeaders(init, get(authStore).accessToken),
|
headers: buildHeaders(init, get(authStore).accessToken)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
@@ -81,18 +81,27 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
res = await fetch(BASE + path, {
|
res = await fetch(BASE + path, {
|
||||||
...init,
|
...init,
|
||||||
headers: buildHeaders(init, get(authStore).accessToken),
|
headers: buildHeaders(init, get(authStore).accessToken)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let body: { code?: string; message?: string; details?: Array<{ field?: string; message?: string }> } = {};
|
let body: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
details?: Array<{ field?: string; message?: string }>;
|
||||||
|
} = {};
|
||||||
try {
|
try {
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse failure
|
// ignore parse failure
|
||||||
}
|
}
|
||||||
throw new ApiError(res.status, body.code ?? 'error', body.message ?? res.statusText, body.details);
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
body.code ?? 'error',
|
||||||
|
body.message ?? res.statusText,
|
||||||
|
body.details
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
@@ -103,7 +112,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
export function uploadWithProgress<T>(
|
export function uploadWithProgress<T>(
|
||||||
path: string,
|
path: string,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
onProgress: (pct: number) => void,
|
onProgress: (pct: number) => void
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const token = get(authStore).accessToken;
|
const token = get(authStore).accessToken;
|
||||||
@@ -126,7 +135,9 @@ export function uploadWithProgress<T>(
|
|||||||
let body: { code?: string; message?: string } = {};
|
let body: { code?: string; message?: string } = {};
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(xhr.responseText);
|
body = JSON.parse(xhr.responseText);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText));
|
reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -146,5 +157,5 @@ export const api = {
|
|||||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
upload: <T>(path: string, formData: FormData) =>
|
||||||
request<T>(path, { method: 'POST', body: formData }),
|
request<T>(path, { method: 'POST', body: formData })
|
||||||
};
|
};
|
||||||
@@ -21,9 +21,7 @@
|
|||||||
function nearViewport(): boolean {
|
function nearViewport(): boolean {
|
||||||
if (!sentinel) return false;
|
if (!sentinel) return false;
|
||||||
const rect = sentinel.getBoundingClientRect();
|
const rect = sentinel.getBoundingClientRect();
|
||||||
return edge === 'bottom'
|
return edge === 'bottom' ? rect.top <= window.innerHeight + MARGIN : rect.bottom >= -MARGIN;
|
||||||
? rect.top <= window.innerHeight + MARGIN
|
|
||||||
: rect.bottom >= -MARGIN;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeLoad() {
|
function maybeLoad() {
|
||||||
@@ -100,6 +98,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc'),
|
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc'),
|
||||||
api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>(
|
api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>(
|
||||||
'/files/bulk/common-tags',
|
'/files/bulk/common-tags',
|
||||||
{ file_ids: fileIds },
|
{ file_ids: fileIds }
|
||||||
),
|
)
|
||||||
]);
|
]);
|
||||||
allTags = tagsRes.items ?? [];
|
allTags = tagsRes.items ?? [];
|
||||||
commonIds = new Set(commonRes.common_tag_ids ?? []);
|
commonIds = new Set(commonRes.common_tag_ids ?? []);
|
||||||
@@ -53,16 +53,16 @@
|
|||||||
allTags.filter(
|
allTags.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
assignedIds.has(t.id ?? '') &&
|
assignedIds.has(t.id ?? '') &&
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let availableTags = $derived(
|
let availableTags = $derived(
|
||||||
allTags.filter(
|
allTags.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
!assignedIds.has(t.id ?? '') &&
|
!assignedIds.has(t.id ?? '') &&
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function tagStyle(tag: Tag) {
|
function tagStyle(tag: Tag) {
|
||||||
@@ -132,8 +132,10 @@
|
|||||||
class="tag assigned"
|
class="tag assigned"
|
||||||
class:partial={isPartial}
|
class:partial={isPartial}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => isPartial ? promotePartial(tag.id!) : remove(tag.id!)}
|
onclick={() => (isPartial ? promotePartial(tag.id!) : remove(tag.id!))}
|
||||||
title={isPartial ? 'Partial — click to add to all files' : 'Click to remove from all files'}
|
title={isPartial
|
||||||
|
? 'Partial — click to add to all files'
|
||||||
|
: 'Click to remove from all files'}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
{#if isPartial}
|
{#if isPartial}
|
||||||
@@ -159,7 +161,12 @@
|
|||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
selected = false,
|
selected = false,
|
||||||
selectionMode = false,
|
selectionMode = false,
|
||||||
onTap,
|
onTap,
|
||||||
onLongPress,
|
onLongPress
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let imgSrc = $state<string | null>(null);
|
let imgSrc = $state<string | null>(null);
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
fetch(`/api/v1/files/${file.id}/thumbnail`, {
|
fetch(`/api/v1/files/${file.id}/thumbnail`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
})
|
})
|
||||||
.then((res) => (res.ok ? res.blob() : null))
|
.then((res) => (res.ok ? res.blob() : null))
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
@@ -111,7 +111,10 @@
|
|||||||
data-file-index={index}
|
data-file-index={index}
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onpointermove={onPointerMoveInternal}
|
onpointermove={onPointerMoveInternal}
|
||||||
onpointerup={() => { cancelPress(); didLongPress = false; }}
|
onpointerup={() => {
|
||||||
|
cancelPress();
|
||||||
|
didLongPress = false;
|
||||||
|
}}
|
||||||
onpointerleave={cancelPress}
|
onpointerleave={cancelPress}
|
||||||
oncontextmenu={(e) => e.preventDefault()}
|
oncontextmenu={(e) => e.preventDefault()}
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
@@ -128,14 +131,27 @@
|
|||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="check" aria-hidden="true">
|
<div class="check" aria-hidden="true">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.55)" stroke="white" stroke-width="1"/>
|
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.55)" stroke="white" stroke-width="1" />
|
||||||
<path d="M5 9l3 3 5-5" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M5 9l3 3 5-5"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{:else if selectionMode}
|
{:else if selectionMode}
|
||||||
<div class="check" aria-hidden="true">
|
<div class="check" aria-hidden="true">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.35)" stroke="rgba(255,255,255,0.5)" stroke-width="1"/>
|
<circle
|
||||||
|
cx="9"
|
||||||
|
cy="9"
|
||||||
|
r="8.5"
|
||||||
|
fill="rgba(0,0,0,0.35)"
|
||||||
|
stroke="rgba(255,255,255,0.5)"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -207,7 +223,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% {
|
||||||
100% { background-position: -200% 0; }
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -50,13 +50,11 @@
|
|||||||
id: uid(),
|
id: uid(),
|
||||||
name: f.name,
|
name: f.name,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: 'uploading',
|
status: 'uploading'
|
||||||
}));
|
}));
|
||||||
queue = [...queue, ...items];
|
queue = [...queue, ...items];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(files.map((file, i) => uploadOne(file, items[i].id)));
|
||||||
files.map((file, i) => uploadOne(file, items[i].id)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadOne(file: globalThis.File, itemId: string) {
|
async function uploadOne(file: globalThis.File, itemId: string) {
|
||||||
@@ -64,10 +62,8 @@
|
|||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await uploadWithProgress<ApiFile>(
|
const result = await uploadWithProgress<ApiFile>('/files', fd, (pct) =>
|
||||||
'/files',
|
updateItem(itemId, { progress: pct })
|
||||||
fd,
|
|
||||||
(pct) => updateItem(itemId, { progress: pct }),
|
|
||||||
);
|
);
|
||||||
updateItem(itemId, { status: 'done', progress: 100 });
|
updateItem(itemId, { status: 'done', progress: 100 });
|
||||||
onUploaded(result);
|
onUploaded(result);
|
||||||
@@ -144,8 +140,14 @@
|
|||||||
<div class="drop-overlay" aria-hidden="true">
|
<div class="drop-overlay" aria-hidden="true">
|
||||||
<div class="drop-label">
|
<div class="drop-label">
|
||||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||||
<path d="M18 4v20M10 14l8-10 8 10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
<path d="M6 28h24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
d="M18 4v20M10 14l8-10 8 10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M6 28h24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Drop files to upload
|
Drop files to upload
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +173,11 @@
|
|||||||
|
|
||||||
<ul class="upload-list">
|
<ul class="upload-list">
|
||||||
{#each queue as item (item.id)}
|
{#each queue as item (item.id)}
|
||||||
<li class="upload-item" class:done={item.status === 'done'} class:error={item.status === 'error'}>
|
<li
|
||||||
|
class="upload-item"
|
||||||
|
class:done={item.status === 'done'}
|
||||||
|
class:error={item.status === 'error'}
|
||||||
|
>
|
||||||
<span class="item-name" title={item.name}>{item.name}</span>
|
<span class="item-name" title={item.name}>{item.name}</span>
|
||||||
<div class="item-right">
|
<div class="item-right">
|
||||||
{#if item.status === 'uploading'}
|
{#if item.status === 'uploading'}
|
||||||
@@ -180,8 +186,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="pct">{item.progress}%</span>
|
<span class="pct">{item.progress}%</span>
|
||||||
{:else if item.status === 'done'}
|
{:else if item.status === 'done'}
|
||||||
<svg class="icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-label="Done">
|
<svg
|
||||||
<path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
class="icon-ok"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-label="Done"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 8l4 4 6-6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="err-msg" title={item.error}>{item.error}</span>
|
<span class="err-msg" title={item.error}>{item.error}</span>
|
||||||
@@ -243,8 +262,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { transform: translateY(10px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
let dirty = $state(false);
|
let dirty = $state(false);
|
||||||
|
|
||||||
let exifEntries = $derived(
|
let exifEntries = $derived(
|
||||||
file?.exif ? Object.entries(file.exif as Record<string, unknown>) : [],
|
file?.exif ? Object.entries(file.exif as Record<string, unknown>) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Load (re-runs whenever the file changes, i.e. paging) ----
|
// ---- Load (re-runs whenever the file changes, i.e. paging) ----
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
const token = get(authStore).accessToken;
|
const token = get(authStore).accessToken;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/files/${id}/preview`, {
|
const res = await fetch(`/api/v1/files/${id}/preview`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
});
|
});
|
||||||
if (res.ok && fileId === id) {
|
if (res.ok && fileId === id) {
|
||||||
previewSrc = URL.createObjectURL(await res.blob());
|
previewSrc = URL.createObjectURL(await res.blob());
|
||||||
@@ -129,13 +129,13 @@
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
tagsVisible = entries[0]?.isIntersecting ?? false;
|
tagsVisible = entries[0]?.isIntersecting ?? false;
|
||||||
},
|
},
|
||||||
{ rootMargin: '200px' },
|
{ rootMargin: '200px' }
|
||||||
);
|
);
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +158,8 @@
|
|||||||
try {
|
try {
|
||||||
const updated = await api.patch<File>(`/files/${file.id}`, {
|
const updated = await api.patch<File>(`/files/${file.id}`, {
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
content_datetime: contentDatetime
|
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
|
||||||
? new Date(contentDatetime).toISOString()
|
is_public: isPublic
|
||||||
: undefined,
|
|
||||||
is_public: isPublic,
|
|
||||||
});
|
});
|
||||||
file = updated;
|
file = updated;
|
||||||
dirty = false;
|
dirty = false;
|
||||||
@@ -206,7 +204,13 @@
|
|||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<button class="back-btn" onclick={onClose} aria-label="Back to files">
|
<button class="back-btn" onclick={onClose} 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>
|
||||||
</button>
|
</button>
|
||||||
<span class="filename">{file?.original_name ?? ''}</span>
|
<span class="filename">{file?.original_name ?? ''}</span>
|
||||||
@@ -230,7 +234,13 @@
|
|||||||
aria-label="Previous file"
|
aria-label="Previous file"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
<path d="M11 3L5 9L11 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M11 3L5 9L11 15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -241,7 +251,13 @@
|
|||||||
aria-label="Next file"
|
aria-label="Next file"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
<path d="M7 3L13 9L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M7 3L13 9L7 15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -290,7 +306,10 @@
|
|||||||
<button
|
<button
|
||||||
class="toggle"
|
class="toggle"
|
||||||
class:on={isPublic}
|
class:on={isPublic}
|
||||||
onclick={() => { isPublic = !isPublic; dirty = true; }}
|
onclick={() => {
|
||||||
|
isPublic = !isPublic;
|
||||||
|
dirty = true;
|
||||||
|
}}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isPublic}
|
aria-checked={isPublic}
|
||||||
aria-label="Public"
|
aria-label="Public"
|
||||||
@@ -299,11 +318,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button
|
<button class="save-btn" onclick={save} disabled={!dirty || saving}>
|
||||||
class="save-btn"
|
|
||||||
onclick={save}
|
|
||||||
disabled={!dirty || saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -409,12 +424,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-placeholder.shimmer {
|
.preview-placeholder.shimmer {
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, #111 25%, #222 50%, #111 75%);
|
||||||
90deg,
|
|
||||||
#111 25%,
|
|
||||||
#222 50%,
|
|
||||||
#111 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -445,8 +455,12 @@
|
|||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-prev { left: 10px; }
|
.nav-prev {
|
||||||
.nav-next { right: 10px; }
|
left: 10px;
|
||||||
|
}
|
||||||
|
.nav-next {
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Metadata panel ---- */
|
/* ---- Metadata panel ---- */
|
||||||
.meta-panel {
|
.meta-panel {
|
||||||
@@ -465,7 +479,9 @@
|
|||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sep { opacity: 0.4; }
|
.sep {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@@ -577,7 +593,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
transition: background-color 0.15s, opacity 0.15s;
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn:hover:not(:disabled) {
|
.save-btn:hover:not(:disabled) {
|
||||||
@@ -632,7 +650,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% {
|
||||||
100% { background-position: -200% 0; }
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,11 +18,7 @@
|
|||||||
let search = $state('');
|
let search = $state('');
|
||||||
let tokens = $state<string[]>(parseDslFilter(value));
|
let tokens = $state<string[]>(parseDslFilter(value));
|
||||||
let tagNames = $derived(
|
let tagNames = $derived(
|
||||||
new Map(
|
new Map(tags.filter((t) => t.id && t.name).map((t) => [t.id as string, t.name as string]))
|
||||||
tags
|
|
||||||
.filter((t) => t.id && t.name)
|
|
||||||
.map((t) => [t.id as string, t.name as string]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -36,9 +32,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let filteredTags = $derived(
|
let filteredTags = $derived(
|
||||||
search.trim()
|
search.trim() ? tags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase())) : tags
|
||||||
? tags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: tags,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function addToken(t: string) {
|
function addToken(t: string) {
|
||||||
@@ -143,7 +137,11 @@
|
|||||||
{#each filteredTags as tag (tag.id)}
|
{#each filteredTags as tag (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="token tag-token"
|
class="token tag-token"
|
||||||
style="background-color: {tag.color ? '#' + tag.color : tag.category_color ? '#' + tag.category_color : 'var(--color-tag-default)'}"
|
style="background-color: {tag.color
|
||||||
|
? '#' + tag.color
|
||||||
|
: tag.category_color
|
||||||
|
? '#' + tag.category_color
|
||||||
|
: 'var(--color-tag-default)'}"
|
||||||
onclick={() => addToken(`t=${tag.id}`)}
|
onclick={() => addToken(`t=${tag.id}`)}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
@@ -214,7 +212,9 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: opacity 0.15s, outline 0.1s;
|
transition:
|
||||||
|
opacity 0.15s,
|
||||||
|
outline 0.1s;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,14 @@
|
|||||||
allTags.filter(
|
allTags.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
!assignedIds.has(t.id) &&
|
!assignedIds.has(t.id) &&
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredAssigned = $derived(
|
let filteredAssigned = $derived(
|
||||||
search.trim()
|
search.trim()
|
||||||
? fileTags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
? fileTags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
: fileTags,
|
: fileTags
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleAdd(tagId: string) {
|
async function handleAdd(tagId: string) {
|
||||||
@@ -93,7 +93,12 @@
|
|||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
onOrderToggle,
|
onOrderToggle,
|
||||||
onFilterToggle,
|
onFilterToggle,
|
||||||
onUpload,
|
onUpload,
|
||||||
onTrash,
|
onTrash
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -39,8 +39,14 @@
|
|||||||
{#if onUpload}
|
{#if onUpload}
|
||||||
<button class="upload-btn icon-btn" onclick={onUpload} title="Upload files">
|
<button class="upload-btn icon-btn" onclick={onUpload} title="Upload files">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M8 2v9M4 6l4-4 4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
<path d="M2 13h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
d="M8 2v9M4 6l4-4 4 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M2 13h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -48,7 +54,13 @@
|
|||||||
{#if onTrash}
|
{#if onTrash}
|
||||||
<button class="icon-btn trash-btn" onclick={onTrash} title="Trash">
|
<button class="icon-btn trash-btn" onclick={onTrash} title="Trash">
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||||
<path d="M2 4h11M5 4V2.5h5V4M5.5 7v4.5M9.5 7v4.5M3 4l.8 9h7.4l.8-9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M2 4h11M5 4V2.5h5V4M5.5 7v4.5M9.5 7v4.5M3 4l.8 9h7.4l.8-9"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -64,14 +76,30 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="icon-btn order-btn" onclick={onOrderToggle} title={order === 'asc' ? 'Ascending' : 'Descending'}>
|
<button
|
||||||
|
class="icon-btn order-btn"
|
||||||
|
onclick={onOrderToggle}
|
||||||
|
title={order === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
>
|
||||||
{#if order === 'asc'}
|
{#if order === 'asc'}
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M4 10L8 6L12 10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M4 10L8 6L12 10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M4 6L8 10L12 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -83,7 +111,12 @@
|
|||||||
title="Filter"
|
title="Filter"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M2 4h12M4 8h8M6 12h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 4h12M4 8h8M6 12h4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,20 @@
|
|||||||
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
||||||
<span class="num">{$selectionCount}</span>
|
<span class="num">{$selectionCount}</span>
|
||||||
<span class="label">selected</span>
|
<span class="label">selected</span>
|
||||||
<svg class="close-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
class="close-icon"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -51,8 +63,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { transform: translateY(12px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(12px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
(t) =>
|
(t) =>
|
||||||
t.id !== tagId &&
|
t.id !== tagId &&
|
||||||
!usedIds.has(t.id) &&
|
!usedIds.has(t.id) &&
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function tagForId(id: string | undefined) {
|
function tagForId(id: string | undefined) {
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
|
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
|
||||||
then_tag_id: thenTagId,
|
then_tag_id: thenTagId,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
apply_to_existing: $appSettings.tagRuleApplyToExisting,
|
apply_to_existing: $appSettings.tagRuleApplyToExisting
|
||||||
});
|
});
|
||||||
onRulesChange([...rules, rule]);
|
onRulesChange([...rules, rule]);
|
||||||
search = '';
|
search = '';
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
const body: Record<string, unknown> = { is_active: activating };
|
const body: Record<string, unknown> = { is_active: activating };
|
||||||
if (activating) body.apply_to_existing = $appSettings.tagRuleApplyToExisting;
|
if (activating) body.apply_to_existing = $appSettings.tagRuleApplyToExisting;
|
||||||
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, body);
|
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, body);
|
||||||
onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r));
|
onRulesChange(rules.map((r) => (r.then_tag_id === thenTagId ? updated : r)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to update rule';
|
error = e instanceof ApiError ? e.message : 'Failed to update rule';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,9 +92,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor" class:busy>
|
<div class="editor" class:busy>
|
||||||
<p class="desc">
|
<p class="desc">When this tag is applied, also apply:</p>
|
||||||
When this tag is applied, also apply:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
@@ -120,20 +118,20 @@
|
|||||||
>
|
>
|
||||||
{#if rule.is_active}
|
{#if rule.is_active}
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5"/>
|
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
|
||||||
<circle cx="6" cy="6" r="2.5" fill="currentColor"/>
|
<circle cx="6" cy="6" r="2.5" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5"/>
|
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="remove-btn"
|
class="remove-btn"
|
||||||
onclick={() => removeRule(rule.then_tag_id!)}
|
onclick={() => removeRule(rule.then_tag_id!)}
|
||||||
aria-label="Remove rule"
|
aria-label="Remove rule">×</button
|
||||||
>×</button>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +153,12 @@
|
|||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface AppSettings {
|
|||||||
|
|
||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
fileLoadLimit: 100,
|
fileLoadLimit: 100,
|
||||||
tagRuleApplyToExisting: false,
|
tagRuleApplyToExisting: false
|
||||||
};
|
};
|
||||||
|
|
||||||
function load(): AppSettings {
|
function load(): AppSettings {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface SelectionState {
|
|||||||
function createSelectionStore() {
|
function createSelectionStore() {
|
||||||
const { subscribe, update, set } = writable<SelectionState>({
|
const { subscribe, update, set } = writable<SelectionState>({
|
||||||
active: false,
|
active: false,
|
||||||
ids: new Set(),
|
ids: new Set()
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -55,7 +55,7 @@ function createSelectionStore() {
|
|||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
set({ active: false, ids: new Set() });
|
set({ active: false, ids: new Set() });
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,30 +29,30 @@ function makeSortStore<F extends string>(key: string, defaults: SortState<F>) {
|
|||||||
},
|
},
|
||||||
toggleOrder() {
|
toggleOrder() {
|
||||||
store.update((s) => ({ ...s, order: s.order === 'asc' ? 'desc' : 'asc' }));
|
store.update((s) => ({ ...s, order: s.order === 'asc' ? 'desc' : 'asc' }));
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fileSorting = makeSortStore<FileSortField>('sort:files', {
|
export const fileSorting = makeSortStore<FileSortField>('sort:files', {
|
||||||
sort: 'created',
|
sort: 'created',
|
||||||
order: 'desc',
|
order: 'desc'
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tagSorting = makeSortStore<TagSortField>('sort:tags', {
|
export const tagSorting = makeSortStore<TagSortField>('sort:tags', {
|
||||||
sort: 'created',
|
sort: 'created',
|
||||||
order: 'desc',
|
order: 'desc'
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CategorySortField = 'name' | 'color' | 'created';
|
export type CategorySortField = 'name' | 'color' | 'created';
|
||||||
|
|
||||||
export const categorySorting = makeSortStore<CategorySortField>('sort:categories', {
|
export const categorySorting = makeSortStore<CategorySortField>('sort:categories', {
|
||||||
sort: 'name',
|
sort: 'name',
|
||||||
order: 'asc',
|
order: 'asc'
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PoolSortField = 'name' | 'created';
|
export type PoolSortField = 'name' | 'created';
|
||||||
|
|
||||||
export const poolSorting = makeSortStore<PoolSortField>('sort:pools', {
|
export const poolSorting = makeSortStore<PoolSortField>('sort:pools', {
|
||||||
sort: 'created',
|
sort: 'created',
|
||||||
order: 'desc',
|
order: 'desc'
|
||||||
});
|
});
|
||||||
@@ -9,28 +9,28 @@
|
|||||||
{
|
{
|
||||||
href: '/categories',
|
href: '/categories',
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
match: '/categories',
|
match: '/categories'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/tags',
|
href: '/tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
match: '/tags',
|
match: '/tags'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/files',
|
href: '/files',
|
||||||
label: 'Files',
|
label: 'Files',
|
||||||
match: '/files',
|
match: '/files'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/pools',
|
href: '/pools',
|
||||||
label: 'Pools',
|
label: 'Pools',
|
||||||
match: '/pools',
|
match: '/pools'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
match: '/settings',
|
match: '/settings'
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const isLogin = $derived($page.url.pathname === '/login');
|
const isLogin = $derived($page.url.pathname === '/login');
|
||||||
@@ -45,30 +45,114 @@
|
|||||||
{@const active = $page.url.pathname.startsWith(item.match)}
|
{@const active = $page.url.pathname.startsWith(item.match)}
|
||||||
<a href={item.href} class="nav" class:curr={active} aria-label={item.label}>
|
<a href={item.href} class="nav" class:curr={active} aria-label={item.label}>
|
||||||
{#if item.label === 'Categories'}
|
{#if item.label === 'Categories'}
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg
|
||||||
<path d="M18.875 9.25C21.1532 9.25 23 7.40317 23 5.125C23 2.84683 21.1532 1 18.875 1C16.5968 1 14.75 2.84683 14.75 5.125C14.75 7.40317 16.5968 9.25 18.875 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
width="24"
|
||||||
<path d="M5.125 23C7.40317 23 9.25 21.1532 9.25 18.875C9.25 16.5968 7.40317 14.75 5.125 14.75C2.84683 14.75 1 16.5968 1 18.875C1 21.1532 2.84683 23 5.125 23Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
height="24"
|
||||||
<path d="M14.75 14.75H23V21.625C23 21.9897 22.8551 22.3394 22.5973 22.5973C22.3394 22.8551 21.9897 23 21.625 23H16.125C15.7603 23 15.4106 22.8551 15.1527 22.5973C14.8949 22.3394 14.75 21.9897 14.75 21.625V14.75ZM1 1H9.25V7.875C9.25 8.23967 9.10513 8.58941 8.84727 8.84727C8.58941 9.10513 8.23967 9.25 7.875 9.25H2.375C2.01033 9.25 1.66059 9.10513 1.40273 8.84727C1.14487 8.58941 1 8.23967 1 7.875V1Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M18.875 9.25C21.1532 9.25 23 7.40317 23 5.125C23 2.84683 21.1532 1 18.875 1C16.5968 1 14.75 2.84683 14.75 5.125C14.75 7.40317 16.5968 9.25 18.875 9.25Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.125 23C7.40317 23 9.25 21.1532 9.25 18.875C9.25 16.5968 7.40317 14.75 5.125 14.75C2.84683 14.75 1 16.5968 1 18.875C1 21.1532 2.84683 23 5.125 23Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.75 14.75H23V21.625C23 21.9897 22.8551 22.3394 22.5973 22.5973C22.3394 22.8551 21.9897 23 21.625 23H16.125C15.7603 23 15.4106 22.8551 15.1527 22.5973C14.8949 22.3394 14.75 21.9897 14.75 21.625V14.75ZM1 1H9.25V7.875C9.25 8.23967 9.10513 8.58941 8.84727 8.84727C8.58941 9.10513 8.23967 9.25 7.875 9.25H2.375C2.01033 9.25 1.66059 9.10513 1.40273 8.84727C1.14487 8.58941 1 8.23967 1 7.875V1Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else if item.label === 'Tags'}
|
{:else if item.label === 'Tags'}
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg
|
||||||
<path d="M4.00067 16.5511C2.30116 14.8505 1.45086 14.0013 1.13515 12.8979C0.818352 11.7946 1.08895 10.6231 1.63016 8.28122L1.94146 6.93041C2.39576 4.9592 2.62346 3.97359 3.29777 3.29819C3.97317 2.62388 4.95878 2.39618 6.92999 1.94188L8.2808 1.62947C10.6238 1.08937 11.7942 0.818769 12.8975 1.13447C14.0008 1.45127 14.85 2.30158 16.5496 4.00109L18.5626 6.0141C21.5227 8.97312 23 10.4515 23 12.2885C23 14.1267 21.5216 15.6051 18.5637 18.563C15.6046 21.522 14.1262 23.0004 12.2881 23.0004C10.4511 23.0004 8.97161 21.522 6.01369 18.5641L4.00067 16.5511Z" stroke="currentColor" stroke-width="1.5"/>
|
width="24"
|
||||||
<path d="M9.82325 10.1229C10.6824 9.26375 10.6824 7.87077 9.82325 7.01161C8.96409 6.15246 7.57112 6.15246 6.71196 7.01162C5.8528 7.87077 5.8528 9.26375 6.71196 10.1229C7.57112 10.9821 8.96409 10.9821 9.82325 10.1229Z" stroke="currentColor" stroke-width="1.5"/>
|
height="24"
|
||||||
<path d="M11.4962 19.1504L19.1731 11.4724" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4.00067 16.5511C2.30116 14.8505 1.45086 14.0013 1.13515 12.8979C0.818352 11.7946 1.08895 10.6231 1.63016 8.28122L1.94146 6.93041C2.39576 4.9592 2.62346 3.97359 3.29777 3.29819C3.97317 2.62388 4.95878 2.39618 6.92999 1.94188L8.2808 1.62947C10.6238 1.08937 11.7942 0.818769 12.8975 1.13447C14.0008 1.45127 14.85 2.30158 16.5496 4.00109L18.5626 6.0141C21.5227 8.97312 23 10.4515 23 12.2885C23 14.1267 21.5216 15.6051 18.5637 18.563C15.6046 21.522 14.1262 23.0004 12.2881 23.0004C10.4511 23.0004 8.97161 21.522 6.01369 18.5641L4.00067 16.5511Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.82325 10.1229C10.6824 9.26375 10.6824 7.87077 9.82325 7.01161C8.96409 6.15246 7.57112 6.15246 6.71196 7.01162C5.8528 7.87077 5.8528 9.26375 6.71196 10.1229C7.57112 10.9821 8.96409 10.9821 9.82325 10.1229Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11.4962 19.1504L19.1731 11.4724"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else if item.label === 'Files'}
|
{:else if item.label === 'Files'}
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg
|
||||||
<path d="M13.0465 20.4651H8.95346V22H13.0465V20.4651ZM1.53488 13.0465V8.95352H1.29521e-06V13.0465H1.53488ZM20.4651 12.5994V13.0465H21.9999V12.5994H20.4651ZM13.9582 3.43921L18.0092 7.08506L19.0356 5.94311L14.9855 2.29726L13.9582 3.43921ZM21.9999 12.5994C21.9999 10.8711 22.0153 9.77621 21.5804 8.79798L20.1775 9.42319C20.4497 10.0351 20.4651 10.736 20.4651 12.5994H21.9999ZM18.0092 7.08506C19.3937 8.33138 19.9053 8.81231 20.1775 9.42319L21.5804 8.79798C21.1445 7.81873 20.3208 7.09938 19.0356 5.94311L18.0092 7.08506ZM8.98416 1.53494C10.6029 1.53494 11.2138 1.54722 11.7572 1.75596L12.3077 0.323405C11.4359 -0.012222 10.4863 5.70826e-05 8.98416 5.70826e-05V1.53494ZM14.9855 2.29828C13.8743 1.29856 13.1795 0.656985 12.3077 0.323405L11.7582 1.75596C12.3026 1.9647 12.761 2.36172 13.9582 3.43921L14.9855 2.29828ZM8.95346 20.4651C7.00212 20.4651 5.61664 20.4631 4.56371 20.3219C3.53534 20.1837 2.94185 19.9238 2.50902 19.491L1.42437 20.5756C2.18976 21.3431 3.16083 21.6818 4.36008 21.8434C5.53682 22.002 7.04612 22 8.95346 22V20.4651ZM1.29521e-06 13.0465C1.29521e-06 14.9539 -0.00204521 16.4621 0.156559 17.6399C0.318233 18.8392 0.657953 19.8102 1.42335 20.5766L2.50799 19.492C2.07618 19.0581 1.81627 18.4646 1.67814 17.4353C1.53693 16.3844 1.53488 14.9979 1.53488 13.0465H1.29521e-06ZM13.0465 22C14.9538 22 16.4621 22.002 17.6399 21.8434C18.8391 21.6818 19.8102 21.342 20.5766 20.5766L19.4919 19.492C19.0581 19.9238 18.4646 20.1837 17.4352 20.3219C16.3843 20.4631 14.9978 20.4651 13.0465 20.4651V22ZM20.4651 13.0465C20.4651 14.9979 20.463 16.3844 20.3218 17.4363C20.1837 18.4647 19.9238 19.0581 19.4909 19.491L20.5756 20.5756C21.343 19.8102 21.6817 18.8392 21.8434 17.6399C22.002 16.4632 21.9999 14.9539 21.9999 13.0465H20.4651ZM1.53488 8.95352C1.53488 7.00217 1.53693 5.61669 1.67814 4.56376C1.81627 3.53539 2.07618 2.94191 2.50902 2.50907L1.42437 1.42442C0.656929 2.18982 0.318233 3.16088 0.156559 4.36014C-0.00204521 5.53688 1.29521e-06 7.04617 1.29521e-06 8.95352H1.53488ZM8.98416 5.70826e-05C7.06556 5.70826e-05 5.55012 -0.00198942 4.36827 0.156615C3.1639 0.318289 2.18976 0.658009 1.42335 1.4234L2.50799 2.50805C2.94185 2.07624 3.53636 1.81633 4.57189 1.67819C5.62891 1.53698 7.02258 1.53494 8.98416 1.53494V5.70826e-05Z" fill="currentColor"/>
|
width="22"
|
||||||
<path d="M12.0232 1.27905V3.83718C12.0232 6.24899 12.0232 7.45541 12.7722 8.20443C13.5212 8.95345 14.7276 8.95345 17.1395 8.95345H21.2325" stroke="currentColor" stroke-width="1.5"/>
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13.0465 20.4651H8.95346V22H13.0465V20.4651ZM1.53488 13.0465V8.95352H1.29521e-06V13.0465H1.53488ZM20.4651 12.5994V13.0465H21.9999V12.5994H20.4651ZM13.9582 3.43921L18.0092 7.08506L19.0356 5.94311L14.9855 2.29726L13.9582 3.43921ZM21.9999 12.5994C21.9999 10.8711 22.0153 9.77621 21.5804 8.79798L20.1775 9.42319C20.4497 10.0351 20.4651 10.736 20.4651 12.5994H21.9999ZM18.0092 7.08506C19.3937 8.33138 19.9053 8.81231 20.1775 9.42319L21.5804 8.79798C21.1445 7.81873 20.3208 7.09938 19.0356 5.94311L18.0092 7.08506ZM8.98416 1.53494C10.6029 1.53494 11.2138 1.54722 11.7572 1.75596L12.3077 0.323405C11.4359 -0.012222 10.4863 5.70826e-05 8.98416 5.70826e-05V1.53494ZM14.9855 2.29828C13.8743 1.29856 13.1795 0.656985 12.3077 0.323405L11.7582 1.75596C12.3026 1.9647 12.761 2.36172 13.9582 3.43921L14.9855 2.29828ZM8.95346 20.4651C7.00212 20.4651 5.61664 20.4631 4.56371 20.3219C3.53534 20.1837 2.94185 19.9238 2.50902 19.491L1.42437 20.5756C2.18976 21.3431 3.16083 21.6818 4.36008 21.8434C5.53682 22.002 7.04612 22 8.95346 22V20.4651ZM1.29521e-06 13.0465C1.29521e-06 14.9539 -0.00204521 16.4621 0.156559 17.6399C0.318233 18.8392 0.657953 19.8102 1.42335 20.5766L2.50799 19.492C2.07618 19.0581 1.81627 18.4646 1.67814 17.4353C1.53693 16.3844 1.53488 14.9979 1.53488 13.0465H1.29521e-06ZM13.0465 22C14.9538 22 16.4621 22.002 17.6399 21.8434C18.8391 21.6818 19.8102 21.342 20.5766 20.5766L19.4919 19.492C19.0581 19.9238 18.4646 20.1837 17.4352 20.3219C16.3843 20.4631 14.9978 20.4651 13.0465 20.4651V22ZM20.4651 13.0465C20.4651 14.9979 20.463 16.3844 20.3218 17.4363C20.1837 18.4647 19.9238 19.0581 19.4909 19.491L20.5756 20.5756C21.343 19.8102 21.6817 18.8392 21.8434 17.6399C22.002 16.4632 21.9999 14.9539 21.9999 13.0465H20.4651ZM1.53488 8.95352C1.53488 7.00217 1.53693 5.61669 1.67814 4.56376C1.81627 3.53539 2.07618 2.94191 2.50902 2.50907L1.42437 1.42442C0.656929 2.18982 0.318233 3.16088 0.156559 4.36014C-0.00204521 5.53688 1.29521e-06 7.04617 1.29521e-06 8.95352H1.53488ZM8.98416 5.70826e-05C7.06556 5.70826e-05 5.55012 -0.00198942 4.36827 0.156615C3.1639 0.318289 2.18976 0.658009 1.42335 1.4234L2.50799 2.50805C2.94185 2.07624 3.53636 1.81633 4.57189 1.67819C5.62891 1.53698 7.02258 1.53494 8.98416 1.53494V5.70826e-05Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.0232 1.27905V3.83718C12.0232 6.24899 12.0232 7.45541 12.7722 8.20443C13.5212 8.95345 14.7276 8.95345 17.1395 8.95345H21.2325"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else if item.label === 'Pools'}
|
{:else if item.label === 'Pools'}
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg
|
||||||
<path d="M6.07102 2.7508H4.64702C4.80588 1.9743 5.22797 1.27646 5.84196 0.775241C6.45595 0.274027 7.22417 0.000183205 8.01676 0H16.7276C18.1259 0 19.467 0.55548 20.4558 1.54424C21.4445 2.533 22 3.87405 22 5.27237V13.9832C22 14.7743 21.7273 15.5411 21.2279 16.1546C20.7285 16.7681 20.033 17.1907 19.2584 17.3511V15.9253C19.6583 15.7816 20.0042 15.518 20.2487 15.1704C20.4932 14.8228 20.6245 14.4082 20.6246 13.9832V5.27237C20.6246 4.23883 20.214 3.24762 19.4832 2.5168C18.7524 1.78597 17.7612 1.3754 16.7276 1.3754H8.01676C7.59001 1.37527 7.17373 1.50748 6.82525 1.75381C6.47678 2.00014 6.21327 2.34846 6.07102 2.7508ZM3.4385 3.66774C2.52656 3.66774 1.65196 4.03001 1.00711 4.67485C0.36227 5.3197 0 6.19429 0 7.10624V18.5679C0 19.4799 0.36227 20.3545 1.00711 20.9993C1.65196 21.6442 2.52656 22.0064 3.4385 22.0064H14.9002C15.8121 22.0064 16.6867 21.6442 17.3316 20.9993C17.9764 20.3545 18.3387 19.4799 18.3387 18.5679V7.10624C18.3387 6.19429 17.9764 5.3197 17.3316 4.67485C16.6867 4.03001 15.8121 3.66774 14.9002 3.66774H3.4385ZM1.3754 7.10624C1.3754 6.55907 1.59276 6.03431 1.97967 5.64741C2.36658 5.2605 2.89133 5.04314 3.4385 5.04314H14.9002C15.4473 5.04314 15.9721 5.2605 16.359 5.64741C16.7459 6.03431 16.9633 6.55907 16.9633 7.10624V18.5679C16.9633 19.1151 16.7459 19.6398 16.359 20.0268C15.9721 20.4137 15.4473 20.631 14.9002 20.631H3.4385C2.89133 20.631 2.36658 20.4137 1.97967 20.0268C1.59276 19.6398 1.3754 19.1151 1.3754 18.5679V7.10624Z" fill="currentColor"/>
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.07102 2.7508H4.64702C4.80588 1.9743 5.22797 1.27646 5.84196 0.775241C6.45595 0.274027 7.22417 0.000183205 8.01676 0H16.7276C18.1259 0 19.467 0.55548 20.4558 1.54424C21.4445 2.533 22 3.87405 22 5.27237V13.9832C22 14.7743 21.7273 15.5411 21.2279 16.1546C20.7285 16.7681 20.033 17.1907 19.2584 17.3511V15.9253C19.6583 15.7816 20.0042 15.518 20.2487 15.1704C20.4932 14.8228 20.6245 14.4082 20.6246 13.9832V5.27237C20.6246 4.23883 20.214 3.24762 19.4832 2.5168C18.7524 1.78597 17.7612 1.3754 16.7276 1.3754H8.01676C7.59001 1.37527 7.17373 1.50748 6.82525 1.75381C6.47678 2.00014 6.21327 2.34846 6.07102 2.7508ZM3.4385 3.66774C2.52656 3.66774 1.65196 4.03001 1.00711 4.67485C0.36227 5.3197 0 6.19429 0 7.10624V18.5679C0 19.4799 0.36227 20.3545 1.00711 20.9993C1.65196 21.6442 2.52656 22.0064 3.4385 22.0064H14.9002C15.8121 22.0064 16.6867 21.6442 17.3316 20.9993C17.9764 20.3545 18.3387 19.4799 18.3387 18.5679V7.10624C18.3387 6.19429 17.9764 5.3197 17.3316 4.67485C16.6867 4.03001 15.8121 3.66774 14.9002 3.66774H3.4385ZM1.3754 7.10624C1.3754 6.55907 1.59276 6.03431 1.97967 5.64741C2.36658 5.2605 2.89133 5.04314 3.4385 5.04314H14.9002C15.4473 5.04314 15.9721 5.2605 16.359 5.64741C16.7459 6.03431 16.9633 6.55907 16.9633 7.10624V18.5679C16.9633 19.1151 16.7459 19.6398 16.359 20.0268C15.9721 20.4137 15.4473 20.631 14.9002 20.631H3.4385C2.89133 20.631 2.36658 20.4137 1.97967 20.0268C1.59276 19.6398 1.3754 19.1151 1.3754 18.5679V7.10624Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else if item.label === 'Settings'}
|
{:else if item.label === 'Settings'}
|
||||||
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg
|
||||||
<path d="M11.371 15.3C13.1935 15.3 14.671 13.8226 14.671 12C14.671 10.1775 13.1935 8.70001 11.371 8.70001C9.54844 8.70001 8.07098 10.1775 8.07098 12C8.07098 13.8226 9.54844 15.3 11.371 15.3Z" stroke="currentColor" stroke-width="1.5"/>
|
width="23"
|
||||||
<path d="M13.3125 1.1672C12.9088 1 12.3962 1 11.371 1C10.3458 1 9.8332 1 9.4295 1.1672C9.1624 1.27776 8.91971 1.43989 8.7153 1.6443C8.51089 1.84871 8.34877 2.0914 8.2382 2.3585C8.137 2.6038 8.0963 2.8909 8.0809 3.3078C8.07405 3.60919 7.9907 3.90389 7.8387 4.16423C7.68669 4.42456 7.47101 4.642 7.2119 4.7961C6.94889 4.94355 6.65271 5.02171 6.35119 5.02325C6.04967 5.02479 5.75271 4.94965 5.4882 4.8049C5.1186 4.6091 4.8513 4.5013 4.5862 4.4661C4.00795 4.39006 3.42317 4.54674 2.9604 4.9017C2.615 5.169 2.3576 5.6123 1.845 6.5C1.3324 7.3877 1.075 7.831 1.0189 8.2655C0.981102 8.552 1.00012 8.84314 1.07486 9.12228C1.1496 9.40143 1.2786 9.66312 1.4545 9.8924C1.6173 10.1036 1.845 10.2807 2.1981 10.5029C2.7184 10.8296 3.0528 11.3862 3.0528 12C3.0528 12.6138 2.7184 13.1704 2.1981 13.496C1.845 13.7193 1.6162 13.8964 1.4545 14.1076C1.2786 14.3369 1.1496 14.5986 1.07486 14.8777C1.00012 15.1569 0.981102 15.448 1.0189 15.7345C1.0761 16.1679 1.3324 16.6123 1.8439 17.5C2.3576 18.3877 2.6139 18.831 2.9604 19.0983C3.18968 19.2742 3.45137 19.4032 3.73052 19.4779C4.00967 19.5527 4.30081 19.5717 4.5873 19.5339C4.8513 19.4987 5.1186 19.3909 5.4882 19.1951C5.75271 19.0503 6.04967 18.9752 6.35119 18.9767C6.65271 18.9783 6.94889 19.0565 7.2119 19.2039C7.7432 19.5119 8.0589 20.0784 8.0809 20.6922C8.0963 21.1102 8.1359 21.3962 8.2382 21.6415C8.34877 21.9086 8.51089 22.1513 8.7153 22.3557C8.91971 22.5601 9.1624 22.7222 9.4295 22.8328C9.8332 23 10.3458 23 11.371 23C12.3962 23 12.9088 23 13.3125 22.8328C13.5796 22.7222 13.8223 22.5601 14.0267 22.3557C14.2311 22.1513 14.3932 21.9086 14.5038 21.6415C14.605 21.3962 14.6457 21.1102 14.6611 20.6922C14.6831 20.0784 14.9988 19.5108 15.5301 19.2039C15.7931 19.0565 16.0893 18.9783 16.3908 18.9767C16.6923 18.9752 16.9893 19.0503 17.2538 19.1951C17.6234 19.3909 17.8907 19.4987 18.1547 19.5339C18.4412 19.5717 18.7323 19.5527 19.0115 19.4779C19.2906 19.4032 19.5523 19.2742 19.7816 19.0983C20.1281 18.8321 20.3844 18.3877 20.897 17.5C21.4096 16.6123 21.667 16.169 21.7231 15.7345C21.7609 15.448 21.7419 15.1569 21.6672 14.8777C21.5924 14.5986 21.4634 14.3369 21.2875 14.1076C21.1247 13.8964 20.897 13.7193 20.5439 13.4971C20.2862 13.3405 20.0726 13.1209 19.9231 12.859C19.7736 12.5971 19.6931 12.3015 19.6892 12C19.6892 11.3862 20.0236 10.8296 20.5439 10.504C20.897 10.2807 21.1258 10.1036 21.2875 9.8924C21.4634 9.66312 21.5924 9.40143 21.6672 9.12228C21.7419 8.84314 21.7609 8.552 21.7231 8.2655C21.6659 7.8321 21.4096 7.3877 20.8981 6.5C20.3844 5.6123 20.1281 5.169 19.7816 4.9017C19.5523 4.7258 19.2906 4.59679 19.0115 4.52205C18.7323 4.44731 18.4412 4.4283 18.1547 4.4661C17.8907 4.5013 17.6234 4.6091 17.2527 4.8049C16.9883 4.94946 16.6916 5.02449 16.3903 5.02295C16.089 5.02141 15.793 4.94335 15.5301 4.7961C15.271 4.642 15.0553 4.42456 14.9033 4.16423C14.7513 3.90389 14.668 3.60919 14.6611 3.3078C14.6457 2.8898 14.6061 2.6038 14.5038 2.3585C14.3932 2.0914 14.2311 1.84871 14.0267 1.6443C13.8223 1.43989 13.5796 1.27776 13.3125 1.1672Z" stroke="currentColor" stroke-width="1.5"/>
|
height="24"
|
||||||
|
viewBox="0 0 23 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11.371 15.3C13.1935 15.3 14.671 13.8226 14.671 12C14.671 10.1775 13.1935 8.70001 11.371 8.70001C9.54844 8.70001 8.07098 10.1775 8.07098 12C8.07098 13.8226 9.54844 15.3 11.371 15.3Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13.3125 1.1672C12.9088 1 12.3962 1 11.371 1C10.3458 1 9.8332 1 9.4295 1.1672C9.1624 1.27776 8.91971 1.43989 8.7153 1.6443C8.51089 1.84871 8.34877 2.0914 8.2382 2.3585C8.137 2.6038 8.0963 2.8909 8.0809 3.3078C8.07405 3.60919 7.9907 3.90389 7.8387 4.16423C7.68669 4.42456 7.47101 4.642 7.2119 4.7961C6.94889 4.94355 6.65271 5.02171 6.35119 5.02325C6.04967 5.02479 5.75271 4.94965 5.4882 4.8049C5.1186 4.6091 4.8513 4.5013 4.5862 4.4661C4.00795 4.39006 3.42317 4.54674 2.9604 4.9017C2.615 5.169 2.3576 5.6123 1.845 6.5C1.3324 7.3877 1.075 7.831 1.0189 8.2655C0.981102 8.552 1.00012 8.84314 1.07486 9.12228C1.1496 9.40143 1.2786 9.66312 1.4545 9.8924C1.6173 10.1036 1.845 10.2807 2.1981 10.5029C2.7184 10.8296 3.0528 11.3862 3.0528 12C3.0528 12.6138 2.7184 13.1704 2.1981 13.496C1.845 13.7193 1.6162 13.8964 1.4545 14.1076C1.2786 14.3369 1.1496 14.5986 1.07486 14.8777C1.00012 15.1569 0.981102 15.448 1.0189 15.7345C1.0761 16.1679 1.3324 16.6123 1.8439 17.5C2.3576 18.3877 2.6139 18.831 2.9604 19.0983C3.18968 19.2742 3.45137 19.4032 3.73052 19.4779C4.00967 19.5527 4.30081 19.5717 4.5873 19.5339C4.8513 19.4987 5.1186 19.3909 5.4882 19.1951C5.75271 19.0503 6.04967 18.9752 6.35119 18.9767C6.65271 18.9783 6.94889 19.0565 7.2119 19.2039C7.7432 19.5119 8.0589 20.0784 8.0809 20.6922C8.0963 21.1102 8.1359 21.3962 8.2382 21.6415C8.34877 21.9086 8.51089 22.1513 8.7153 22.3557C8.91971 22.5601 9.1624 22.7222 9.4295 22.8328C9.8332 23 10.3458 23 11.371 23C12.3962 23 12.9088 23 13.3125 22.8328C13.5796 22.7222 13.8223 22.5601 14.0267 22.3557C14.2311 22.1513 14.3932 21.9086 14.5038 21.6415C14.605 21.3962 14.6457 21.1102 14.6611 20.6922C14.6831 20.0784 14.9988 19.5108 15.5301 19.2039C15.7931 19.0565 16.0893 18.9783 16.3908 18.9767C16.6923 18.9752 16.9893 19.0503 17.2538 19.1951C17.6234 19.3909 17.8907 19.4987 18.1547 19.5339C18.4412 19.5717 18.7323 19.5527 19.0115 19.4779C19.2906 19.4032 19.5523 19.2742 19.7816 19.0983C20.1281 18.8321 20.3844 18.3877 20.897 17.5C21.4096 16.6123 21.667 16.169 21.7231 15.7345C21.7609 15.448 21.7419 15.1569 21.6672 14.8777C21.5924 14.5986 21.4634 14.3369 21.2875 14.1076C21.1247 13.8964 20.897 13.7193 20.5439 13.4971C20.2862 13.3405 20.0726 13.1209 19.9231 12.859C19.7736 12.5971 19.6931 12.3015 19.6892 12C19.6892 11.3862 20.0236 10.8296 20.5439 10.504C20.897 10.2807 21.1258 10.1036 21.2875 9.8924C21.4634 9.66312 21.5924 9.40143 21.6672 9.12228C21.7419 8.84314 21.7609 8.552 21.7231 8.2655C21.6659 7.8321 21.4096 7.3877 20.8981 6.5C20.3844 5.6123 20.1281 5.169 19.7816 4.9017C19.5523 4.7258 19.2906 4.59679 19.0115 4.52205C18.7323 4.44731 18.4412 4.4283 18.1547 4.4661C17.8907 4.5013 17.6234 4.6091 17.2527 4.8049C16.9883 4.94946 16.6916 5.02449 16.3903 5.02295C16.089 5.02141 15.793 4.94335 15.5301 4.7961C15.271 4.642 15.0553 4.42456 14.9033 4.16423C14.7513 3.90389 14.668 3.60919 14.6611 3.3078C14.6457 2.8898 14.6061 2.6038 14.5038 2.3585C14.3932 2.0914 14.2311 1.84871 14.0267 1.6443C13.8223 1.43989 13.5796 1.27776 13.3125 1.1672Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
@@ -110,7 +194,9 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 0.15s, color 0.15s;
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav:hover,
|
.nav:hover,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/admin/users', label: 'Users' },
|
{ href: '/admin/users', label: 'Users' },
|
||||||
{ href: '/admin/audit', label: 'Audit log' },
|
{ href: '/admin/audit', label: 'Audit log' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -14,17 +14,21 @@
|
|||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M10 3L5 8L10 13"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="admin-title">Admin</span>
|
<span class="admin-title">Admin</span>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
<a
|
<a href={tab.href} class="tab" class:active={$page.url.pathname.startsWith(tab.href)}
|
||||||
href={tab.href}
|
>{tab.label}</a
|
||||||
class="tab"
|
>
|
||||||
class:active={$page.url.pathname.startsWith(tab.href)}
|
|
||||||
>{tab.label}</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -6,42 +6,42 @@
|
|||||||
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
// Auth
|
// Auth
|
||||||
user_login: 'User logged in',
|
user_login: 'User logged in',
|
||||||
user_logout: 'User logged out',
|
user_logout: 'User logged out',
|
||||||
// Files
|
// Files
|
||||||
file_create: 'File uploaded',
|
file_create: 'File uploaded',
|
||||||
file_edit: 'File edited',
|
file_edit: 'File edited',
|
||||||
file_delete: 'File deleted',
|
file_delete: 'File deleted',
|
||||||
file_restore: 'File restored',
|
file_restore: 'File restored',
|
||||||
file_permanent_delete: 'File permanently deleted',
|
file_permanent_delete: 'File permanently deleted',
|
||||||
file_replace: 'File replaced',
|
file_replace: 'File replaced',
|
||||||
// Tags
|
// Tags
|
||||||
tag_create: 'Tag created',
|
tag_create: 'Tag created',
|
||||||
tag_edit: 'Tag edited',
|
tag_edit: 'Tag edited',
|
||||||
tag_delete: 'Tag deleted',
|
tag_delete: 'Tag deleted',
|
||||||
// Categories
|
// Categories
|
||||||
category_create: 'Category created',
|
category_create: 'Category created',
|
||||||
category_edit: 'Category edited',
|
category_edit: 'Category edited',
|
||||||
category_delete: 'Category deleted',
|
category_delete: 'Category deleted',
|
||||||
// Pools
|
// Pools
|
||||||
pool_create: 'Pool created',
|
pool_create: 'Pool created',
|
||||||
pool_edit: 'Pool edited',
|
pool_edit: 'Pool edited',
|
||||||
pool_delete: 'Pool deleted',
|
pool_delete: 'Pool deleted',
|
||||||
// Relations
|
// Relations
|
||||||
file_tag_add: 'Tag added to file',
|
file_tag_add: 'Tag added to file',
|
||||||
file_tag_remove: 'Tag removed from file',
|
file_tag_remove: 'Tag removed from file',
|
||||||
file_pool_add: 'File added to pool',
|
file_pool_add: 'File added to pool',
|
||||||
file_pool_remove: 'File removed from pool',
|
file_pool_remove: 'File removed from pool',
|
||||||
// ACL
|
// ACL
|
||||||
acl_change: 'ACL changed',
|
acl_change: 'ACL changed',
|
||||||
// Admin
|
// Admin
|
||||||
user_create: 'User created',
|
user_create: 'User created',
|
||||||
user_delete: 'User deleted',
|
user_delete: 'User deleted',
|
||||||
user_block: 'User blocked',
|
user_block: 'User blocked',
|
||||||
user_unblock: 'User unblocked',
|
user_unblock: 'User unblocked',
|
||||||
user_role_change: 'User role changed',
|
user_role_change: 'User role changed',
|
||||||
// Sessions
|
// Sessions
|
||||||
session_terminate: 'Session terminated',
|
session_terminate: 'Session terminated'
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Filters ----
|
// ---- Filters ----
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
// ---- Data ----
|
// ---- Data ----
|
||||||
let entries = $state<AuditEntry[]>([]);
|
let entries = $state<AuditEntry[]>([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let page = $state(0); // 0-based
|
let page = $state(0); // 0-based
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let initialLoaded = $state(false);
|
let initialLoaded = $state(false);
|
||||||
@@ -65,14 +65,23 @@
|
|||||||
// ---- Users for filter dropdown ----
|
// ---- Users for filter dropdown ----
|
||||||
let allUsers = $state<User[]>([]);
|
let allUsers = $state<User[]>([]);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
api.get<UserOffsetPage>('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {});
|
api
|
||||||
|
.get<UserOffsetPage>('/users?limit=200')
|
||||||
|
.then((r) => {
|
||||||
|
allUsers = r.items ?? [];
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unknown action types not in ACTION_LABELS (server may add new ones)
|
// Unknown action types not in ACTION_LABELS (server may add new ones)
|
||||||
let knownActions = $derived([...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]);
|
let knownActions = $derived(
|
||||||
|
[...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Reset on filter change ----
|
// ---- Reset on filter change ----
|
||||||
let filterKey = $derived(`${filterUserId}|${filterAction}|${filterObjectType}|${filterObjectId}|${filterFrom}|${filterTo}`);
|
let filterKey = $derived(
|
||||||
|
`${filterUserId}|${filterAction}|${filterObjectType}|${filterObjectId}|${filterFrom}|${filterTo}`
|
||||||
|
);
|
||||||
let prevFilterKey = $state('');
|
let prevFilterKey = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -94,12 +103,12 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(page * LIMIT) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(page * LIMIT) });
|
||||||
if (filterUserId) params.set('user_id', filterUserId);
|
if (filterUserId) params.set('user_id', filterUserId);
|
||||||
if (filterAction) params.set('action', filterAction);
|
if (filterAction) params.set('action', filterAction);
|
||||||
if (filterObjectType) params.set('object_type', filterObjectType);
|
if (filterObjectType) params.set('object_type', filterObjectType);
|
||||||
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
||||||
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
||||||
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
||||||
|
|
||||||
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
||||||
entries = res.items ?? [];
|
entries = res.items ?? [];
|
||||||
@@ -122,8 +131,12 @@
|
|||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleString(undefined, {
|
return d.toLocaleString(undefined, {
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,60 +224,74 @@
|
|||||||
<p class="msg error" role="alert">{error}</p>
|
<p class="msg error" role="alert">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Object</th>
|
|
||||||
<th>ID</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each entries as e (e.id)}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ts-cell">{formatTs(e.performed_at)}</td>
|
<th>Time</th>
|
||||||
<td class="user-cell">{e.user_name ?? '—'}</td>
|
<th>User</th>
|
||||||
<td class="action-cell">
|
<th>Action</th>
|
||||||
<span class="action-tag" class:file={e.object_type === 'file'} class:tag={e.object_type === 'tag'} class:pool={e.object_type === 'pool'} class:cat={e.object_type === 'category'}>
|
<th>Object</th>
|
||||||
{actionLabel(e.action)}
|
<th>ID</th>
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="obj-type-cell">{e.object_type ?? '—'}</td>
|
|
||||||
<td class="obj-id-cell" title={e.object_id ?? ''}>{shortId(e.object_id)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each entries as e (e.id)}
|
||||||
|
<tr>
|
||||||
|
<td class="ts-cell">{formatTs(e.performed_at)}</td>
|
||||||
|
<td class="user-cell">{e.user_name ?? '—'}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<span
|
||||||
|
class="action-tag"
|
||||||
|
class:file={e.object_type === 'file'}
|
||||||
|
class:tag={e.object_type === 'tag'}
|
||||||
|
class:pool={e.object_type === 'pool'}
|
||||||
|
class:cat={e.object_type === 'category'}
|
||||||
|
>
|
||||||
|
{actionLabel(e.action)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="obj-type-cell">{e.object_type ?? '—'}</td>
|
||||||
|
<td class="obj-id-cell" title={e.object_id ?? ''}>{shortId(e.object_id)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<tr class="loading-row">
|
<tr class="loading-row">
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !loading && initialLoaded && entries.length === 0}
|
{#if !loading && initialLoaded && entries.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="empty-cell">No entries match the current filters.</td>
|
<td colspan="5" class="empty-cell">No entries match the current filters.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination">
|
|
||||||
<button class="page-btn" onclick={() => goToPage(page - 1)} disabled={page === 0 || loading}>
|
|
||||||
← Prev
|
|
||||||
</button>
|
|
||||||
<span class="page-info">Page {page + 1} of {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => goToPage(page + 1)} disabled={page >= totalPages - 1 || loading}>
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="pagination">
|
||||||
|
<button
|
||||||
|
class="page-btn"
|
||||||
|
onclick={() => goToPage(page - 1)}
|
||||||
|
disabled={page === 0 || loading}
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span class="page-info">Page {page + 1} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
class="page-btn"
|
||||||
|
onclick={() => goToPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1 || loading}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -428,10 +455,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-tag.file { background-color: color-mix(in srgb, var(--color-info) 12%, transparent); color: var(--color-info); }
|
.action-tag.file {
|
||||||
.action-tag.tag { background-color: color-mix(in srgb, #7ECBA1 12%, transparent); color: #7ECBA1; }
|
background-color: color-mix(in srgb, var(--color-info) 12%, transparent);
|
||||||
.action-tag.pool { background-color: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); }
|
color: var(--color-info);
|
||||||
.action-tag.cat { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); }
|
}
|
||||||
|
.action-tag.tag {
|
||||||
|
background-color: color-mix(in srgb, #7ecba1 12%, transparent);
|
||||||
|
color: #7ecba1;
|
||||||
|
}
|
||||||
|
.action-tag.pool {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
.action-tag.cat {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.obj-type-cell {
|
.obj-type-cell {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -466,7 +505,11 @@
|
|||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
password: newPassword.trim(),
|
password: newPassword.trim(),
|
||||||
can_create: newCanCreate,
|
can_create: newCanCreate,
|
||||||
is_admin: newIsAdmin,
|
is_admin: newIsAdmin
|
||||||
});
|
});
|
||||||
users = [u, ...users];
|
users = [u, ...users];
|
||||||
total++;
|
total++;
|
||||||
@@ -73,7 +73,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { void load(); });
|
$effect(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Users — Admin | Tanabata</title></svelte:head>
|
<svelte:head><title>Users — Admin | Tanabata</title></svelte:head>
|
||||||
@@ -90,8 +92,20 @@
|
|||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
{#if createError}<p class="form-error" role="alert">{createError}</p>{/if}
|
{#if createError}<p class="form-error" role="alert">{createError}</p>{/if}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input class="input" type="text" placeholder="Username" bind:value={newName} autocomplete="off" />
|
<input
|
||||||
<input class="input" type="password" placeholder="Password" bind:value={newPassword} autocomplete="new-password" />
|
class="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
bind:value={newName}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row checks">
|
<div class="form-row checks">
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
@@ -140,7 +154,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" class:admin={u.is_admin} class:creator={!u.is_admin && u.can_create}>
|
<span
|
||||||
|
class="badge"
|
||||||
|
class:admin={u.is_admin}
|
||||||
|
class:creator={!u.is_admin && u.can_create}
|
||||||
|
>
|
||||||
{u.is_admin ? 'Admin' : u.can_create ? 'Creator' : 'Viewer'}
|
{u.is_admin ? 'Admin' : u.can_create ? 'Creator' : 'Viewer'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -154,12 +172,27 @@
|
|||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<button class="icon-btn" onclick={() => goto(`/admin/users/${u.id}`)} title="Edit">
|
<button class="icon-btn" onclick={() => goto(`/admin/users/${u.id}`)} title="Edit">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M9.5 2.5l2 2-7 7H2.5v-2l7-7z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M9.5 2.5l2 2-7 7H2.5v-2l7-7z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn danger" onclick={() => (confirmDeleteUser = u)} title="Delete">
|
<button
|
||||||
|
class="icon-btn danger"
|
||||||
|
onclick={() => (confirmDeleteUser = u)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 4h10M5 4V2.5h4V4M5.5 6.5v4M8.5 6.5v4M3 4l.8 7.5h6.4L11 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M2 4h10M5 4V2.5h4V4M5.5 6.5v4M8.5 6.5v4M3 4l.8 7.5h6.4L11 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -265,7 +298,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
@@ -348,8 +384,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge.active {
|
.badge.active {
|
||||||
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
background-color: color-mix(in srgb, #7ecba1 15%, transparent);
|
||||||
color: #7ECBA1;
|
color: #7ecba1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.blocked {
|
.badge.blocked {
|
||||||
@@ -402,14 +438,21 @@
|
|||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error, .empty {
|
.error,
|
||||||
|
.empty {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error { color: var(--color-danger); }
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -25,16 +25,20 @@
|
|||||||
const id = userId;
|
const id = userId;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
void api.get<User>(`/users/${id}`).then((u) => {
|
void api
|
||||||
user = u;
|
.get<User>(`/users/${id}`)
|
||||||
isAdmin = u.is_admin ?? false;
|
.then((u) => {
|
||||||
canCreate = u.can_create ?? false;
|
user = u;
|
||||||
isBlocked = u.is_blocked ?? false;
|
isAdmin = u.is_admin ?? false;
|
||||||
}).catch((e) => {
|
canCreate = u.can_create ?? false;
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load user';
|
isBlocked = u.is_blocked ?? false;
|
||||||
}).finally(() => {
|
})
|
||||||
loading = false;
|
.catch((e) => {
|
||||||
});
|
error = e instanceof ApiError ? e.message : 'Failed to load user';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -46,7 +50,7 @@
|
|||||||
const updated = await api.patch<User>(`/users/${user.id}`, {
|
const updated = await api.patch<User>(`/users/${user.id}`, {
|
||||||
is_admin: isAdmin,
|
is_admin: isAdmin,
|
||||||
can_create: canCreate,
|
can_create: canCreate,
|
||||||
is_blocked: isBlocked,
|
is_blocked: isBlocked
|
||||||
});
|
});
|
||||||
user = updated;
|
user = updated;
|
||||||
saveSuccess = true;
|
saveSuccess = true;
|
||||||
@@ -74,9 +78,7 @@
|
|||||||
<svelte:head><title>{user?.name ?? 'User'} — Admin | Tanabata</title></svelte:head>
|
<svelte:head><title>{user?.name ?? 'User'} — Admin | Tanabata</title></svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<button class="back-link" onclick={() => goto('/admin/users')}>
|
<button class="back-link" onclick={() => goto('/admin/users')}> ← All users </button>
|
||||||
← All users
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="msg error" role="alert">{error}</p>
|
<p class="msg error" role="alert">{error}</p>
|
||||||
@@ -101,10 +103,12 @@
|
|||||||
<p class="toggle-hint">Full access to all data and admin panel.</p>
|
<p class="toggle-hint">Full access to all data and admin panel.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="toggle" class:on={isAdmin}
|
class="toggle"
|
||||||
role="switch" aria-checked={isAdmin}
|
class:on={isAdmin}
|
||||||
onclick={() => (isAdmin = !isAdmin)}
|
role="switch"
|
||||||
><span class="thumb"></span></button>
|
aria-checked={isAdmin}
|
||||||
|
onclick={() => (isAdmin = !isAdmin)}><span class="thumb"></span></button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
@@ -113,10 +117,12 @@
|
|||||||
<p class="toggle-hint">Can upload files and create tags, pools, categories.</p>
|
<p class="toggle-hint">Can upload files and create tags, pools, categories.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="toggle" class:on={canCreate}
|
class="toggle"
|
||||||
role="switch" aria-checked={canCreate}
|
class:on={canCreate}
|
||||||
onclick={() => (canCreate = !canCreate)}
|
role="switch"
|
||||||
><span class="thumb"></span></button>
|
aria-checked={canCreate}
|
||||||
|
onclick={() => (canCreate = !canCreate)}><span class="thumb"></span></button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,10 +135,13 @@
|
|||||||
<p class="toggle-hint">Blocked users cannot log in.</p>
|
<p class="toggle-hint">Blocked users cannot log in.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="toggle" class:on={isBlocked} class:danger={isBlocked}
|
class="toggle"
|
||||||
role="switch" aria-checked={isBlocked}
|
class:on={isBlocked}
|
||||||
onclick={() => (isBlocked = !isBlocked)}
|
class:danger={isBlocked}
|
||||||
><span class="thumb"></span></button>
|
role="switch"
|
||||||
|
aria-checked={isBlocked}
|
||||||
|
onclick={() => (isBlocked = !isBlocked)}><span class="thumb"></span></button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -140,7 +149,11 @@
|
|||||||
<button class="btn primary" onclick={save} disabled={saving}>
|
<button class="btn primary" onclick={save} disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn danger-outline" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
<button
|
||||||
|
class="btn danger-outline"
|
||||||
|
onclick={() => (confirmDelete = true)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
{deleting ? 'Deleting…' : 'Delete user'}
|
{deleting ? 'Deleting…' : 'Delete user'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +192,9 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link:hover { color: var(--color-accent); }
|
.back-link:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
@@ -257,8 +272,12 @@
|
|||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
.toggle.on {
|
||||||
.toggle.on.danger { background-color: var(--color-danger); }
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.toggle.on.danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.toggle .thumb {
|
.toggle .thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -271,7 +290,9 @@
|
|||||||
transition: transform 0.15s;
|
transition: transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -290,7 +311,10 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
@@ -316,8 +340,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg.error { color: var(--color-danger); }
|
.msg.error {
|
||||||
.msg.success { color: #7ECBA1; }
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.msg.success {
|
||||||
|
color: #7ecba1;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -335,5 +363,9 @@
|
|||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
const SORT_OPTIONS: { value: CategorySortField; label: string }[] = [
|
const SORT_OPTIONS: { value: CategorySortField; label: string }[] = [
|
||||||
{ value: 'name', label: 'Name' },
|
{ value: 'name', label: 'Name' },
|
||||||
{ value: 'color', label: 'Color' },
|
{ value: 'color', label: 'Color' },
|
||||||
{ value: 'created', label: 'Created' },
|
{ value: 'created', label: 'Created' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let categories = $state<Category[]>([]);
|
let categories = $state<Category[]>([]);
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
limit: String(LIMIT),
|
limit: String(LIMIT),
|
||||||
offset: String(offset),
|
offset: String(offset),
|
||||||
sort: sortState.sort,
|
sort: sortState.sort,
|
||||||
order: sortState.order,
|
order: sortState.order
|
||||||
});
|
});
|
||||||
if (search.trim()) params.set('search', search.trim());
|
if (search.trim()) params.set('search', search.trim());
|
||||||
const page = await api.get<CategoryOffsetPage>(`/categories?${params}`);
|
const page = await api.get<CategoryOffsetPage>(`/categories?${params}`);
|
||||||
@@ -79,7 +79,10 @@
|
|||||||
<select
|
<select
|
||||||
class="sort-select"
|
class="sort-select"
|
||||||
value={sortState.sort}
|
value={sortState.sort}
|
||||||
onchange={(e) => categorySorting.setSort((e.currentTarget as HTMLSelectElement).value as CategorySortField)}
|
onchange={(e) =>
|
||||||
|
categorySorting.setSort(
|
||||||
|
(e.currentTarget as HTMLSelectElement).value as CategorySortField
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{#each SORT_OPTIONS as opt}
|
{#each SORT_OPTIONS as opt}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
@@ -93,11 +96,23 @@
|
|||||||
>
|
>
|
||||||
{#if sortState.order === 'asc'}
|
{#if sortState.order === 'asc'}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M3 9L7 5L11 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M3 5L7 9L11 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -119,7 +134,12 @@
|
|||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -323,7 +343,6 @@
|
|||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -35,16 +35,19 @@
|
|||||||
tags = [];
|
tags = [];
|
||||||
tagsOffset = 0;
|
tagsOffset = 0;
|
||||||
tagsTotal = 0;
|
tagsTotal = 0;
|
||||||
void api.get<Category>(`/categories/${id}`).then((cat) => {
|
void api
|
||||||
category = cat;
|
.get<Category>(`/categories/${id}`)
|
||||||
name = cat.name ?? '';
|
.then((cat) => {
|
||||||
notes = cat.notes ?? '';
|
category = cat;
|
||||||
color = cat.color ? `#${cat.color}` : '#9592B5';
|
name = cat.name ?? '';
|
||||||
isPublic = cat.is_public ?? false;
|
notes = cat.notes ?? '';
|
||||||
loaded = true;
|
color = cat.color ? `#${cat.color}` : '#9592B5';
|
||||||
}).catch((e) => {
|
isPublic = cat.is_public ?? false;
|
||||||
loadError = e instanceof ApiError ? e.message : 'Failed to load category';
|
loaded = true;
|
||||||
});
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'Failed to load category';
|
||||||
|
});
|
||||||
void loadTags(id, 0);
|
void loadTags(id, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@
|
|||||||
limit: String(TAGS_LIMIT),
|
limit: String(TAGS_LIMIT),
|
||||||
offset: String(startOffset),
|
offset: String(startOffset),
|
||||||
sort: 'name',
|
sort: 'name',
|
||||||
order: 'asc',
|
order: 'asc'
|
||||||
});
|
});
|
||||||
const p = await api.get<TagOffsetPage>(`/categories/${id}/tags?${params}`);
|
const p = await api.get<TagOffsetPage>(`/categories/${id}/tags?${params}`);
|
||||||
tags = startOffset === 0 ? (p.items ?? []) : [...tags, ...(p.items ?? [])];
|
tags = startOffset === 0 ? (p.items ?? []) : [...tags, ...(p.items ?? [])];
|
||||||
@@ -80,7 +83,7 @@
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
color: color.slice(1),
|
color: color.slice(1),
|
||||||
is_public: isPublic,
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
goto('/categories');
|
goto('/categories');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -111,7 +114,13 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="page-title">{category?.name ?? 'Category'}</h1>
|
<h1 class="page-title">{category?.name ?? 'Category'}</h1>
|
||||||
@@ -129,7 +138,13 @@
|
|||||||
<p class="error" role="alert">{saveError}</p>
|
<p class="error" role="alert">{saveError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
<form
|
||||||
|
class="form"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="row-fields">
|
<div class="row-fields">
|
||||||
<div class="field" style="flex: 1">
|
<div class="field" style="flex: 1">
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
@@ -151,7 +166,13 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="notes">Notes</label>
|
<label class="label" for="notes">Notes</label>
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="3"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
@@ -173,7 +194,12 @@
|
|||||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-btn"
|
||||||
|
onclick={() => (confirmDelete = true)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,73 +251,134 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: sticky; top: 0; z-index: 10;
|
position: sticky;
|
||||||
display: flex; align-items: center; gap: 8px;
|
top: 0;
|
||||||
padding: 6px 10px; min-height: 44px;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 44px;
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
align-items: center;
|
||||||
border: none; background: none;
|
justify-content: center;
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1; overflow-y: auto;
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 16px 14px calc(60px + 16px);
|
padding: 16px 14px calc(60px + 16px);
|
||||||
display: flex; flex-direction: column; gap: 24px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-row { display: flex; justify-content: center; padding: 40px; }
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
display: block; width: 28px; height: 28px;
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
border-top-color: var(--color-accent);
|
border-top-color: var(--color-accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
|
|
||||||
.color-field { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.75rem; font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%;
|
||||||
height: 36px; padding: 0 10px;
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.color-input {
|
.color-input {
|
||||||
width: 50px; height: 36px; padding: 2px;
|
width: 50px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
@@ -299,67 +386,124 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit;
|
font-size: 0.875rem;
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex; align-items: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.toggle-row .label { margin: 0; }
|
.toggle-row .label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative; width: 44px; height: 26px;
|
position: relative;
|
||||||
border-radius: 13px; border: none;
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: none;
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute; top: 3px; left: 3px;
|
position: absolute;
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
top: 3px;
|
||||||
background-color: #fff; transition: transform 0.2s;
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.action-row { display: flex; gap: 8px; }
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
flex: 1; height: 42px; border-radius: 8px; border: none;
|
flex: 1;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-bg-primary);
|
color: var(--color-bg-primary);
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
height: 42px; padding: 0 18px; border-radius: 8px;
|
height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 8px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
||||||
background: none; color: var(--color-danger);
|
background: none;
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
color: var(--color-danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.delete-btn:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
.delete-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
|
||||||
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.section { display: flex; flex-direction: column; gap: 10px; }
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 0.75rem; font-weight: 600;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
display: flex; gap: 6px; align-items: baseline;
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
@@ -396,7 +540,14 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more:disabled { opacity: 0.5; cursor: default; }
|
.load-more:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
color: color.slice(1),
|
color: color.slice(1),
|
||||||
is_public: isPublic,
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
goto('/categories');
|
goto('/categories');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -37,7 +37,13 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="page-title">New Category</h1>
|
<h1 class="page-title">New Category</h1>
|
||||||
@@ -48,7 +54,13 @@
|
|||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
<form
|
||||||
|
class="form"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="row-fields">
|
<div class="row-fields">
|
||||||
<div class="field" style="flex: 1">
|
<div class="field" style="flex: 1">
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
@@ -70,7 +82,13 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="notes">Notes</label>
|
<label class="label" for="notes">Notes</label>
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="3"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
@@ -96,58 +114,110 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: sticky; top: 0; z-index: 10;
|
position: sticky;
|
||||||
display: flex; align-items: center; gap: 8px;
|
top: 0;
|
||||||
padding: 6px 10px; min-height: 44px;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 44px;
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
align-items: center;
|
||||||
border: none; background: none;
|
justify-content: center;
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); }
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 14px calc(60px + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
.row-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.color-field { flex-shrink: 0; }
|
.color-field {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 0.75rem; font-weight: 600;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
.required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%;
|
||||||
height: 36px; padding: 0 10px;
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.color-input {
|
.color-input {
|
||||||
width: 50px; height: 36px; padding: 2px;
|
width: 50px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
@@ -155,45 +225,83 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit;
|
font-size: 0.875rem;
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex; align-items: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.toggle-row .label { margin: 0; }
|
.toggle-row .label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative; width: 44px; height: 26px;
|
position: relative;
|
||||||
border-radius: 13px; border: none;
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: none;
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute; top: 3px; left: 3px;
|
position: absolute;
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
top: 3px;
|
||||||
background-color: #fff; transition: transform 0.2s;
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width: 100%; height: 42px; border-radius: 8px; border: none;
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-bg-primary);
|
color: var(--color-bg-primary);
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; }
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
{ value: 'created', label: 'Created' },
|
{ value: 'created', label: 'Created' },
|
||||||
{ value: 'content_datetime', label: 'Date taken' },
|
{ value: 'content_datetime', label: 'Date taken' },
|
||||||
{ value: 'original_name', label: 'Name' },
|
{ value: 'original_name', label: 'Name' },
|
||||||
{ value: 'mime', label: 'Type' },
|
{ value: 'mime', label: 'Type' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let files = $state<File[]>([]);
|
let files = $state<File[]>([]);
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
const p = new URLSearchParams({
|
const p = new URLSearchParams({
|
||||||
limit: String(LIMIT),
|
limit: String(LIMIT),
|
||||||
sort: sortState.sort,
|
sort: sortState.sort,
|
||||||
order: sortState.order,
|
order: sortState.order
|
||||||
});
|
});
|
||||||
if (filterParam) p.set('filter', filterParam);
|
if (filterParam) p.set('filter', filterParam);
|
||||||
return p;
|
return p;
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
|
let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
|
||||||
let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
|
let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
|
||||||
let viewerNextId = $derived(
|
let viewerNextId = $derived(
|
||||||
activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null,
|
activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Paging near the end of the loaded grid: pull the next page by cursor so the
|
// Paging near the end of the loaded grid: pull the next page by cursor so the
|
||||||
@@ -469,11 +469,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if filterOpen}
|
{#if filterOpen}
|
||||||
<FilterBar
|
<FilterBar value={filterParam} onApply={applyFilter} onClose={() => (filterOpen = false)} />
|
||||||
value={filterParam}
|
|
||||||
onApply={applyFilter}
|
|
||||||
onClose={() => (filterOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
||||||
@@ -535,10 +531,19 @@
|
|||||||
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
|
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
|
||||||
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
|
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
|
||||||
<div class="picker-header">
|
<div class="picker-header">
|
||||||
<span class="picker-title">Edit tags — {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''}</span>
|
<span class="picker-title"
|
||||||
|
>Edit tags — {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}</span
|
||||||
|
>
|
||||||
<button class="picker-close" onclick={() => (tagEditorOpen = false)} aria-label="Close">
|
<button class="picker-close" onclick={() => (tagEditorOpen = false)} aria-label="Close">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M3 3l10 10M13 3L3 13"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,10 +558,17 @@
|
|||||||
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div>
|
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div>
|
||||||
<div class="picker-sheet" role="dialog" aria-label="Add to pool">
|
<div class="picker-sheet" role="dialog" aria-label="Add to pool">
|
||||||
<div class="picker-header">
|
<div class="picker-header">
|
||||||
<span class="picker-title">Add {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''} to pool</span>
|
<span class="picker-title"
|
||||||
|
>Add {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''} to pool</span
|
||||||
|
>
|
||||||
<button class="picker-close" onclick={() => (poolPickerOpen = false)} aria-label="Close">
|
<button class="picker-close" onclick={() => (poolPickerOpen = false)} aria-label="Close">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M3 3l10 10M13 3L3 13"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -697,8 +709,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-header {
|
.picker-header {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
anchor: id,
|
anchor: id,
|
||||||
limit: '3',
|
limit: '3',
|
||||||
sort: sort.sort,
|
sort: sort.sort,
|
||||||
order: sort.order,
|
order: sort.order
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await api.get<FileCursorPage>(`/files?${params}`);
|
const result = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
// In trash, tap always selects (no detail page)
|
// In trash, tap always selects (no detail page)
|
||||||
if (e.shiftKey && lastSelectedIdx !== null) {
|
if (e.shiftKey && lastSelectedIdx !== null) {
|
||||||
const from = Math.min(lastSelectedIdx, idx);
|
const from = Math.min(lastSelectedIdx, idx);
|
||||||
const to = Math.max(lastSelectedIdx, idx);
|
const to = Math.max(lastSelectedIdx, idx);
|
||||||
for (let i = from; i <= to; i++) {
|
for (let i = from; i <= to; i++) {
|
||||||
if (files[i]?.id) selectionStore.select(files[i].id!);
|
if (files[i]?.id) selectionStore.select(files[i].id!);
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,9 @@
|
|||||||
else selectionStore.deselect(files[idx].id!);
|
else selectionStore.deselect(files[idx].id!);
|
||||||
lastSelectedIdx = idx;
|
lastSelectedIdx = idx;
|
||||||
}
|
}
|
||||||
function onTouchEnd() { dragSelecting = false; }
|
function onTouchEnd() {
|
||||||
|
dragSelecting = false;
|
||||||
|
}
|
||||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
document.addEventListener('touchend', onTouchEnd);
|
document.addEventListener('touchend', onTouchEnd);
|
||||||
document.addEventListener('touchcancel', onTouchEnd);
|
document.addEventListener('touchcancel', onTouchEnd);
|
||||||
@@ -145,7 +147,13 @@
|
|||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header>
|
<header>
|
||||||
<button class="back-btn" onclick={() => { selectionStore.exit(); goto('/files'); }}>
|
<button
|
||||||
|
class="back-btn"
|
||||||
|
onclick={() => {
|
||||||
|
selectionStore.exit();
|
||||||
|
goto('/files');
|
||||||
|
}}
|
||||||
|
>
|
||||||
← Files
|
← Files
|
||||||
</button>
|
</button>
|
||||||
<span class="title">Trash</span>
|
<span class="title">Trash</span>
|
||||||
@@ -190,14 +198,27 @@
|
|||||||
<span class="sel-num">{$selectionCount}</span>
|
<span class="sel-num">{$selectionCount}</span>
|
||||||
<span class="sel-label">selected</span>
|
<span class="sel-label">selected</span>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="sel-spacer"></div>
|
<div class="sel-spacer"></div>
|
||||||
<button class="sel-action restore" onclick={() => (confirmRestore = true)} disabled={actionBusy}>
|
<button
|
||||||
|
class="sel-action restore"
|
||||||
|
onclick={() => (confirmRestore = true)}
|
||||||
|
disabled={actionBusy}
|
||||||
|
>
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
<button class="sel-action perm-delete" onclick={() => (confirmPermDelete = true)} disabled={actionBusy}>
|
<button
|
||||||
|
class="sel-action perm-delete"
|
||||||
|
onclick={() => (confirmPermDelete = true)}
|
||||||
|
disabled={actionBusy}
|
||||||
|
>
|
||||||
Delete permanently
|
Delete permanently
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +275,9 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn:hover { color: var(--color-accent); }
|
.back-btn:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -275,7 +298,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-btn:hover { color: var(--color-text-primary); border-color: var(--color-accent); }
|
.select-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.select-btn.active {
|
.select-btn.active {
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
@@ -335,8 +361,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { transform: translateY(12px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(12px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-count {
|
.sel-count {
|
||||||
@@ -363,9 +395,13 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-label { font-size: 0.85rem; }
|
.sel-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sel-spacer { flex: 1; }
|
.sel-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sel-action {
|
.sel-action {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -378,14 +414,17 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-action:disabled { opacity: 0.5; cursor: default; }
|
.sel-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.sel-action.restore {
|
.sel-action.restore {
|
||||||
color: #7ECBA1;
|
color: #7ecba1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-action.restore:hover:not(:disabled) {
|
.sel-action.restore:hover:not(:disabled) {
|
||||||
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
background-color: color-mix(in srgb, #7ecba1 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-action.perm-delete {
|
.sel-action.perm-delete {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
user: {
|
user: {
|
||||||
id: me.id!,
|
id: me.id!,
|
||||||
name: me.name!,
|
name: me.name!,
|
||||||
isAdmin: me.is_admin ?? false,
|
isAdmin: me.is_admin ?? false
|
||||||
},
|
}
|
||||||
}));
|
}));
|
||||||
await goto('/files');
|
await goto('/files');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -106,8 +106,12 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.decoration.left { left: 0; }
|
.decoration.left {
|
||||||
.decoration.right { right: 0; }
|
left: 0;
|
||||||
|
}
|
||||||
|
.decoration.right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -157,7 +161,9 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder { color: var(--color-text-muted); }
|
input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
const SORT_OPTIONS: { value: PoolSortField; label: string }[] = [
|
const SORT_OPTIONS: { value: PoolSortField; label: string }[] = [
|
||||||
{ value: 'name', label: 'Name' },
|
{ value: 'name', label: 'Name' },
|
||||||
{ value: 'created', label: 'Created' },
|
{ value: 'created', label: 'Created' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let pools = $state<Pool[]>([]);
|
let pools = $state<Pool[]>([]);
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
limit: String(LIMIT),
|
limit: String(LIMIT),
|
||||||
offset: String(offset),
|
offset: String(offset),
|
||||||
sort: sortState.sort,
|
sort: sortState.sort,
|
||||||
order: sortState.order,
|
order: sortState.order
|
||||||
});
|
});
|
||||||
if (search.trim()) params.set('search', search.trim());
|
if (search.trim()) params.set('search', search.trim());
|
||||||
const page = await api.get<PoolOffsetPage>(`/pools?${params}`);
|
const page = await api.get<PoolOffsetPage>(`/pools?${params}`);
|
||||||
@@ -82,7 +82,8 @@
|
|||||||
<select
|
<select
|
||||||
class="sort-select"
|
class="sort-select"
|
||||||
value={sortState.sort}
|
value={sortState.sort}
|
||||||
onchange={(e) => poolSorting.setSort((e.currentTarget as HTMLSelectElement).value as PoolSortField)}
|
onchange={(e) =>
|
||||||
|
poolSorting.setSort((e.currentTarget as HTMLSelectElement).value as PoolSortField)}
|
||||||
>
|
>
|
||||||
{#each SORT_OPTIONS as opt}
|
{#each SORT_OPTIONS as opt}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
@@ -96,11 +97,23 @@
|
|||||||
>
|
>
|
||||||
{#if sortState.order === 'asc'}
|
{#if sortState.order === 'asc'}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M3 9L7 5L11 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M3 5L7 9L11 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -122,7 +135,12 @@
|
|||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -139,10 +157,10 @@
|
|||||||
<button class="pool-card" onclick={() => goto(`/pools/${pool.id}`)}>
|
<button class="pool-card" onclick={() => goto(`/pools/${pool.id}`)}>
|
||||||
<div class="pool-icon" aria-hidden="true">
|
<div class="pool-icon" aria-hidden="true">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<rect x="2" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.7"/>
|
<rect x="2" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.7" />
|
||||||
<rect x="11" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
|
<rect x="11" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5" />
|
||||||
<rect x="2" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
|
<rect x="2" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5" />
|
||||||
<rect x="11" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/>
|
<rect x="11" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="pool-info">
|
<div class="pool-info">
|
||||||
@@ -153,8 +171,21 @@
|
|||||||
{#if pool.is_public}<span class="badge-public">public</span>{/if}
|
{#if pool.is_public}<span class="badge-public">public</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<svg class="chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg
|
||||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
class="chevron"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 4l4 4-4 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -331,7 +362,9 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: border-color 0.15s, background-color 0.15s;
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pool-card:hover {
|
.pool-card:hover {
|
||||||
@@ -386,7 +419,6 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
import type { Pool, PoolFile, PoolFileCursorPage, File as FileType, FileCursorPage } from '$lib/api/types';
|
import type {
|
||||||
|
Pool,
|
||||||
|
PoolFile,
|
||||||
|
PoolFileCursorPage,
|
||||||
|
File as FileType,
|
||||||
|
FileCursorPage
|
||||||
|
} from '$lib/api/types';
|
||||||
|
|
||||||
let poolId = $derived(page.params.id);
|
let poolId = $derived(page.params.id);
|
||||||
|
|
||||||
@@ -72,15 +78,18 @@
|
|||||||
filesError = '';
|
filesError = '';
|
||||||
selectedIds = new Set();
|
selectedIds = new Set();
|
||||||
editOpen = false;
|
editOpen = false;
|
||||||
void api.get<Pool>(`/pools/${id}`).then((p) => {
|
void api
|
||||||
pool = p;
|
.get<Pool>(`/pools/${id}`)
|
||||||
name = p.name ?? '';
|
.then((p) => {
|
||||||
notes = p.notes ?? '';
|
pool = p;
|
||||||
isPublic = p.is_public ?? false;
|
name = p.name ?? '';
|
||||||
loaded = true;
|
notes = p.notes ?? '';
|
||||||
}).catch((e) => {
|
isPublic = p.is_public ?? false;
|
||||||
loadError = e instanceof ApiError ? e.message : 'Failed to load pool';
|
loaded = true;
|
||||||
});
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'Failed to load pool';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset files when filter changes
|
// Reset files when filter changes
|
||||||
@@ -126,7 +135,7 @@
|
|||||||
const updated = await api.patch<Pool>(`/pools/${poolId}`, {
|
const updated = await api.patch<Pool>(`/pools/${poolId}`, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
is_public: isPublic,
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
pool = updated;
|
pool = updated;
|
||||||
editOpen = false;
|
editOpen = false;
|
||||||
@@ -218,7 +227,7 @@
|
|||||||
let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
|
let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
|
||||||
let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
|
let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
|
||||||
let viewerNextId = $derived(
|
let viewerNextId = $derived(
|
||||||
activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null,
|
activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
function openFile(file: PoolFile) {
|
function openFile(file: PoolFile) {
|
||||||
@@ -303,7 +312,7 @@
|
|||||||
reorderPending = true;
|
reorderPending = true;
|
||||||
try {
|
try {
|
||||||
await api.put(`/pools/${poolId}/files/reorder`, {
|
await api.put(`/pools/${poolId}/files/reorder`, {
|
||||||
file_ids: files.map((f) => f.id),
|
file_ids: files.map((f) => f.id)
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// non-critical — positions may be out of sync
|
// non-critical — positions may be out of sync
|
||||||
@@ -398,7 +407,13 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={closeAddMode} aria-label="Close">
|
<button class="back-btn" onclick={closeAddMode} aria-label="Close">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<span class="header-title">Add files to "{pool?.name ?? ''}"</span>
|
<span class="header-title">Add files to "{pool?.name ?? ''}"</span>
|
||||||
@@ -417,7 +432,12 @@
|
|||||||
{#if addSearch}
|
{#if addSearch}
|
||||||
<button class="search-clear" onclick={() => (addSearch = '')} aria-label="Clear">
|
<button class="search-clear" onclick={() => (addSearch = '')} aria-label="Clear">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -427,10 +447,7 @@
|
|||||||
<main class="add-main">
|
<main class="add-main">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each addFiles as file, i (file.id)}
|
{#each addFiles as file, i (file.id)}
|
||||||
<div
|
<div class="add-card-wrap" class:already-in={inPoolIds.has(file.id ?? '')}>
|
||||||
class="add-card-wrap"
|
|
||||||
class:already-in={inPoolIds.has(file.id ?? '')}
|
|
||||||
>
|
|
||||||
<FileCard
|
<FileCard
|
||||||
{file}
|
{file}
|
||||||
index={i}
|
index={i}
|
||||||
@@ -461,25 +478,41 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ====== NORMAL POOL VIEW ====== -->
|
<!-- ====== NORMAL POOL VIEW ====== -->
|
||||||
{:else}
|
{:else}
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/pools')} aria-label="Back">
|
<button class="back-btn" onclick={() => goto('/pools')} aria-label="Back">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<span class="header-title">{pool?.name ?? 'Pool'}</span>
|
<span class="header-title">{pool?.name ?? 'Pool'}</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="icon-text-btn" onclick={() => (editOpen = !editOpen)}>
|
<button class="icon-text-btn" onclick={() => (editOpen = !editOpen)}>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||||
<path d="M10.5 2.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M10.5 2.5l2 2-8 8H2.5v-2l8-8z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-text-btn add-btn" onclick={openAddMode}>
|
<button class="icon-text-btn add-btn" onclick={openAddMode}>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||||
<path d="M7.5 2v11M2 7.5h11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M7.5 2v11M2 7.5h11"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -489,7 +522,9 @@
|
|||||||
{#if loadError}
|
{#if loadError}
|
||||||
<p class="load-error" role="alert">{loadError}</p>
|
<p class="load-error" role="alert">{loadError}</p>
|
||||||
{:else if !loaded}
|
{:else if !loaded}
|
||||||
<div class="loading-row"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
<div class="loading-row">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Edit form -->
|
<!-- Edit form -->
|
||||||
{#if editOpen}
|
{#if editOpen}
|
||||||
@@ -499,15 +534,37 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
<input id="name" class="input" type="text" bind:value={name} required placeholder="Pool name" autocomplete="off" />
|
<input
|
||||||
|
id="name"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="Pool name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="notes">Notes</label>
|
<label class="label" for="notes">Notes</label>
|
||||||
<textarea id="notes" class="textarea" rows="2" bind:value={notes} placeholder="Optional notes…"></textarea>
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="2"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<span class="label">Public</span>
|
<span class="label">Public</span>
|
||||||
<button type="button" class="toggle" class:on={isPublic} onclick={() => (isPublic = !isPublic)} role="switch" aria-checked={isPublic} aria-label="Public">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
class:on={isPublic}
|
||||||
|
onclick={() => (isPublic = !isPublic)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isPublic}
|
||||||
|
aria-label="Public"
|
||||||
|
>
|
||||||
<span class="thumb"></span>
|
<span class="thumb"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,12 +589,12 @@
|
|||||||
{#if canReorder && files.length > 1}
|
{#if canReorder && files.length > 1}
|
||||||
<span class="reorder-hint" title="Drag thumbnails to reorder">
|
<span class="reorder-hint" title="Drag thumbnails to reorder">
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
||||||
<circle cx="4" cy="3" r="1" fill="currentColor"/>
|
<circle cx="4" cy="3" r="1" fill="currentColor" />
|
||||||
<circle cx="9" cy="3" r="1" fill="currentColor"/>
|
<circle cx="9" cy="3" r="1" fill="currentColor" />
|
||||||
<circle cx="4" cy="6.5" r="1" fill="currentColor"/>
|
<circle cx="4" cy="6.5" r="1" fill="currentColor" />
|
||||||
<circle cx="9" cy="6.5" r="1" fill="currentColor"/>
|
<circle cx="9" cy="6.5" r="1" fill="currentColor" />
|
||||||
<circle cx="4" cy="10" r="1" fill="currentColor"/>
|
<circle cx="4" cy="10" r="1" fill="currentColor" />
|
||||||
<circle cx="9" cy="10" r="1" fill="currentColor"/>
|
<circle cx="9" cy="10" r="1" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
reorder
|
reorder
|
||||||
</span>
|
</span>
|
||||||
@@ -549,7 +606,12 @@
|
|||||||
title="Filter"
|
title="Filter"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M1 3h12M3 7h8M5 11h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M1 3h12M3 7h8M5 11h4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -557,11 +619,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filterOpen}
|
{#if filterOpen}
|
||||||
<FilterBar
|
<FilterBar value={filterParam} onApply={applyFilter} onClose={() => (filterOpen = false)} />
|
||||||
value={filterParam}
|
|
||||||
onApply={applyFilter}
|
|
||||||
onClose={() => (filterOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- File grid -->
|
<!-- File grid -->
|
||||||
@@ -585,10 +643,10 @@
|
|||||||
ondragend={canReorder ? onDragEnd : undefined}
|
ondragend={canReorder ? onDragEnd : undefined}
|
||||||
>
|
>
|
||||||
<FileCard
|
<FileCard
|
||||||
file={file}
|
{file}
|
||||||
index={i}
|
index={i}
|
||||||
selected={selectedIds.has(file.id ?? '')}
|
selected={selectedIds.has(file.id ?? '')}
|
||||||
selectionMode={selectionMode}
|
{selectionMode}
|
||||||
onTap={(e) => handleTap(file, i, e)}
|
onTap={(e) => handleTap(file, i, e)}
|
||||||
onLongPress={() => handleLongPress(file, i)}
|
onLongPress={() => handleLongPress(file, i)}
|
||||||
/>
|
/>
|
||||||
@@ -625,11 +683,22 @@
|
|||||||
<!-- Selection bar (remove mode) -->
|
<!-- Selection bar (remove mode) -->
|
||||||
{#if selectionMode && !addMode}
|
{#if selectionMode && !addMode}
|
||||||
<div class="selection-bar" role="toolbar">
|
<div class="selection-bar" role="toolbar">
|
||||||
<button class="sel-cancel" onclick={() => { selectedIds = new Set(); lastSelectedIdx = null; }}>
|
<button
|
||||||
|
class="sel-cancel"
|
||||||
|
onclick={() => {
|
||||||
|
selectedIds = new Set();
|
||||||
|
lastSelectedIdx = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span class="sel-num">{selectedIds.size}</span>
|
<span class="sel-num">{selectedIds.size}</span>
|
||||||
<span class="sel-label">selected</span>
|
<span class="sel-label">selected</span>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="sel-spacer"></div>
|
<div class="sel-spacer"></div>
|
||||||
@@ -705,7 +774,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -759,7 +830,11 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -768,7 +843,9 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
.required { color: var(--color-danger); }
|
.required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -783,7 +860,9 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--color-accent); }
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -799,14 +878,18 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.toggle-row .label { margin: 0; }
|
.toggle-row .label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -819,7 +902,9 @@
|
|||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
@@ -830,9 +915,14 @@
|
|||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
.action-row { display: flex; gap: 8px; }
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -846,8 +936,13 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
.submit-btn:hover:not(:disabled) {
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
@@ -861,8 +956,13 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
.delete-btn:hover:not(:disabled) {
|
||||||
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
.delete-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Files header ---- */
|
/* ---- Files header ---- */
|
||||||
.files-header {
|
.files-header {
|
||||||
@@ -975,7 +1075,11 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.load-error,
|
.load-error,
|
||||||
.error {
|
.error {
|
||||||
@@ -1010,8 +1114,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { transform: translateY(12px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(12px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-cancel {
|
.sel-cancel {
|
||||||
@@ -1037,9 +1147,13 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-label { font-size: 0.85rem; }
|
.sel-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sel-spacer { flex: 1; }
|
.sel-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sel-action {
|
.sel-action {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1094,7 +1208,9 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.search-input:focus { border-color: var(--color-accent); }
|
.search-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.search-clear {
|
.search-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1111,7 +1227,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.search-clear:hover { color: var(--color-text-primary); }
|
.search-clear:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.add-main {
|
.add-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1163,5 +1281,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
.add-confirm-btn:hover { background-color: var(--color-accent-hover); }
|
.add-confirm-btn:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
const pool = await api.post<Pool>('/pools', {
|
const pool = await api.post<Pool>('/pools', {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
is_public: isPublic,
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
goto(`/pools/${pool.id}`);
|
goto(`/pools/${pool.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -35,7 +35,13 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/pools')} aria-label="Back">
|
<button class="back-btn" onclick={() => goto('/pools')} aria-label="Back">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="page-title">New Pool</h1>
|
<h1 class="page-title">New Pool</h1>
|
||||||
@@ -46,7 +52,13 @@
|
|||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
<form
|
||||||
|
class="form"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
<input
|
<input
|
||||||
@@ -62,7 +74,13 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="notes">Notes</label>
|
<label class="label" for="notes">Notes</label>
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="3"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
@@ -88,95 +106,174 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: sticky; top: 0; z-index: 10;
|
position: sticky;
|
||||||
display: flex; align-items: center; gap: 8px;
|
top: 0;
|
||||||
padding: 6px 10px; min-height: 44px;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 44px;
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
align-items: center;
|
||||||
border: none; background: none;
|
justify-content: center;
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1; overflow-y: auto;
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 16px 14px calc(60px + 16px);
|
padding: 16px 14px calc(60px + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 0.75rem; font-weight: 600;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
.required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%;
|
||||||
height: 36px; padding: 0 10px;
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit;
|
font-size: 0.875rem;
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex; align-items: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.toggle-row .label { margin: 0; }
|
.toggle-row .label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative; width: 44px; height: 26px;
|
position: relative;
|
||||||
border-radius: 13px; border: none;
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: none;
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute; top: 3px; left: 3px;
|
position: absolute;
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
top: 3px;
|
||||||
background-color: #fff; transition: transform 0.2s;
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
height: 42px; border-radius: 8px; border: none;
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-bg-primary);
|
color: var(--color-bg-primary);
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0 0 8px; }
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
const updated = await api.patch<User>('/users/me', body);
|
const updated = await api.patch<User>('/users/me', body);
|
||||||
authStore.update((s) => ({
|
authStore.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
user: s.user ? { ...s.user, name: updated.name ?? s.user.name } : s.user,
|
user: s.user ? { ...s.user, name: updated.name ?? s.user.name } : s.user
|
||||||
}));
|
}));
|
||||||
password = '';
|
password = '';
|
||||||
passwordConfirm = '';
|
passwordConfirm = '';
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,8 @@
|
|||||||
ua.match(/\b(MSIE|Trident)\b/)?.[0] ??
|
ua.match(/\b(MSIE|Trident)\b/)?.[0] ??
|
||||||
ua.slice(0, 40);
|
ua.slice(0, 40);
|
||||||
const os =
|
const os =
|
||||||
ua.match(/\((Windows[^;)]*|Mac OS X [^;)]*|Linux[^;)]*|Android [^;)]*|iOS [^;)]*)/)?.[1] ?? '';
|
ua.match(/\((Windows[^;)]*|Mac OS X [^;)]*|Linux[^;)]*|Android [^;)]*|iOS [^;)]*)/)?.[1] ??
|
||||||
|
'';
|
||||||
return os ? `${browser} · ${os}` : browser;
|
return os ? `${browser} · ${os}` : browser;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -207,14 +208,24 @@
|
|||||||
{#if $themeStore === 'light'}
|
{#if $themeStore === 'light'}
|
||||||
<!-- Sun icon -->
|
<!-- Sun icon -->
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
<circle cx="9" cy="9" r="3.5" stroke="currentColor" stroke-width="1.5"/>
|
<circle cx="9" cy="9" r="3.5" stroke="currentColor" stroke-width="1.5" />
|
||||||
<path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.22 3.22l1.41 1.41M13.36 13.36l1.42 1.42M3.22 14.78l1.41-1.41M13.36 4.64l1.42-1.42" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M9 1v2M9 15v2M1 9h2M15 9h2M3.22 3.22l1.41 1.41M13.36 13.36l1.42 1.42M3.22 14.78l1.41-1.41M13.36 4.64l1.42-1.42"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Switch to dark
|
Switch to dark
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Moon icon -->
|
<!-- Moon icon -->
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
<path d="M15 11.5A7 7 0 0 1 6.5 3a7.001 7.001 0 1 0 8.5 8.5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M15 11.5A7 7 0 0 1 6.5 3a7.001 7.001 0 1 0 8.5 8.5z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Switch to light
|
Switch to light
|
||||||
{/if}
|
{/if}
|
||||||
@@ -225,7 +236,10 @@
|
|||||||
<!-- ====== PWA ====== -->
|
<!-- ====== PWA ====== -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="section-title">App cache</h2>
|
<h2 class="section-title">App cache</h2>
|
||||||
<p class="hint-text">Clear service worker and cached assets, then reload. Useful if the app feels stale after an update.</p>
|
<p class="hint-text">
|
||||||
|
Clear service worker and cached assets, then reload. Useful if the app feels stale after an
|
||||||
|
update.
|
||||||
|
</p>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="btn danger-outline" onclick={resetPwa} disabled={pwaResetting}>
|
<button class="btn danger-outline" onclick={resetPwa} disabled={pwaResetting}>
|
||||||
{pwaResetting ? 'Clearing…' : 'Clear PWA cache'}
|
{pwaResetting ? 'Clearing…' : 'Clear PWA cache'}
|
||||||
@@ -259,7 +273,10 @@
|
|||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<span class="toggle-label">Apply new tag rules to existing files</span>
|
<span class="toggle-label">Apply new tag rules to existing files</span>
|
||||||
<p class="hint-text">When a tag rule is created or activated, automatically add the implied tag to all files that already have the source tag.</p>
|
<p class="hint-text">
|
||||||
|
When a tag rule is created or activated, automatically add the implied tag to all files
|
||||||
|
that already have the source tag.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="toggle"
|
class="toggle"
|
||||||
@@ -267,7 +284,8 @@
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={$appSettings.tagRuleApplyToExisting}
|
aria-checked={$appSettings.tagRuleApplyToExisting}
|
||||||
aria-label="Apply activated tag rules to existing files"
|
aria-label="Apply activated tag rules to existing files"
|
||||||
onclick={() => appSettings.update((s) => ({ ...s, tagRuleApplyToExisting: !s.tagRuleApplyToExisting }))}
|
onclick={() =>
|
||||||
|
appSettings.update((s) => ({ ...s, tagRuleApplyToExisting: !s.tagRuleApplyToExisting }))}
|
||||||
>
|
>
|
||||||
<span class="thumb"></span>
|
<span class="thumb"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -429,9 +447,15 @@
|
|||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg.error { color: var(--color-danger); }
|
.msg.error {
|
||||||
.msg.success { color: #7ECBA1; }
|
color: var(--color-danger);
|
||||||
.msg.muted { color: var(--color-text-muted); }
|
}
|
||||||
|
.msg.success {
|
||||||
|
color: #7ecba1;
|
||||||
|
}
|
||||||
|
.msg.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Appearance toggle ---- */
|
/* ---- Appearance toggle ---- */
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{ value: 'name', label: 'Name' },
|
{ value: 'name', label: 'Name' },
|
||||||
{ value: 'created', label: 'Created' },
|
{ value: 'created', label: 'Created' },
|
||||||
{ value: 'color', label: 'Color' },
|
{ value: 'color', label: 'Color' },
|
||||||
{ value: 'category_name', label: 'Category' },
|
{ value: 'category_name', label: 'Category' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let tags = $state<Tag[]>([]);
|
let tags = $state<Tag[]>([]);
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
limit: String(LIMIT),
|
limit: String(LIMIT),
|
||||||
offset: String(offset),
|
offset: String(offset),
|
||||||
sort: sortState.sort,
|
sort: sortState.sort,
|
||||||
order: sortState.order,
|
order: sortState.order
|
||||||
});
|
});
|
||||||
if (search.trim()) params.set('search', search.trim());
|
if (search.trim()) params.set('search', search.trim());
|
||||||
const page = await api.get<TagOffsetPage>(`/tags?${params}`);
|
const page = await api.get<TagOffsetPage>(`/tags?${params}`);
|
||||||
@@ -90,7 +90,8 @@
|
|||||||
<select
|
<select
|
||||||
class="sort-select"
|
class="sort-select"
|
||||||
value={sortState.sort}
|
value={sortState.sort}
|
||||||
onchange={(e) => tagSorting.setSort((e.currentTarget as HTMLSelectElement).value as TagSortField)}
|
onchange={(e) =>
|
||||||
|
tagSorting.setSort((e.currentTarget as HTMLSelectElement).value as TagSortField)}
|
||||||
>
|
>
|
||||||
{#each SORT_OPTIONS as opt}
|
{#each SORT_OPTIONS as opt}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
@@ -104,11 +105,23 @@
|
|||||||
>
|
>
|
||||||
{#if sortState.order === 'asc'}
|
{#if sortState.order === 'asc'}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M3 9L7 5L11 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
<path
|
||||||
|
d="M3 5L7 9L11 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -130,7 +143,12 @@
|
|||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
<path
|
||||||
|
d="M2 2l10 10M12 2L2 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -33,21 +33,23 @@
|
|||||||
void Promise.all([
|
void Promise.all([
|
||||||
api.get<Tag>(`/tags/${id}`),
|
api.get<Tag>(`/tags/${id}`),
|
||||||
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc'),
|
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc'),
|
||||||
api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[]),
|
api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[])
|
||||||
]).then(([t, cats, r]) => {
|
])
|
||||||
tag = t;
|
.then(([t, cats, r]) => {
|
||||||
categories = cats.items ?? [];
|
tag = t;
|
||||||
rules = r;
|
categories = cats.items ?? [];
|
||||||
|
rules = r;
|
||||||
|
|
||||||
name = t.name ?? '';
|
name = t.name ?? '';
|
||||||
notes = t.notes ?? '';
|
notes = t.notes ?? '';
|
||||||
color = t.color ? `#${t.color}` : '#444455';
|
color = t.color ? `#${t.color}` : '#444455';
|
||||||
categoryId = t.category_id ?? '';
|
categoryId = t.category_id ?? '';
|
||||||
isPublic = t.is_public ?? false;
|
isPublic = t.is_public ?? false;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}).catch((e) => {
|
})
|
||||||
loadError = e instanceof ApiError ? e.message : 'Failed to load tag';
|
.catch((e) => {
|
||||||
});
|
loadError = e instanceof ApiError ? e.message : 'Failed to load tag';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
color: color.slice(1),
|
color: color.slice(1),
|
||||||
category_id: categoryId || null,
|
category_id: categoryId || null,
|
||||||
is_public: isPublic,
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
goto('/tags');
|
goto('/tags');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -91,7 +93,13 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/tags')} aria-label="Back">
|
<button class="back-btn" onclick={() => goto('/tags')} aria-label="Back">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="page-title">{tag?.name ?? 'Tag'}</h1>
|
<h1 class="page-title">{tag?.name ?? 'Tag'}</h1>
|
||||||
@@ -109,7 +117,13 @@
|
|||||||
<p class="error" role="alert">{saveError}</p>
|
<p class="error" role="alert">{saveError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
<form
|
||||||
|
class="form"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="row-fields">
|
<div class="row-fields">
|
||||||
<div class="field" style="flex: 1">
|
<div class="field" style="flex: 1">
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
@@ -131,7 +145,13 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="notes">Notes</label>
|
<label class="label" for="notes">Notes</label>
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="3"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -163,7 +183,12 @@
|
|||||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-btn"
|
||||||
|
onclick={() => (confirmDelete = true)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +199,6 @@
|
|||||||
<h2 class="section-title">Implied tags</h2>
|
<h2 class="section-title">Implied tags</h2>
|
||||||
<TagRuleEditor tagId={tagId ?? ''} {rules} onRulesChange={(r) => (rules = r)} />
|
<TagRuleEditor tagId={tagId ?? ''} {rules} onRulesChange={(r) => (rules = r)} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,69 +214,134 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: sticky; top: 0; z-index: 10;
|
position: sticky;
|
||||||
display: flex; align-items: center; gap: 8px;
|
top: 0;
|
||||||
padding: 6px 10px; min-height: 44px;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 44px;
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
align-items: center;
|
||||||
border: none; background: none;
|
justify-content: center;
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); display: flex; flex-direction: column; gap: 24px; }
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 14px calc(60px + 16px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-row { display: flex; justify-content: center; padding: 40px; }
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
display: block; width: 28px; height: 28px;
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
border-top-color: var(--color-accent);
|
border-top-color: var(--color-accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
|
|
||||||
.color-field { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.75rem; font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%;
|
||||||
height: 36px; padding: 0 10px;
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.color-input {
|
.color-input {
|
||||||
width: 50px; height: 36px; padding: 2px;
|
width: 50px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
@@ -260,69 +349,131 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit;
|
font-size: 0.875rem;
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
select.input { cursor: pointer; color-scheme: dark; }
|
select.input {
|
||||||
|
cursor: pointer;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex; align-items: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.toggle-row .label { margin: 0; }
|
.toggle-row .label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative; width: 44px; height: 26px;
|
position: relative;
|
||||||
border-radius: 13px; border: none;
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: none;
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute; top: 3px; left: 3px;
|
position: absolute;
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
top: 3px;
|
||||||
background-color: #fff; transition: transform 0.2s;
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.action-row { display: flex; gap: 8px; }
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
flex: 1; height: 42px; border-radius: 8px; border: none;
|
flex: 1;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-bg-primary);
|
color: var(--color-bg-primary);
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
height: 42px; padding: 0 18px; border-radius: 8px;
|
height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 8px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
||||||
background: none; color: var(--color-danger);
|
background: none;
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
color: var(--color-danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.delete-btn:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
.delete-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
|
||||||
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.section { display: flex; flex-direction: column; gap: 10px; }
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 0.75rem; font-weight: 600;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
color: color.slice(1), // strip #
|
color: color.slice(1), // strip #
|
||||||
category_id: categoryId || null,
|
category_id: categoryId || null,
|
||||||
is_public: isPublic,
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
goto('/tags');
|
goto('/tags');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -47,7 +47,13 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/tags')} aria-label="Back">
|
<button class="back-btn" onclick={() => goto('/tags')} aria-label="Back">
|
||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="page-title">New Tag</h1>
|
<h1 class="page-title">New Tag</h1>
|
||||||
@@ -58,7 +64,13 @@
|
|||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
<form
|
||||||
|
class="form"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="row-fields">
|
<div class="row-fields">
|
||||||
<div class="field" style="flex: 1">
|
<div class="field" style="flex: 1">
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
@@ -80,7 +92,13 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="notes">Notes</label>
|
<label class="label" for="notes">Notes</label>
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="3"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -116,58 +134,110 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: sticky; top: 0; z-index: 10;
|
position: sticky;
|
||||||
display: flex; align-items: center; gap: 8px;
|
top: 0;
|
||||||
padding: 6px 10px; min-height: 44px;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 44px;
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
align-items: center;
|
||||||
border: none; background: none;
|
justify-content: center;
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); }
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 14px calc(60px + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
.row-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.color-field { flex-shrink: 0; }
|
.color-field {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 0.75rem; font-weight: 600;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
.required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%;
|
||||||
height: 36px; padding: 0 10px;
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.color-input {
|
.color-input {
|
||||||
width: 50px; height: 36px; padding: 2px;
|
width: 50px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
@@ -175,47 +245,88 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem; font-family: inherit;
|
font-size: 0.875rem;
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
select.input { cursor: pointer; color-scheme: dark; }
|
select.input {
|
||||||
|
cursor: pointer;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex; align-items: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.toggle-row .label { margin: 0; }
|
.toggle-row .label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative; width: 44px; height: 26px;
|
position: relative;
|
||||||
border-radius: 13px; border: none;
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: none;
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
.thumb {
|
||||||
position: absolute; top: 3px; left: 3px;
|
position: absolute;
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
top: 3px;
|
||||||
background-color: #fff; transition: transform 0.2s;
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width: 100%; height: 42px; border-radius: 8px; border: none;
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-bg-primary);
|
color: var(--color-bg-primary);
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; }
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -16,9 +16,7 @@ const SHELL = ['/', ...build, ...files];
|
|||||||
|
|
||||||
// ---- Install: pre-cache the app shell ----
|
// ---- Install: pre-cache the app shell ----
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
|
||||||
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
|
|
||||||
);
|
|
||||||
// Activate immediately without waiting for old tabs to close.
|
// Activate immediately without waiting for old tabs to close.
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
@@ -26,9 +24,9 @@ self.addEventListener('install', (event) => {
|
|||||||
// ---- Activate: remove stale caches from previous versions ----
|
// ---- Activate: remove stale caches from previous versions ----
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((keys) =>
|
caches
|
||||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
.keys()
|
||||||
)
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
|
|||||||
+506
-112
@@ -23,7 +23,7 @@ function json(res: ServerResponse, status: number, body: unknown) {
|
|||||||
const payload = JSON.stringify(body);
|
const payload = JSON.stringify(body);
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(payload),
|
'Content-Length': Buffer.byteLength(payload)
|
||||||
});
|
});
|
||||||
res.end(payload);
|
res.end(payload);
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ const MOCK_REFRESH_TOKEN = 'mock-refresh-token';
|
|||||||
const TOKEN_PAIR = {
|
const TOKEN_PAIR = {
|
||||||
access_token: MOCK_ACCESS_TOKEN,
|
access_token: MOCK_ACCESS_TOKEN,
|
||||||
refresh_token: MOCK_REFRESH_TOKEN,
|
refresh_token: MOCK_REFRESH_TOKEN,
|
||||||
expires_in: 900,
|
expires_in: 900
|
||||||
};
|
};
|
||||||
|
|
||||||
const ME = {
|
const ME = {
|
||||||
@@ -47,7 +47,7 @@ const ME = {
|
|||||||
name: 'admin',
|
name: 'admin',
|
||||||
is_admin: true,
|
is_admin: true,
|
||||||
can_create: true,
|
can_create: true,
|
||||||
is_blocked: false,
|
is_blocked: false
|
||||||
};
|
};
|
||||||
|
|
||||||
type MockUser = {
|
type MockUser = {
|
||||||
@@ -59,17 +59,27 @@ type MockUser = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockUsersArr: MockUser[] = [
|
const mockUsersArr: MockUser[] = [
|
||||||
{ id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false },
|
{ id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false },
|
||||||
{ id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false },
|
{ id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false },
|
||||||
{ id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false },
|
{ id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false },
|
||||||
{ id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true },
|
{ id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true },
|
||||||
{ id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false },
|
{ id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
const AUDIT_ACTIONS = [
|
const AUDIT_ACTIONS = [
|
||||||
'file_create', 'file_edit', 'file_delete', 'file_tag_add', 'file_tag_remove',
|
'file_create',
|
||||||
'tag_create', 'tag_edit', 'tag_delete', 'pool_create', 'pool_edit', 'pool_delete',
|
'file_edit',
|
||||||
'category_create', 'category_edit',
|
'file_delete',
|
||||||
|
'file_tag_add',
|
||||||
|
'file_tag_remove',
|
||||||
|
'tag_create',
|
||||||
|
'tag_edit',
|
||||||
|
'tag_delete',
|
||||||
|
'pool_create',
|
||||||
|
'pool_edit',
|
||||||
|
'pool_delete',
|
||||||
|
'category_create',
|
||||||
|
'category_edit'
|
||||||
];
|
];
|
||||||
const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category'];
|
const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category'];
|
||||||
|
|
||||||
@@ -96,13 +106,21 @@ const mockAuditLog: MockAuditEntry[] = Array.from({ length: 80 }, (_, i) => {
|
|||||||
object_type: objType,
|
object_type: objType,
|
||||||
object_id: `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`,
|
object_id: `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`,
|
||||||
details: null,
|
details: null,
|
||||||
performed_at: new Date(Date.now() - i * 1_800_000).toISOString(),
|
performed_at: new Date(Date.now() - i * 1_800_000).toISOString()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const THUMB_COLORS = [
|
const THUMB_COLORS = [
|
||||||
'#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1',
|
'#9592B5',
|
||||||
'#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E',
|
'#4DC7ED',
|
||||||
|
'#DB6060',
|
||||||
|
'#F5E872',
|
||||||
|
'#7ECBA1',
|
||||||
|
'#E08C5A',
|
||||||
|
'#A67CB8',
|
||||||
|
'#5A9ED4',
|
||||||
|
'#C4A44A',
|
||||||
|
'#6DB89E'
|
||||||
];
|
];
|
||||||
|
|
||||||
function mockThumbSvg(id: string): string {
|
function mockThumbSvg(id: string): string {
|
||||||
@@ -135,7 +153,7 @@ type MockFile = {
|
|||||||
// 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'];
|
||||||
const exts = ['jpg', 'png', 'webp' ];
|
const exts = ['jpg', 'png', 'webp'];
|
||||||
const mi = i % mimes.length;
|
const mi = i % mimes.length;
|
||||||
const id = `00000000-0000-7000-8000-trash${String(i + 1).padStart(7, '0')}`;
|
const id = `00000000-0000-7000-8000-trash${String(i + 1).padStart(7, '0')}`;
|
||||||
return {
|
return {
|
||||||
@@ -153,13 +171,13 @@ const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_deleted: true,
|
is_deleted: true,
|
||||||
created_at: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
|
created_at: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
|
||||||
position: 0,
|
position: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, i) => {
|
const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, 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;
|
||||||
const id = `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`;
|
const id = `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`;
|
||||||
return {
|
return {
|
||||||
@@ -176,67 +194,343 @@ const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, i) => {
|
|||||||
creator_name: 'admin',
|
creator_name: 'admin',
|
||||||
is_public: false,
|
is_public: false,
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
created_at: new Date(Date.now() - i * 3_600_000).toISOString(),
|
created_at: new Date(Date.now() - i * 3_600_000).toISOString()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const TAG_NAMES = [
|
const TAG_NAMES = [
|
||||||
'nature', 'portrait', 'travel', 'architecture', 'food', 'street', 'macro',
|
'nature',
|
||||||
'landscape', 'wildlife', 'urban', 'abstract', 'black-and-white', 'night',
|
'portrait',
|
||||||
'golden-hour', 'blue-hour', 'aerial', 'underwater', 'infrared', 'long-exposure',
|
'travel',
|
||||||
'panorama', 'astrophotography', 'documentary', 'editorial', 'fashion', 'wedding',
|
'architecture',
|
||||||
'newborn', 'maternity', 'family', 'pet', 'sport', 'concert', 'theatre',
|
'food',
|
||||||
'interior', 'exterior', 'product', 'still-life', 'automotive', 'aviation',
|
'street',
|
||||||
'marine', 'industrial', 'medical', 'scientific', 'satellite', 'drone',
|
'macro',
|
||||||
'film', 'analog', 'polaroid', 'tilt-shift', 'fisheye', 'telephoto',
|
'landscape',
|
||||||
'wide-angle', 'bokeh', 'silhouette', 'reflection', 'shadow', 'texture',
|
'wildlife',
|
||||||
'pattern', 'color', 'minimal', 'surreal', 'conceptual', 'fine-art',
|
'urban',
|
||||||
'photojournalism', 'war', 'protest', 'people', 'crowd', 'solitude',
|
'abstract',
|
||||||
'children', 'elderly', 'culture', 'tradition', 'festival', 'religion',
|
'black-and-white',
|
||||||
'asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert',
|
'night',
|
||||||
'forest', 'mountain', 'ocean', 'lake', 'river', 'waterfall', 'cave',
|
'golden-hour',
|
||||||
'volcano', 'canyon', 'glacier', 'field', 'garden', 'park', 'city',
|
'blue-hour',
|
||||||
'village', 'ruins', 'bridge', 'road', 'railway', 'harbor', 'airport',
|
'aerial',
|
||||||
'market', 'cafe', 'restaurant', 'bar', 'museum', 'library', 'school',
|
'underwater',
|
||||||
'hospital', 'church', 'mosque', 'temple', 'shrine', 'cemetery', 'stadium',
|
'infrared',
|
||||||
'spring', 'summer', 'autumn', 'winter', 'rain', 'snow', 'fog', 'storm',
|
'long-exposure',
|
||||||
'sunrise', 'sunset', 'cloudy', 'clear', 'rainbow', 'lightning', 'wind',
|
'panorama',
|
||||||
'cat', 'dog', 'bird', 'horse', 'fish', 'insect', 'reptile', 'mammal',
|
'astrophotography',
|
||||||
'flower', 'tree', 'grass', 'moss', 'mushroom', 'fruit', 'vegetable',
|
'documentary',
|
||||||
'fire', 'water', 'earth', 'air', 'smoke', 'ice', 'stone', 'wood', 'metal',
|
'editorial',
|
||||||
'glass', 'fabric', 'paper', 'plastic', 'ceramic', 'leather', 'concrete',
|
'fashion',
|
||||||
'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink',
|
'wedding',
|
||||||
'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted',
|
'newborn',
|
||||||
'raw', 'edited', 'hdr', 'composite', 'retouched', 'unedited', 'scanned',
|
'maternity',
|
||||||
'selfie', 'candid', 'posed', 'staged', 'spontaneous', 'planned', 'series',
|
'family',
|
||||||
|
'pet',
|
||||||
|
'sport',
|
||||||
|
'concert',
|
||||||
|
'theatre',
|
||||||
|
'interior',
|
||||||
|
'exterior',
|
||||||
|
'product',
|
||||||
|
'still-life',
|
||||||
|
'automotive',
|
||||||
|
'aviation',
|
||||||
|
'marine',
|
||||||
|
'industrial',
|
||||||
|
'medical',
|
||||||
|
'scientific',
|
||||||
|
'satellite',
|
||||||
|
'drone',
|
||||||
|
'film',
|
||||||
|
'analog',
|
||||||
|
'polaroid',
|
||||||
|
'tilt-shift',
|
||||||
|
'fisheye',
|
||||||
|
'telephoto',
|
||||||
|
'wide-angle',
|
||||||
|
'bokeh',
|
||||||
|
'silhouette',
|
||||||
|
'reflection',
|
||||||
|
'shadow',
|
||||||
|
'texture',
|
||||||
|
'pattern',
|
||||||
|
'color',
|
||||||
|
'minimal',
|
||||||
|
'surreal',
|
||||||
|
'conceptual',
|
||||||
|
'fine-art',
|
||||||
|
'photojournalism',
|
||||||
|
'war',
|
||||||
|
'protest',
|
||||||
|
'people',
|
||||||
|
'crowd',
|
||||||
|
'solitude',
|
||||||
|
'children',
|
||||||
|
'elderly',
|
||||||
|
'culture',
|
||||||
|
'tradition',
|
||||||
|
'festival',
|
||||||
|
'religion',
|
||||||
|
'asia',
|
||||||
|
'europe',
|
||||||
|
'africa',
|
||||||
|
'americas',
|
||||||
|
'oceania',
|
||||||
|
'arctic',
|
||||||
|
'desert',
|
||||||
|
'forest',
|
||||||
|
'mountain',
|
||||||
|
'ocean',
|
||||||
|
'lake',
|
||||||
|
'river',
|
||||||
|
'waterfall',
|
||||||
|
'cave',
|
||||||
|
'volcano',
|
||||||
|
'canyon',
|
||||||
|
'glacier',
|
||||||
|
'field',
|
||||||
|
'garden',
|
||||||
|
'park',
|
||||||
|
'city',
|
||||||
|
'village',
|
||||||
|
'ruins',
|
||||||
|
'bridge',
|
||||||
|
'road',
|
||||||
|
'railway',
|
||||||
|
'harbor',
|
||||||
|
'airport',
|
||||||
|
'market',
|
||||||
|
'cafe',
|
||||||
|
'restaurant',
|
||||||
|
'bar',
|
||||||
|
'museum',
|
||||||
|
'library',
|
||||||
|
'school',
|
||||||
|
'hospital',
|
||||||
|
'church',
|
||||||
|
'mosque',
|
||||||
|
'temple',
|
||||||
|
'shrine',
|
||||||
|
'cemetery',
|
||||||
|
'stadium',
|
||||||
|
'spring',
|
||||||
|
'summer',
|
||||||
|
'autumn',
|
||||||
|
'winter',
|
||||||
|
'rain',
|
||||||
|
'snow',
|
||||||
|
'fog',
|
||||||
|
'storm',
|
||||||
|
'sunrise',
|
||||||
|
'sunset',
|
||||||
|
'cloudy',
|
||||||
|
'clear',
|
||||||
|
'rainbow',
|
||||||
|
'lightning',
|
||||||
|
'wind',
|
||||||
|
'cat',
|
||||||
|
'dog',
|
||||||
|
'bird',
|
||||||
|
'horse',
|
||||||
|
'fish',
|
||||||
|
'insect',
|
||||||
|
'reptile',
|
||||||
|
'mammal',
|
||||||
|
'flower',
|
||||||
|
'tree',
|
||||||
|
'grass',
|
||||||
|
'moss',
|
||||||
|
'mushroom',
|
||||||
|
'fruit',
|
||||||
|
'vegetable',
|
||||||
|
'fire',
|
||||||
|
'water',
|
||||||
|
'earth',
|
||||||
|
'air',
|
||||||
|
'smoke',
|
||||||
|
'ice',
|
||||||
|
'stone',
|
||||||
|
'wood',
|
||||||
|
'metal',
|
||||||
|
'glass',
|
||||||
|
'fabric',
|
||||||
|
'paper',
|
||||||
|
'plastic',
|
||||||
|
'ceramic',
|
||||||
|
'leather',
|
||||||
|
'concrete',
|
||||||
|
'red',
|
||||||
|
'orange',
|
||||||
|
'yellow',
|
||||||
|
'green',
|
||||||
|
'cyan',
|
||||||
|
'blue',
|
||||||
|
'purple',
|
||||||
|
'pink',
|
||||||
|
'brown',
|
||||||
|
'white',
|
||||||
|
'grey',
|
||||||
|
'dark',
|
||||||
|
'bright',
|
||||||
|
'pastel',
|
||||||
|
'vivid',
|
||||||
|
'muted',
|
||||||
|
'raw',
|
||||||
|
'edited',
|
||||||
|
'hdr',
|
||||||
|
'composite',
|
||||||
|
'retouched',
|
||||||
|
'unedited',
|
||||||
|
'scanned',
|
||||||
|
'selfie',
|
||||||
|
'candid',
|
||||||
|
'posed',
|
||||||
|
'staged',
|
||||||
|
'spontaneous',
|
||||||
|
'planned',
|
||||||
|
'series'
|
||||||
];
|
];
|
||||||
|
|
||||||
const TAG_COLORS = [
|
const TAG_COLORS = [
|
||||||
'7ECBA1', '9592B5', '4DC7ED', 'E08C5A', 'DB6060',
|
'7ECBA1',
|
||||||
'F5E872', 'A67CB8', '5A9ED4', 'C4A44A', '6DB89E',
|
'9592B5',
|
||||||
'E07090', '70B0E0', 'C0A060', '80C080', 'D080B0',
|
'4DC7ED',
|
||||||
|
'E08C5A',
|
||||||
|
'DB6060',
|
||||||
|
'F5E872',
|
||||||
|
'A67CB8',
|
||||||
|
'5A9ED4',
|
||||||
|
'C4A44A',
|
||||||
|
'6DB89E',
|
||||||
|
'E07090',
|
||||||
|
'70B0E0',
|
||||||
|
'C0A060',
|
||||||
|
'80C080',
|
||||||
|
'D080B0'
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_CATEGORIES = [
|
const MOCK_CATEGORIES = [
|
||||||
{ id: '00000000-0000-7000-8002-000000000001', name: 'Style', color: '9592B5', notes: null, created_at: new Date().toISOString() },
|
{
|
||||||
{ id: '00000000-0000-7000-8002-000000000002', name: 'Subject', color: '4DC7ED', notes: null, created_at: new Date().toISOString() },
|
id: '00000000-0000-7000-8002-000000000001',
|
||||||
{ id: '00000000-0000-7000-8002-000000000003', name: 'Location', color: '7ECBA1', notes: null, created_at: new Date().toISOString() },
|
name: 'Style',
|
||||||
{ id: '00000000-0000-7000-8002-000000000004', name: 'Season', color: 'E08C5A', notes: null, created_at: new Date().toISOString() },
|
color: '9592B5',
|
||||||
{ id: '00000000-0000-7000-8002-000000000005', name: 'Color', color: 'DB6060', notes: null, created_at: new Date().toISOString() },
|
notes: null,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-7000-8002-000000000002',
|
||||||
|
name: 'Subject',
|
||||||
|
color: '4DC7ED',
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-7000-8002-000000000003',
|
||||||
|
name: 'Location',
|
||||||
|
color: '7ECBA1',
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-7000-8002-000000000004',
|
||||||
|
name: 'Season',
|
||||||
|
color: 'E08C5A',
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-7000-8002-000000000005',
|
||||||
|
name: 'Color',
|
||||||
|
color: 'DB6060',
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Assign some tags to categories
|
// Assign some tags to categories
|
||||||
const CATEGORY_ASSIGNMENTS: Record<string, string> = {};
|
const CATEGORY_ASSIGNMENTS: Record<string, string> = {};
|
||||||
TAG_NAMES.forEach((name, i) => {
|
TAG_NAMES.forEach((name, i) => {
|
||||||
if (['film', 'analog', 'polaroid', 'bokeh', 'silhouette', 'long-exposure', 'tilt-shift', 'fisheye', 'telephoto', 'wide-angle', 'macro', 'infrared', 'hdr', 'composite'].includes(name))
|
if (
|
||||||
|
[
|
||||||
|
'film',
|
||||||
|
'analog',
|
||||||
|
'polaroid',
|
||||||
|
'bokeh',
|
||||||
|
'silhouette',
|
||||||
|
'long-exposure',
|
||||||
|
'tilt-shift',
|
||||||
|
'fisheye',
|
||||||
|
'telephoto',
|
||||||
|
'wide-angle',
|
||||||
|
'macro',
|
||||||
|
'infrared',
|
||||||
|
'hdr',
|
||||||
|
'composite'
|
||||||
|
].includes(name)
|
||||||
|
)
|
||||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[0].id; // Style
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[0].id; // Style
|
||||||
else if (['portrait', 'wildlife', 'people', 'children', 'elderly', 'cat', 'dog', 'bird', 'horse', 'flower', 'tree', 'insect', 'reptile', 'mammal'].includes(name))
|
else if (
|
||||||
|
[
|
||||||
|
'portrait',
|
||||||
|
'wildlife',
|
||||||
|
'people',
|
||||||
|
'children',
|
||||||
|
'elderly',
|
||||||
|
'cat',
|
||||||
|
'dog',
|
||||||
|
'bird',
|
||||||
|
'horse',
|
||||||
|
'flower',
|
||||||
|
'tree',
|
||||||
|
'insect',
|
||||||
|
'reptile',
|
||||||
|
'mammal'
|
||||||
|
].includes(name)
|
||||||
|
)
|
||||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[1].id; // Subject
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[1].id; // Subject
|
||||||
else if (['asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert', 'forest', 'mountain', 'ocean', 'lake', 'river', 'city', 'village'].includes(name))
|
else if (
|
||||||
|
[
|
||||||
|
'asia',
|
||||||
|
'europe',
|
||||||
|
'africa',
|
||||||
|
'americas',
|
||||||
|
'oceania',
|
||||||
|
'arctic',
|
||||||
|
'desert',
|
||||||
|
'forest',
|
||||||
|
'mountain',
|
||||||
|
'ocean',
|
||||||
|
'lake',
|
||||||
|
'river',
|
||||||
|
'city',
|
||||||
|
'village'
|
||||||
|
].includes(name)
|
||||||
|
)
|
||||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[2].id; // Location
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[2].id; // Location
|
||||||
else if (['spring', 'summer', 'autumn', 'winter'].includes(name))
|
else if (['spring', 'summer', 'autumn', 'winter'].includes(name))
|
||||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[3].id; // Season
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[3].id; // Season
|
||||||
else if (['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink', 'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted'].includes(name))
|
else if (
|
||||||
|
[
|
||||||
|
'red',
|
||||||
|
'orange',
|
||||||
|
'yellow',
|
||||||
|
'green',
|
||||||
|
'cyan',
|
||||||
|
'blue',
|
||||||
|
'purple',
|
||||||
|
'pink',
|
||||||
|
'brown',
|
||||||
|
'white',
|
||||||
|
'grey',
|
||||||
|
'dark',
|
||||||
|
'bright',
|
||||||
|
'pastel',
|
||||||
|
'vivid',
|
||||||
|
'muted'
|
||||||
|
].includes(name)
|
||||||
|
)
|
||||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,7 +563,7 @@ const mockTagsArr: MockTag[] = TAG_NAMES.map((name, i) => {
|
|||||||
category_name: cat?.name ?? null,
|
category_name: cat?.name ?? null,
|
||||||
category_color: cat?.color ?? null,
|
category_color: cat?.color ?? null,
|
||||||
is_public: false,
|
is_public: false,
|
||||||
created_at: new Date(Date.now() - i * 3_600_000).toISOString(),
|
created_at: new Date(Date.now() - i * 3_600_000).toISOString()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +574,7 @@ const MOCK_TAGS = mockTagsArr;
|
|||||||
const tagRules = new Map<string, Map<string, boolean>>();
|
const tagRules = new Map<string, Map<string, boolean>>();
|
||||||
|
|
||||||
// Mutable in-memory state for file metadata and tags
|
// Mutable in-memory state for file metadata and tags
|
||||||
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
|
const fileOverrides = new Map<string, Partial<(typeof MOCK_FILES)[0]>>();
|
||||||
const fileTags = new Map<string, Set<string>>(); // fileId → Set<tagId>
|
const fileTags = new Map<string, Set<string>>(); // fileId → Set<tagId>
|
||||||
|
|
||||||
type MockPool = {
|
type MockPool = {
|
||||||
@@ -313,9 +607,36 @@ type PoolFile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockPoolsArr: MockPool[] = [
|
const mockPoolsArr: MockPool[] = [
|
||||||
{ id: '00000000-0000-7000-8003-000000000001', name: 'Best of 2024', notes: 'Top picks from last year', is_public: false, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 10 * 86400000).toISOString() },
|
{
|
||||||
{ id: '00000000-0000-7000-8003-000000000002', name: 'Portfolio', notes: null, is_public: true, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 5 * 86400000).toISOString() },
|
id: '00000000-0000-7000-8003-000000000001',
|
||||||
{ id: '00000000-0000-7000-8003-000000000003', name: 'Work in Progress', notes: 'Drafts and experiments', is_public: false, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 2 * 86400000).toISOString() },
|
name: 'Best of 2024',
|
||||||
|
notes: 'Top picks from last year',
|
||||||
|
is_public: false,
|
||||||
|
file_count: 0,
|
||||||
|
creator_id: 1,
|
||||||
|
creator_name: 'admin',
|
||||||
|
created_at: new Date(Date.now() - 10 * 86400000).toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-7000-8003-000000000002',
|
||||||
|
name: 'Portfolio',
|
||||||
|
notes: null,
|
||||||
|
is_public: true,
|
||||||
|
file_count: 0,
|
||||||
|
creator_id: 1,
|
||||||
|
creator_name: 'admin',
|
||||||
|
created_at: new Date(Date.now() - 5 * 86400000).toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-7000-8003-000000000003',
|
||||||
|
name: 'Work in Progress',
|
||||||
|
notes: 'Drafts and experiments',
|
||||||
|
is_public: false,
|
||||||
|
file_count: 0,
|
||||||
|
creator_id: 1,
|
||||||
|
creator_name: 'admin',
|
||||||
|
created_at: new Date(Date.now() - 2 * 86400000).toISOString()
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Pool files: Map<poolId, PoolFile[]> ordered by position
|
// Pool files: Map<poolId, PoolFile[]> ordered by position
|
||||||
@@ -323,8 +644,20 @@ const poolFilesMap = new Map<string, PoolFile[]>();
|
|||||||
|
|
||||||
// Seed some files into first two pools
|
// Seed some files into first two pools
|
||||||
function seedPoolFiles() {
|
function seedPoolFiles() {
|
||||||
const p1Files: PoolFile[] = MOCK_FILES.slice(0, 8).map((f, i) => ({ ...f, metadata: null, exif: {}, phash: null, position: i + 1 }));
|
const p1Files: PoolFile[] = MOCK_FILES.slice(0, 8).map((f, i) => ({
|
||||||
const p2Files: PoolFile[] = MOCK_FILES.slice(5, 14).map((f, i) => ({ ...f, metadata: null, exif: {}, phash: null, position: i + 1 }));
|
...f,
|
||||||
|
metadata: null,
|
||||||
|
exif: {},
|
||||||
|
phash: null,
|
||||||
|
position: i + 1
|
||||||
|
}));
|
||||||
|
const p2Files: PoolFile[] = MOCK_FILES.slice(5, 14).map((f, i) => ({
|
||||||
|
...f,
|
||||||
|
metadata: null,
|
||||||
|
exif: {},
|
||||||
|
phash: null,
|
||||||
|
position: i + 1
|
||||||
|
}));
|
||||||
poolFilesMap.set(mockPoolsArr[0].id, p1Files);
|
poolFilesMap.set(mockPoolsArr[0].id, p1Files);
|
||||||
poolFilesMap.set(mockPoolsArr[1].id, p2Files);
|
poolFilesMap.set(mockPoolsArr[1].id, p2Files);
|
||||||
mockPoolsArr[0].file_count = p1Files.length;
|
mockPoolsArr[0].file_count = p1Files.length;
|
||||||
@@ -381,10 +714,10 @@ export function mockApiPlugin(): Plugin {
|
|||||||
started_at: new Date().toISOString(),
|
started_at: new Date().toISOString(),
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
last_activity: new Date().toISOString(),
|
last_activity: new Date().toISOString(),
|
||||||
is_current: true,
|
is_current: true
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
total: 1,
|
total: 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +743,10 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/);
|
const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/);
|
||||||
if (method === 'GET' && thumbMatch) {
|
if (method === 'GET' && thumbMatch) {
|
||||||
const svg = mockThumbSvg(thumbMatch[1]);
|
const svg = mockThumbSvg(thumbMatch[1]);
|
||||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) });
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Content-Length': Buffer.byteLength(svg)
|
||||||
|
});
|
||||||
return res.end(svg);
|
return res.end(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +760,10 @@ export function mockApiPlugin(): Plugin {
|
|||||||
<rect width="800" height="600" fill="${color}"/>
|
<rect width="800" height="600" fill="${color}"/>
|
||||||
<text x="400" y="315" text-anchor="middle" font-family="monospace" font-size="48" fill="rgba(0,0,0,0.35)">${label}</text>
|
<text x="400" y="315" text-anchor="middle" font-family="monospace" font-size="48" fill="rgba(0,0,0,0.35)">${label}</text>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) });
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Content-Length': Buffer.byteLength(svg)
|
||||||
|
});
|
||||||
return res.end(svg);
|
return res.end(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +771,11 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/);
|
const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/);
|
||||||
if (method === 'GET' && fileTagsGetMatch) {
|
if (method === 'GET' && fileTagsGetMatch) {
|
||||||
const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set<string>();
|
const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set<string>();
|
||||||
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
|
return json(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
MOCK_TAGS.filter((t) => ids.has(t.id))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /files/{id}/tags/{tag_id} — add tag
|
// PUT /files/{id}/tags/{tag_id} — add tag
|
||||||
@@ -442,7 +785,11 @@ export function mockApiPlugin(): Plugin {
|
|||||||
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
|
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
|
||||||
fileTags.get(fid)!.add(tid);
|
fileTags.get(fid)!.add(tid);
|
||||||
const ids = fileTags.get(fid)!;
|
const ids = fileTags.get(fid)!;
|
||||||
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
|
return json(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
MOCK_TAGS.filter((t) => ids.has(t.id))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /files/{id}/tags/{tag_id} — remove tag
|
// DELETE /files/{id}/tags/{tag_id} — remove tag
|
||||||
@@ -491,7 +838,11 @@ export function mockApiPlugin(): Plugin {
|
|||||||
|
|
||||||
// POST /files/bulk/tags
|
// POST /files/bulk/tags
|
||||||
if (method === 'POST' && path === '/files/bulk/tags') {
|
if (method === 'POST' && path === '/files/bulk/tags') {
|
||||||
const body = (await readBody(req)) as { file_ids?: string[]; action?: string; tag_ids?: string[] };
|
const body = (await readBody(req)) as {
|
||||||
|
file_ids?: string[];
|
||||||
|
action?: string;
|
||||||
|
tag_ids?: string[];
|
||||||
|
};
|
||||||
const fileIds = body.file_ids ?? [];
|
const fileIds = body.file_ids ?? [];
|
||||||
const tagIds = body.tag_ids ?? [];
|
const tagIds = body.tag_ids ?? [];
|
||||||
const action = body.action ?? 'add';
|
const action = body.action ?? 'add';
|
||||||
@@ -569,7 +920,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
creator_name: 'admin',
|
creator_name: 'admin',
|
||||||
is_public: false,
|
is_public: false,
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
MOCK_FILES.unshift(newFile);
|
MOCK_FILES.unshift(newFile);
|
||||||
return json(res, 201, newFile);
|
return json(res, 201, newFile);
|
||||||
@@ -585,8 +936,10 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
||||||
const slice = MOCK_TRASH.slice(offset, offset + limit);
|
const slice = MOCK_TRASH.slice(offset, offset + limit);
|
||||||
const nextOffset = offset + slice.length;
|
const nextOffset = offset + slice.length;
|
||||||
const next_cursor = nextOffset < MOCK_TRASH.length
|
const next_cursor =
|
||||||
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
nextOffset < MOCK_TRASH.length
|
||||||
|
? Buffer.from(String(nextOffset)).toString('base64')
|
||||||
|
: null;
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
||||||
}
|
}
|
||||||
const anchor = qs.get('anchor');
|
const anchor = qs.get('anchor');
|
||||||
@@ -596,13 +949,15 @@ export function mockApiPlugin(): Plugin {
|
|||||||
if (anchor) {
|
if (anchor) {
|
||||||
// Anchor mode: return the anchor file surrounded by neighbors
|
// Anchor mode: return the anchor file surrounded by neighbors
|
||||||
const anchorIdx = MOCK_FILES.findIndex((f) => f.id === anchor);
|
const anchorIdx = MOCK_FILES.findIndex((f) => f.id === anchor);
|
||||||
if (anchorIdx < 0) return json(res, 404, { code: 'not_found', message: 'Anchor not found' });
|
if (anchorIdx < 0)
|
||||||
|
return json(res, 404, { code: 'not_found', message: 'Anchor not found' });
|
||||||
const from = Math.max(0, anchorIdx - Math.floor(limit / 2));
|
const from = Math.max(0, anchorIdx - Math.floor(limit / 2));
|
||||||
const slice = MOCK_FILES.slice(from, from + limit);
|
const slice = MOCK_FILES.slice(from, from + limit);
|
||||||
const next_cursor = from + slice.length < MOCK_FILES.length
|
const next_cursor =
|
||||||
? Buffer.from(String(from + slice.length)).toString('base64') : null;
|
from + slice.length < MOCK_FILES.length
|
||||||
const prev_cursor = from > 0
|
? Buffer.from(String(from + slice.length)).toString('base64')
|
||||||
? Buffer.from(String(from)).toString('base64') : null;
|
: null;
|
||||||
|
const prev_cursor = from > 0 ? Buffer.from(String(from)).toString('base64') : null;
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,18 +967,18 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const end = Number(Buffer.from(cursor, 'base64').toString());
|
const end = Number(Buffer.from(cursor, 'base64').toString());
|
||||||
const start = Math.max(0, end - limit);
|
const start = Math.max(0, end - limit);
|
||||||
const slice = MOCK_FILES.slice(start, end);
|
const slice = MOCK_FILES.slice(start, end);
|
||||||
const prev_cursor = start > 0
|
const prev_cursor = start > 0 ? Buffer.from(String(start)).toString('base64') : null;
|
||||||
? Buffer.from(String(start)).toString('base64') : null;
|
|
||||||
const next_cursor = Buffer.from(String(end)).toString('base64');
|
const next_cursor = Buffer.from(String(end)).toString('base64');
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
}
|
}
|
||||||
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
||||||
const slice = MOCK_FILES.slice(offset, offset + limit);
|
const slice = MOCK_FILES.slice(offset, offset + limit);
|
||||||
const nextOffset = offset + slice.length;
|
const nextOffset = offset + slice.length;
|
||||||
const next_cursor = nextOffset < MOCK_FILES.length
|
const next_cursor =
|
||||||
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
nextOffset < MOCK_FILES.length
|
||||||
const prev_cursor = offset > 0
|
? Buffer.from(String(nextOffset)).toString('base64')
|
||||||
? Buffer.from(String(offset)).toString('base64') : null;
|
: null;
|
||||||
|
const prev_cursor = offset > 0 ? Buffer.from(String(offset)).toString('base64') : null;
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +989,12 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const ruleMap = tagRules.get(tid) ?? new Map<string, boolean>();
|
const ruleMap = tagRules.get(tid) ?? new Map<string, boolean>();
|
||||||
const items = [...ruleMap.entries()].map(([thenId, isActive]) => {
|
const items = [...ruleMap.entries()].map(([thenId, isActive]) => {
|
||||||
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||||
return { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive };
|
return {
|
||||||
|
tag_id: tid,
|
||||||
|
then_tag_id: thenId,
|
||||||
|
then_tag_name: t?.name ?? null,
|
||||||
|
is_active: isActive
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return json(res, 200, items);
|
return json(res, 200, items);
|
||||||
}
|
}
|
||||||
@@ -649,7 +1009,12 @@ export function mockApiPlugin(): Plugin {
|
|||||||
if (!tagRules.has(tid)) tagRules.set(tid, new Map());
|
if (!tagRules.has(tid)) tagRules.set(tid, new Map());
|
||||||
tagRules.get(tid)!.set(thenId, isActive);
|
tagRules.get(tid)!.set(thenId, isActive);
|
||||||
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||||
return json(res, 201, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive });
|
return json(res, 201, {
|
||||||
|
tag_id: tid,
|
||||||
|
then_tag_id: thenId,
|
||||||
|
then_tag_name: t?.name ?? null,
|
||||||
|
is_active: isActive
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /tags/{id}/rules/{then_id} — activate / deactivate
|
// PATCH /tags/{id}/rules/{then_id} — activate / deactivate
|
||||||
@@ -659,10 +1024,16 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const body = (await readBody(req)) as Record<string, unknown>;
|
const body = (await readBody(req)) as Record<string, unknown>;
|
||||||
const isActive = body.is_active as boolean;
|
const isActive = body.is_active as boolean;
|
||||||
const ruleMap = tagRules.get(tid);
|
const ruleMap = tagRules.get(tid);
|
||||||
if (!ruleMap?.has(thenId)) return json(res, 404, { code: 'not_found', message: 'Rule not found' });
|
if (!ruleMap?.has(thenId))
|
||||||
|
return json(res, 404, { code: 'not_found', message: 'Rule not found' });
|
||||||
ruleMap.set(thenId, isActive);
|
ruleMap.set(thenId, isActive);
|
||||||
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||||
return json(res, 200, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive });
|
return json(res, 200, {
|
||||||
|
tag_id: tid,
|
||||||
|
then_tag_id: thenId,
|
||||||
|
then_tag_name: t?.name ?? null,
|
||||||
|
is_active: isActive
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /tags/{id}/rules/{then_id}
|
// DELETE /tags/{id}/rules/{then_id}
|
||||||
@@ -692,7 +1063,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
Object.assign(MOCK_TAGS[idx], {
|
Object.assign(MOCK_TAGS[idx], {
|
||||||
...body,
|
...body,
|
||||||
category_name: cat?.name ?? null,
|
category_name: cat?.name ?? null,
|
||||||
category_color: cat?.color ?? null,
|
category_color: cat?.color ?? null
|
||||||
});
|
});
|
||||||
return json(res, 200, MOCK_TAGS[idx]);
|
return json(res, 200, MOCK_TAGS[idx]);
|
||||||
}
|
}
|
||||||
@@ -720,10 +1091,19 @@ export function mockApiPlugin(): Plugin {
|
|||||||
|
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
let av: string, bv: string;
|
let av: string, bv: string;
|
||||||
if (sort === 'color') { av = a.color; bv = b.color; }
|
if (sort === 'color') {
|
||||||
else if (sort === 'category_name') { av = a.category_name ?? ''; bv = b.category_name ?? ''; }
|
av = a.color;
|
||||||
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
|
bv = b.color;
|
||||||
else { av = a.name; bv = b.name; }
|
} else if (sort === 'category_name') {
|
||||||
|
av = a.category_name ?? '';
|
||||||
|
bv = b.category_name ?? '';
|
||||||
|
} else if (sort === 'created') {
|
||||||
|
av = a.created_at;
|
||||||
|
bv = b.created_at;
|
||||||
|
} else {
|
||||||
|
av = a.name;
|
||||||
|
bv = b.name;
|
||||||
|
}
|
||||||
const cmp = av.localeCompare(bv);
|
const cmp = av.localeCompare(bv);
|
||||||
return order === 'desc' ? -cmp : cmp;
|
return order === 'desc' ? -cmp : cmp;
|
||||||
});
|
});
|
||||||
@@ -746,7 +1126,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
category_name: cat?.name ?? null,
|
category_name: cat?.name ?? null,
|
||||||
category_color: cat?.color ?? null,
|
category_color: cat?.color ?? null,
|
||||||
is_public: body.is_public ?? false,
|
is_public: body.is_public ?? false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
MOCK_TAGS.unshift(newTag);
|
MOCK_TAGS.unshift(newTag);
|
||||||
return json(res, 201, newTag);
|
return json(res, 201, newTag);
|
||||||
@@ -778,7 +1158,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
if (method === 'PATCH' && catPatchMatch) {
|
if (method === 'PATCH' && catPatchMatch) {
|
||||||
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catPatchMatch[1]);
|
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catPatchMatch[1]);
|
||||||
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Category not found' });
|
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Category not found' });
|
||||||
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
|
const body = (await readBody(req)) as Partial<(typeof MOCK_CATEGORIES)[0]>;
|
||||||
Object.assign(MOCK_CATEGORIES[idx], body);
|
Object.assign(MOCK_CATEGORIES[idx], body);
|
||||||
// Sync category_name/color on affected tags
|
// Sync category_name/color on affected tags
|
||||||
const cat = MOCK_CATEGORIES[idx];
|
const cat = MOCK_CATEGORIES[idx];
|
||||||
@@ -824,9 +1204,16 @@ export function mockApiPlugin(): Plugin {
|
|||||||
|
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
let av: string, bv: string;
|
let av: string, bv: string;
|
||||||
if (sort === 'color') { av = a.color; bv = b.color; }
|
if (sort === 'color') {
|
||||||
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
|
av = a.color;
|
||||||
else { av = a.name; bv = b.name; }
|
bv = b.color;
|
||||||
|
} else if (sort === 'created') {
|
||||||
|
av = a.created_at;
|
||||||
|
bv = b.created_at;
|
||||||
|
} else {
|
||||||
|
av = a.name;
|
||||||
|
bv = b.name;
|
||||||
|
}
|
||||||
const cmp = av.localeCompare(bv);
|
const cmp = av.localeCompare(bv);
|
||||||
return order === 'desc' ? -cmp : cmp;
|
return order === 'desc' ? -cmp : cmp;
|
||||||
});
|
});
|
||||||
@@ -837,13 +1224,13 @@ export function mockApiPlugin(): Plugin {
|
|||||||
|
|
||||||
// POST /categories
|
// POST /categories
|
||||||
if (method === 'POST' && path === '/categories') {
|
if (method === 'POST' && path === '/categories') {
|
||||||
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
|
const body = (await readBody(req)) as Partial<(typeof MOCK_CATEGORIES)[0]>;
|
||||||
const newCat = {
|
const newCat = {
|
||||||
id: `00000000-0000-7000-8002-${String(Date.now()).slice(-12)}`,
|
id: `00000000-0000-7000-8002-${String(Date.now()).slice(-12)}`,
|
||||||
name: body.name ?? 'Unnamed',
|
name: body.name ?? 'Unnamed',
|
||||||
color: body.color ?? '9592B5',
|
color: body.color ?? '9592B5',
|
||||||
notes: body.notes ?? null,
|
notes: body.notes ?? null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
MOCK_CATEGORIES.unshift(newCat);
|
MOCK_CATEGORIES.unshift(newCat);
|
||||||
return json(res, 201, newCat);
|
return json(res, 201, newCat);
|
||||||
@@ -862,8 +1249,8 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
||||||
const slice = files.slice(offset, offset + limit);
|
const slice = files.slice(offset, offset + limit);
|
||||||
const nextOffset = offset + slice.length;
|
const nextOffset = offset + slice.length;
|
||||||
const next_cursor = nextOffset < files.length
|
const next_cursor =
|
||||||
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
nextOffset < files.length ? Buffer.from(String(nextOffset)).toString('base64') : null;
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,7 +1265,9 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const files = poolFilesMap.get(pid) ?? [];
|
const files = poolFilesMap.get(pid) ?? [];
|
||||||
const updated = files.filter((f) => !toRemove.has(f.id));
|
const updated = files.filter((f) => !toRemove.has(f.id));
|
||||||
// Reassign positions
|
// Reassign positions
|
||||||
updated.forEach((f, i) => { f.position = i + 1; });
|
updated.forEach((f, i) => {
|
||||||
|
f.position = i + 1;
|
||||||
|
});
|
||||||
poolFilesMap.set(pid, updated);
|
poolFilesMap.set(pid, updated);
|
||||||
pool.file_count = updated.length;
|
pool.file_count = updated.length;
|
||||||
return noContent(res);
|
return noContent(res);
|
||||||
@@ -899,7 +1288,9 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const f = byId.get(id);
|
const f = byId.get(id);
|
||||||
if (f) reordered.push(f);
|
if (f) reordered.push(f);
|
||||||
}
|
}
|
||||||
reordered.forEach((f, i) => { f.position = i + 1; });
|
reordered.forEach((f, i) => {
|
||||||
|
f.position = i + 1;
|
||||||
|
});
|
||||||
poolFilesMap.set(pid, reordered);
|
poolFilesMap.set(pid, reordered);
|
||||||
return noContent(res);
|
return noContent(res);
|
||||||
}
|
}
|
||||||
@@ -914,7 +1305,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
const files = poolFilesMap.get(pid) ?? [];
|
const files = poolFilesMap.get(pid) ?? [];
|
||||||
const existing = new Set(files.map((f) => f.id));
|
const existing = new Set(files.map((f) => f.id));
|
||||||
let pos = files.length;
|
let pos = files.length;
|
||||||
for (const fid of (body.file_ids ?? [])) {
|
for (const fid of body.file_ids ?? []) {
|
||||||
if (existing.has(fid)) continue;
|
if (existing.has(fid)) continue;
|
||||||
const base = MOCK_FILES.find((f) => f.id === fid);
|
const base = MOCK_FILES.find((f) => f.id === fid);
|
||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
@@ -991,7 +1382,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
file_count: 0,
|
file_count: 0,
|
||||||
creator_id: 1,
|
creator_id: 1,
|
||||||
creator_name: 'admin',
|
creator_name: 'admin',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
mockPoolsArr.unshift(newPool);
|
mockPoolsArr.unshift(newPool);
|
||||||
return json(res, 201, newPool);
|
return json(res, 201, newPool);
|
||||||
@@ -1040,7 +1431,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
name: body.name ?? 'unnamed',
|
name: body.name ?? 'unnamed',
|
||||||
is_admin: body.is_admin ?? false,
|
is_admin: body.is_admin ?? false,
|
||||||
can_create: body.can_create ?? false,
|
can_create: body.can_create ?? false,
|
||||||
is_blocked: false,
|
is_blocked: false
|
||||||
};
|
};
|
||||||
mockUsersArr.push(newUser);
|
mockUsersArr.push(newUser);
|
||||||
return json(res, 201, newUser);
|
return json(res, 201, newUser);
|
||||||
@@ -1074,8 +1465,11 @@ export function mockApiPlugin(): Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: 404
|
// Fallback: 404
|
||||||
return json(res, 404, { code: 'not_found', message: `Mock: no handler for ${method} ${path}` });
|
return json(res, 404, {
|
||||||
|
code: 'not_found',
|
||||||
|
message: `Mock: no handler for ${method} ${path}`
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user