feat: per-pool automatic file sorting
deploy / deploy (push) Successful in 2s

Pools now store a sort setting (sort_key + sort_order). "manual" keeps the
user-arranged order in file_pool.position and allows drag-to-reorder; any
other key (content_datetime, created, original_name) sorts the pool's files
automatically server-side, in which case reordering is rejected. Manual
order is always ascending by position — direction does not apply.

Backend: add the columns (sort_key defaults to 'manual'), generalise the
pool-files keyset cursor to page by the active sort, persist the setting on
create/update, and guard reorder against non-manual pools.

Frontend: a sort dropdown (+ direction toggle) on the pool page that PATCHes
the pool and reloads; drag-to-reorder is disabled unless the pool is manual.

Closes the "automatic sorting in pools" requirement (REQUIREMENTS.md §4.6.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 08:36:12 +03:00
parent 28f5b5d150
commit 4b6db84afc
9 changed files with 445 additions and 33 deletions
+100 -20
View File
@@ -30,6 +30,8 @@ type poolRow struct {
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
SortKey string `db:"sort_key"`
SortOrder string `db:"sort_order"`
FileCount int `db:"file_count"`
}
@@ -68,6 +70,8 @@ func toPool(r poolRow) domain.Pool {
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
SortKey: r.SortKey,
SortOrder: r.SortOrder,
FileCount: r.FileCount,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
@@ -103,9 +107,15 @@ func toPoolFile(r poolFileRow) domain.PoolFile {
// Cursor
// ---------------------------------------------------------------------------
// poolFileCursor is the keyset paging cursor for pool files. Which fields are
// populated depends on the pool's sort key: Pos for manual (position), Val for
// content_datetime (RFC3339Nano) / original_name (the coalesced name); the
// "created" sort orders by file id alone, so only FileID matters. FileID is
// always the final tiebreak.
type poolFileCursor struct {
Position int `json:"p"`
FileID string `json:"id"`
Pos int `json:"p,omitempty"`
Val string `json:"v,omitempty"`
FileID string `json:"id"`
}
func encodePoolCursor(c poolFileCursor) string {
@@ -135,6 +145,7 @@ const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool
const poolSelectFrom = `
SELECT p.id, p.name, p.notes, p.metadata,
p.creator_id, u.name AS creator_name, p.is_public,
p.sort_key, p.sort_order,
COALESCE(fc.cnt, 0) AS file_count
FROM data.pools p
JOIN core.users u ON u.id = p.creator_id
@@ -147,6 +158,29 @@ func poolSortColumn(s string) string {
return "p.id" // "created"
}
// poolFileSort maps a pool's stored sort settings to the SQL column expression,
// the ORDER BY direction, and the keyset comparison operator used for paging.
// The column is chosen so it is never NULL (original_name is coalesced), which
// keeps the keyset comparison total.
func poolFileSort(sortKey, sortOrder string) (col, dir, cmp string) {
dir, cmp = "ASC", ">"
if strings.EqualFold(sortOrder, domain.SortOrderDesc) {
dir, cmp = "DESC", "<"
}
switch sortKey {
case domain.PoolSortContentDatetime:
col = "f.content_datetime"
case domain.PoolSortOriginalName:
col = "COALESCE(f.original_name, '')"
case domain.PoolSortCreated:
col = "f.id"
default: // manual — the user-arranged sequence; direction does not apply
col = "fp.position"
dir, cmp = "ASC", ">"
}
return col, dir, cmp
}
// ---------------------------------------------------------------------------
// PoolRepo
// ---------------------------------------------------------------------------
@@ -207,6 +241,7 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
query := fmt.Sprintf(`
SELECT p.id, p.name, p.notes, p.metadata,
p.creator_id, u.name AS creator_name, p.is_public,
p.sort_key, p.sort_order,
COALESCE(fc.cnt, 0) AS file_count,
COUNT(*) OVER() AS total
FROM data.pools p
@@ -289,6 +324,7 @@ WITH ins AS (
)
SELECT ins.id, ins.name, ins.notes, ins.metadata,
ins.creator_id, u.name AS creator_name, ins.is_public,
ins.sort_key, ins.sort_order,
0 AS file_count
FROM ins
JOIN core.users u ON u.id = ins.creator_id`
@@ -322,15 +358,18 @@ func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*d
const query = `
WITH upd AS (
UPDATE data.pools SET
name = $2,
notes = $3,
metadata = COALESCE($4, metadata),
is_public = $5
name = $2,
notes = $3,
metadata = COALESCE($4, metadata),
is_public = $5,
sort_key = $6,
sort_order = $7
WHERE id = $1
RETURNING *
)
SELECT upd.id, upd.name, upd.notes, upd.metadata,
upd.creator_id, u.name AS creator_name, upd.is_public,
upd.sort_key, upd.sort_order,
COALESCE(fc.cnt, 0) AS file_count
FROM upd
JOIN core.users u ON u.id = upd.creator_id
@@ -343,7 +382,7 @@ LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic, p.SortKey, p.SortOrder)
if err != nil {
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
}
@@ -412,8 +451,16 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
}
}
// Cursor condition.
var orderBy string
// Resolve the pool's sort setting (defaulting to manual position order) into
// the ORDER BY column, direction, and keyset comparison operator.
sortKey := params.SortKey
if !domain.ValidPoolSortKey(sortKey) {
sortKey = domain.PoolSortManual
}
col, dir, cmp := poolFileSort(sortKey, params.SortOrder)
// Keyset cursor condition. For "created" the file id is both the sort key and
// the tiebreak, so a single comparison suffices; the others compare (col, id).
if params.Cursor != "" {
cur, err := decodePoolCursor(params.Cursor)
if err != nil {
@@ -423,13 +470,36 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
if err != nil {
return nil, domain.ErrValidation
}
conds = append(conds, fmt.Sprintf(
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
n, n, n+1))
args = append(args, cur.Position, fileID)
n += 2
if sortKey == domain.PoolSortCreated {
conds = append(conds, fmt.Sprintf("f.id %s $%d", cmp, n))
args = append(args, fileID)
n++
} else {
conds = append(conds, fmt.Sprintf(
"(%s %s $%d OR (%s = $%d AND f.id %s $%d))", col, cmp, n, col, n, cmp, n+1))
switch sortKey {
case domain.PoolSortContentDatetime:
t, err := time.Parse(time.RFC3339Nano, cur.Val)
if err != nil {
return nil, domain.ErrValidation
}
args = append(args, t)
case domain.PoolSortOriginalName:
args = append(args, cur.Val)
default: // manual
args = append(args, cur.Pos)
}
args = append(args, fileID)
n += 2
}
}
var orderBy string
if sortKey == domain.PoolSortCreated {
orderBy = fmt.Sprintf("f.id %s", dir)
} else {
orderBy = fmt.Sprintf("%s %s, f.id %s", col, dir, dir)
}
orderBy = "fp.position ASC, fp.file_id ASC"
where := "WHERE " + strings.Join(conds, " AND ")
args = append(args, limit+1)
@@ -468,11 +538,21 @@ LIMIT $%d`, fileSelectForPool, where, orderBy, n)
if hasMore && len(collected) > 0 {
last := collected[len(collected)-1]
cur := encodePoolCursor(poolFileCursor{
Position: last.Position,
FileID: last.ID.String(),
})
page.NextCursor = &cur
cursor := poolFileCursor{FileID: last.ID.String()}
switch sortKey {
case domain.PoolSortContentDatetime:
cursor.Val = last.ContentDatetime.UTC().Format(time.RFC3339Nano)
case domain.PoolSortOriginalName:
if last.OriginalName != nil {
cursor.Val = *last.OriginalName
}
case domain.PoolSortCreated:
// file id alone orders; nothing else to carry
default: // manual
cursor.Pos = last.Position
}
enc := encodePoolCursor(cursor)
page.NextCursor = &enc
}
// Batch-load tags.
+31
View File
@@ -7,6 +7,32 @@ import (
"github.com/google/uuid"
)
// Pool file sort keys. PoolSortManual keeps the user-defined order in
// file_pool.position; the others sort the pool's files by that file field.
const (
PoolSortManual = "manual"
PoolSortContentDatetime = "content_datetime"
PoolSortCreated = "created"
PoolSortOriginalName = "original_name"
SortOrderAsc = "asc"
SortOrderDesc = "desc"
)
// ValidPoolSortKey reports whether s is an accepted pool sort key.
func ValidPoolSortKey(s string) bool {
switch s {
case PoolSortManual, PoolSortContentDatetime, PoolSortCreated, PoolSortOriginalName:
return true
}
return false
}
// ValidSortOrder reports whether s is an accepted sort direction.
func ValidSortOrder(s string) bool {
return s == SortOrderAsc || s == SortOrderDesc
}
// Pool is an ordered collection of files.
type Pool struct {
ID uuid.UUID
@@ -16,6 +42,11 @@ type Pool struct {
CreatorID int16
CreatorName string // denormalized
IsPublic bool
// SortKey / SortOrder control how the pool's files are ordered. When SortKey
// is PoolSortManual, files follow the manual position order and can be
// reordered; otherwise they are sorted automatically and reordering is a no-op.
SortKey string
SortOrder string
FileCount int
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
}
+14
View File
@@ -34,6 +34,8 @@ type poolJSON struct {
CreatorID int16 `json:"creator_id"`
CreatorName string `json:"creator_name"`
IsPublic bool `json:"is_public"`
SortKey string `json:"sort_key"`
SortOrder string `json:"sort_order"`
FileCount int `json:"file_count"`
CreatedAt string `json:"created_at"`
}
@@ -51,6 +53,8 @@ func toPoolJSON(p domain.Pool) poolJSON {
CreatorID: p.CreatorID,
CreatorName: p.CreatorName,
IsPublic: p.IsPublic,
SortKey: p.SortKey,
SortOrder: p.SortOrder,
FileCount: p.FileCount,
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
}
@@ -214,6 +218,16 @@ func (h *PoolHandler) Update(c *gin.Context) {
params.IsPublic = &b
}
}
if v, ok := raw["sort_key"]; ok {
if s, ok := v.(string); ok {
params.SortKey = &s
}
}
if v, ok := raw["sort_order"]; ok {
if s, ok := v.(string); ok {
params.SortOrder = &s
}
}
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
if err != nil {
+113
View File
@@ -26,6 +26,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
@@ -1745,3 +1746,115 @@ func TestDuplicateDetection(t *testing.T) {
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
}
// TestPoolAutomaticSort exercises per-pool file ordering: the default manual
// order, switching to automatic sorts (by name and by creation), that an
// auto-sorted pool rejects manual reordering, and that keyset paging honours the
// active sort.
func TestPoolAutomaticSort(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
admin := h.login("admin", "admin")
// Upload three files with distinct names in a non-alphabetical order.
idB := h.uploadJPEG(admin, "b.jpg")["id"].(string)
idA := h.uploadJPEG(admin, "a.jpg")["id"].(string)
idC := h.uploadJPEG(admin, "c.jpg")["id"].(string)
// "created" order is UUID (byte) order — lexicographic on the canonical string.
createdAsc := []string{idA, idB, idC}
sort.Strings(createdAsc)
createdDesc := []string{createdAsc[2], createdAsc[1], createdAsc[0]}
// New pool defaults to manual ordering.
resp := h.doJSON("POST", "/pools", map[string]any{"name": "sortpool"}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
require.Equal(t, "manual", pool["sort_key"])
// Add in upload order (b, a, c) → that becomes the manual position order.
resp = h.doJSON("POST", "/pools/"+poolID+"/files",
map[string]any{"file_ids": []string{idB, idA, idC}}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
listIDs := func() []string {
resp := h.doJSON("GET", "/pools/"+poolID+"/files", nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
resp.decode(t, &page)
ids := make([]string, len(page.Items))
for i, it := range page.Items {
ids[i] = it.ID
}
return ids
}
setSort := func(key, order string) {
body := map[string]any{"sort_key": key}
if order != "" {
body["sort_order"] = order
}
resp := h.doJSON("PATCH", "/pools/"+poolID, body, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// Manual = insertion order.
require.Equal(t, []string{idB, idA, idC}, listIDs())
// By name ascending → a, b, c.
setSort("original_name", "asc")
require.Equal(t, []string{idA, idB, idC}, listIDs())
// By creation descending → reverse UUID order.
setSort("created", "desc")
require.Equal(t, createdDesc, listIDs())
// Reordering an auto-sorted pool is rejected.
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.String())
// Back to manual → reordering works again and sticks.
setSort("manual", "")
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
require.Equal(t, []string{idC, idB, idA}, listIDs())
// Keyset paging (limit 1) under an automatic sort returns the full sequence.
setSort("original_name", "asc")
var paged []string
cursor := ""
for i := 0; i < 10; i++ {
url := "/pools/" + poolID + "/files?limit=1"
if cursor != "" {
url += "&cursor=" + cursor
}
resp := h.doJSON("GET", url, nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
NextCursor *string `json:"next_cursor"`
}
resp.decode(t, &page)
for _, it := range page.Items {
paged = append(paged, it.ID)
}
if page.NextCursor == nil {
break
}
cursor = *page.NextCursor
}
require.Equal(t, []string{idA, idB, idC}, paged)
}
+4
View File
@@ -36,6 +36,10 @@ type PoolFileListParams struct {
Cursor string
Limit int
Filter string // filter DSL expression
// SortKey / SortOrder come from the pool itself (not the request) and select
// how files are ordered: manual position order or an automatic file-field sort.
SortKey string
SortOrder string
}
// FileRepo is the persistence interface for file records.
+50 -10
View File
@@ -13,12 +13,15 @@ import (
const poolObjectType = "pool"
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. SortKey and
// SortOrder are pointers so a patch can leave them unchanged (nil) vs set them.
type PoolParams struct {
Name string
Notes *string
Metadata json.RawMessage
IsPublic *bool
Name string
Notes *string
Metadata json.RawMessage
IsPublic *bool
SortKey *string
SortOrder *string
}
// PoolService handles pool CRUD and poolfile management with ACL + audit.
@@ -164,6 +167,18 @@ func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
if p.SortKey != nil {
if !domain.ValidPoolSortKey(*p.SortKey) {
return nil, domain.ErrValidation
}
patch.SortKey = *p.SortKey
}
if p.SortOrder != nil {
if !domain.ValidSortOrder(*p.SortOrder) {
return nil, domain.ErrValidation
}
patch.SortOrder = *p.SortOrder
}
updated, err := s.pools.Update(ctx, id, &patch)
if err != nil {
@@ -205,12 +220,24 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
// Poolfile operations
// ---------------------------------------------------------------------------
// ListFiles returns cursor-paginated files within a pool ordered by position,
// enforcing view ACL on the pool.
// ListFiles returns cursor-paginated files within a pool, enforcing view ACL.
// The ordering is the pool's own stored sort setting (manual position order or
// an automatic file-field sort), not a request parameter.
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
if err := s.authorizeView(ctx, poolID); err != nil {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pool, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, pool.CreatorID, pool.IsPublic, poolObjectTypeID, poolID)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
params.SortKey = pool.SortKey
params.SortOrder = pool.SortOrder
return s.pools.ListFiles(ctx, poolID, params)
}
@@ -242,11 +269,24 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs
}
// Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
// ACL on the pool.
// ACL on the pool. Manual ordering only applies when the pool's sort key is
// "manual"; reordering an auto-sorted pool is rejected as a validation error.
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pool, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, poolID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if pool.SortKey != domain.PoolSortManual {
return domain.ErrValidation
}
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
return err
}
+10 -1
View File
@@ -76,8 +76,17 @@ CREATE TABLE data.pools (
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
-- File ordering within the pool. 'manual' keeps the user-defined order in
-- data.file_pool.position (drag-to-reorder); any other key sorts the pool's
-- files automatically by that file field, in which case reordering is disabled.
sort_key varchar(32) NOT NULL DEFAULT 'manual',
sort_order varchar(4) NOT NULL DEFAULT 'asc',
CONSTRAINT uni__pools__name UNIQUE (name)
CONSTRAINT uni__pools__name UNIQUE (name),
CONSTRAINT chk__pools__sort_key
CHECK (sort_key IN ('manual', 'content_datetime', 'created', 'original_name')),
CONSTRAINT chk__pools__sort_order
CHECK (sort_order IN ('asc', 'desc'))
);
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
+107 -2
View File
@@ -61,8 +61,15 @@
let addSelected = $state(new Set<string>());
let addSearchPrev = $state('');
// ---- Drag-to-reorder (disabled when filter active) ----
let canReorder = $derived(!filterParam);
// ---- Sorting ----
// The pool stores its own file sort. "manual" keeps the drag order; any other
// key sorts automatically (server-side) and disables reordering.
let sortKey = $derived(pool?.sort_key ?? 'manual');
let sortOrder = $derived(pool?.sort_order ?? 'asc');
let sortChanging = $state(false);
// ---- Drag-to-reorder (only in manual order, and not while filtering) ----
let canReorder = $derived(!filterParam && sortKey === 'manual');
let dragSrcIdx = $state<number | null>(null);
let dragOverIdx = $state<number | null>(null);
let reorderPending = $state(false);
@@ -131,6 +138,27 @@
}
}
// ---- Change the pool's file sort ----
// Persist the new sort on the pool, then reload the files from the top so the
// new server-side order takes effect. A no-op if the setting hasn't changed.
async function changeSort(key: string, order: string) {
if (!pool || sortChanging) return;
if (key === sortKey && order === sortOrder) return;
sortChanging = true;
try {
pool = await api.patch<Pool>(`/pools/${poolId}`, { sort_key: key, sort_order: order });
files = [];
nextCursor = null;
hasMore = true;
filesError = '';
await loadMore();
} catch (e) {
filesError = e instanceof ApiError ? e.message : 'Failed to change sort';
} finally {
sortChanging = false;
}
}
// ---- Save pool ----
async function save() {
if (!name.trim() || saving) return;
@@ -591,6 +619,32 @@
{#if pool?.file_count != null}<span class="count">({pool.file_count})</span>{/if}
</span>
<div class="files-header-actions">
<div class="sort-control">
<select
class="sort-select"
value={sortKey}
disabled={sortChanging}
onchange={(e) => changeSort((e.currentTarget as HTMLSelectElement).value, sortOrder)}
title="Sort files"
aria-label="Sort files"
>
<option value="manual">Manual order</option>
<option value="content_datetime">Date</option>
<option value="created">Date added</option>
<option value="original_name">Name</option>
</select>
{#if sortKey !== 'manual'}
<button
class="order-btn"
disabled={sortChanging}
onclick={() => changeSort(sortKey, sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Ascending' : 'Descending'}
aria-label="Toggle sort direction"
>
{sortOrder === 'asc' ? '↑' : '↓'}
</button>
{/if}
</div>
{#if canReorder && files.length > 1}
<span class="reorder-hint" title="Drag thumbnails to reorder">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
@@ -1043,6 +1097,57 @@
border-color: var(--color-accent);
}
.sort-control {
display: flex;
align-items: center;
gap: 4px;
}
.sort-select {
height: 26px;
padding: 0 6px;
border-radius: 5px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
outline: none;
}
.sort-select:hover,
.sort-select:focus {
color: var(--color-text-primary);
border-color: var(--color-accent);
}
.sort-select:disabled {
opacity: 0.5;
cursor: default;
}
.order-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 5px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background: none;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.order-btn:hover {
color: var(--color-accent);
border-color: var(--color-accent);
}
.order-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ---- File grid ---- */
main {
flex: 1;
+16
View File
@@ -2281,6 +2281,16 @@ components:
type: string
is_public:
type: boolean
sort_key:
type: string
enum: [manual, content_datetime, created, original_name]
description: >-
How the pool's files are ordered. "manual" keeps the user-defined
order (drag-to-reorder); any other key sorts the files automatically
by that file field and disables manual reordering.
sort_order:
type: string
enum: [asc, desc]
file_count:
type: integer
created_at:
@@ -2312,6 +2322,12 @@ components:
type: object
is_public:
type: boolean
sort_key:
type: string
enum: [manual, content_datetime, created, original_name]
sort_order:
type: string
enum: [asc, desc]
PoolOffsetPage:
type: object