Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38572b1c80 | |||
| fedfa8df3a | |||
| cb1588ecc0 | |||
| 73ae8a046f | |||
| 6a3bb9ff51 |
@@ -811,3 +811,33 @@ func (r *FileRepo) RecordView(ctx context.Context, fileID uuid.UUID, userID int1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordTagUses appends a row to activity.tag_uses for each tag referenced in a
|
||||
// filter DSL, flagging it included (positive) or excluded (negated). Tags are
|
||||
// deduplicated per call, so one statement_timestamp() never collides on the
|
||||
// (tag_id, used_at, user_id) PK; ON CONFLICT DO NOTHING guards the rest. A
|
||||
// filter with no tag terms is a no-op.
|
||||
func (r *FileRepo) RecordTagUses(ctx context.Context, userID int16, filterDSL string) error {
|
||||
uses := filterTagUses(filterDSL)
|
||||
if len(uses) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("INSERT INTO activity.tag_uses (tag_id, user_id, is_included) VALUES ")
|
||||
args := make([]any, 0, len(uses)*3)
|
||||
for i, u := range uses {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
base := i * 3
|
||||
fmt.Fprintf(&sb, "($%d, $%d, $%d)", base+1, base+2, base+3)
|
||||
args = append(args, u.tagID, userID, u.included)
|
||||
}
|
||||
sb.WriteString(" ON CONFLICT DO NOTHING")
|
||||
|
||||
if _, err := connOrTx(ctx, r.pool).Exec(ctx, sb.String(), args...); err != nil {
|
||||
return fmt.Errorf("FileRepo.RecordTagUses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -253,6 +253,31 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseFilterAST lexes and parses a filter DSL into an AST. Returns (nil, nil)
|
||||
// for an empty or trivial DSL.
|
||||
func parseFilterAST(dsl string) (filterNode, error) {
|
||||
dsl = strings.TrimSpace(dsl)
|
||||
if dsl == "" || dsl == "{}" {
|
||||
return nil, nil
|
||||
}
|
||||
toks, err := lexFilter(dsl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(toks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
p := &filterParser{tokens: toks}
|
||||
node, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.pos != len(p.tokens) {
|
||||
return nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ParseFilter parses a filter DSL string into a parameterized SQL fragment.
|
||||
//
|
||||
// argStart is the 1-based index for the first $N placeholder; this lets the
|
||||
@@ -262,25 +287,62 @@ func (p *filterParser) parseAtom() (filterNode, error) {
|
||||
// SQL injection is structurally impossible: every user-supplied value is
|
||||
// bound as a query parameter ($N), never interpolated into the SQL string.
|
||||
func ParseFilter(dsl string, argStart int) (sql string, nextN int, args []any, err error) {
|
||||
dsl = strings.TrimSpace(dsl)
|
||||
if dsl == "" || dsl == "{}" {
|
||||
return "", argStart, nil, nil
|
||||
}
|
||||
toks, err := lexFilter(dsl)
|
||||
node, err := parseFilterAST(dsl)
|
||||
if err != nil {
|
||||
return "", argStart, nil, err
|
||||
}
|
||||
if len(toks) == 0 {
|
||||
if node == nil {
|
||||
return "", argStart, nil, nil
|
||||
}
|
||||
p := &filterParser{tokens: toks}
|
||||
node, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return "", argStart, nil, err
|
||||
}
|
||||
if p.pos != len(p.tokens) {
|
||||
return "", argStart, nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
||||
}
|
||||
sql, nextN, args = node.toSQL(argStart, nil)
|
||||
return sql, nextN, args, nil
|
||||
}
|
||||
|
||||
// tagUse is a tag referenced by a filter, with whether it was included
|
||||
// (positive) or excluded (negated under an odd number of NOTs).
|
||||
type tagUse struct {
|
||||
tagID uuid.UUID
|
||||
included bool
|
||||
}
|
||||
|
||||
// filterTagUses extracts the distinct tag references in a filter DSL, marking
|
||||
// each as included or excluded. The "untagged" pseudo-token (zero UUID) is
|
||||
// skipped. Returns nil for a filter with no tag terms; an unparseable filter
|
||||
// also yields nil (extraction is best-effort analytics, not validation).
|
||||
func filterTagUses(dsl string) []tagUse {
|
||||
node, err := parseFilterAST(dsl)
|
||||
if err != nil || node == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool)
|
||||
collectTagUses(node, true, seen)
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
uses := make([]tagUse, 0, len(seen))
|
||||
for id, inc := range seen {
|
||||
uses = append(uses, tagUse{tagID: id, included: inc})
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
// collectTagUses walks the AST, recording each real tag leaf into out keyed by
|
||||
// id. included flips under every NOT, so a tag is "excluded" only when nested
|
||||
// under an odd number of NOTs. A tag appearing under both polarities keeps the
|
||||
// last seen — pathological, but it avoids a duplicate-key insert.
|
||||
func collectTagUses(node filterNode, included bool, out map[uuid.UUID]bool) {
|
||||
switch nd := node.(type) {
|
||||
case *andNode:
|
||||
collectTagUses(nd.left, included, out)
|
||||
collectTagUses(nd.right, included, out)
|
||||
case *orNode:
|
||||
collectTagUses(nd.left, included, out)
|
||||
collectTagUses(nd.right, included, out)
|
||||
case *notNode:
|
||||
collectTagUses(nd.child, !included, out)
|
||||
case *leafNode:
|
||||
if nd.tok.kind == ftkTag && !nd.tok.untagged {
|
||||
out[nd.tok.tagID] = included
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestFilterTagUses(t *testing.T) {
|
||||
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsl string
|
||||
want map[uuid.UUID]bool // tag → included; absence means "not recorded"
|
||||
}{
|
||||
{"single included", "{t=" + a.String() + "}", map[uuid.UUID]bool{a: true}},
|
||||
{"single excluded", "{!,t=" + a.String() + "}", map[uuid.UUID]bool{a: false}},
|
||||
{"double negation is included", "{!,!,t=" + a.String() + "}", map[uuid.UUID]bool{a: true}},
|
||||
{
|
||||
"and of two included",
|
||||
"{t=" + a.String() + ",&,t=" + b.String() + "}",
|
||||
map[uuid.UUID]bool{a: true, b: true},
|
||||
},
|
||||
{
|
||||
"not over a group excludes both",
|
||||
"{!,(,t=" + a.String() + ",|,t=" + b.String() + ",)}",
|
||||
map[uuid.UUID]bool{a: false, b: false},
|
||||
},
|
||||
{"untagged pseudo-token skipped", "{t=" + uuid.Nil.String() + "}", map[uuid.UUID]bool{}},
|
||||
{"mime-only filter records nothing", "{m=3}", map[uuid.UUID]bool{}},
|
||||
{"empty filter", "{}", map[uuid.UUID]bool{}},
|
||||
{"unparseable filter is best-effort nil", "{t=not-a-uuid}", map[uuid.UUID]bool{}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := make(map[uuid.UUID]bool)
|
||||
for _, u := range filterTagUses(tc.dsl) {
|
||||
got[u.tagID] = u.included
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %d uses %v, want %d %v", len(got), got, len(tc.want), tc.want)
|
||||
}
|
||||
for id, inc := range tc.want {
|
||||
if g, ok := got[id]; !ok || g != inc {
|
||||
t.Errorf("tag %s: got (included=%v, present=%v), want included=%v", id, g, ok, inc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,13 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
|
||||
}
|
||||
sortCol := tagSortColumn(params.Sort)
|
||||
|
||||
// When sorting by category, break ties within a category by the tag's own
|
||||
// name (same direction), so tags are grouped by category then alphabetical.
|
||||
secondarySort := ""
|
||||
if params.Sort == "category_name" {
|
||||
secondarySort = fmt.Sprintf("t.name %s, ", order)
|
||||
}
|
||||
|
||||
args := []any{}
|
||||
n := 1
|
||||
var conditions []string
|
||||
@@ -204,8 +211,8 @@ FROM data.tags t
|
||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
||||
JOIN core.users u ON u.id = t.creator_id
|
||||
%s
|
||||
ORDER BY %s %s NULLS LAST, t.id ASC
|
||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
||||
ORDER BY %s %s NULLS LAST, %st.id ASC
|
||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, secondarySort, n, n+1)
|
||||
|
||||
args = append(args, limit, offset)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ type harness struct {
|
||||
server *httptest.Server
|
||||
client *http.Client
|
||||
importDir string
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// setupSuite creates an ephemeral database, runs migrations, wires the full
|
||||
@@ -165,6 +166,7 @@ func setupSuite(t *testing.T) *harness {
|
||||
server: srv,
|
||||
client: srv.Client(),
|
||||
importDir: importDir,
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +194,32 @@ func (h *harness) url(path string) string {
|
||||
return h.server.URL + "/api/v1" + path
|
||||
}
|
||||
|
||||
// tagUses returns all activity.tag_uses rows as tag_id (text) → is_included.
|
||||
func (h *harness) tagUses(ctx context.Context) map[string]bool {
|
||||
h.t.Helper()
|
||||
rows, err := h.pool.Query(ctx, `SELECT tag_id::text, is_included FROM activity.tag_uses`)
|
||||
require.NoError(h.t, err)
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var included bool
|
||||
require.NoError(h.t, rows.Scan(&id, &included))
|
||||
out[id] = included
|
||||
}
|
||||
require.NoError(h.t, rows.Err())
|
||||
return out
|
||||
}
|
||||
|
||||
// countTagUses returns the number of rows in activity.tag_uses.
|
||||
func (h *harness) countTagUses(ctx context.Context) int {
|
||||
h.t.Helper()
|
||||
var n int
|
||||
require.NoError(h.t, h.pool.QueryRow(ctx, `SELECT count(*) FROM activity.tag_uses`).Scan(&n))
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *harness) do(method, path string, body io.Reader, token string, contentType string) *testResponse {
|
||||
h.t.Helper()
|
||||
req, err := http.NewRequest(method, h.url(path), body)
|
||||
@@ -718,6 +746,117 @@ func TestRecordFileView(t *testing.T) {
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// TestRecordTagUses verifies that filtering files by tags logs to
|
||||
// activity.tag_uses — included tags as is_included=true, negated ones as
|
||||
// false — while an unfiltered listing and follow-up pagination record nothing.
|
||||
func TestRecordTagUses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
h := setupSuite(t)
|
||||
ctx := context.Background()
|
||||
adminToken := h.login("admin", "admin")
|
||||
|
||||
resp := h.doJSON("POST", "/tags", map[string]any{"name": "sea"}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var sea map[string]any
|
||||
resp.decode(t, &sea)
|
||||
seaID := sea["id"].(string)
|
||||
|
||||
resp = h.doJSON("POST", "/tags", map[string]any{"name": "sky"}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var sky map[string]any
|
||||
resp.decode(t, &sky)
|
||||
skyID := sky["id"].(string)
|
||||
|
||||
// Two files both tagged "sea", so a paged {t=sea} listing has a second page.
|
||||
for _, name := range []string{"a.jpg", "b.jpg"} {
|
||||
f := h.uploadJPEG(adminToken, name)
|
||||
resp = h.doJSON("PUT", "/files/"+f["id"].(string)+"/tags",
|
||||
map[string]any{"tag_ids": []string{seaID}}, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// An unfiltered listing must not touch tag_uses.
|
||||
resp = h.doJSON("GET", "/files", nil, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
require.Equal(t, 0, h.countTagUses(ctx), "unfiltered list should record nothing")
|
||||
|
||||
// Include "sea": {t=sea}, one item per page so a next_cursor comes back.
|
||||
resp = h.doJSON("GET", "/files?limit=1&filter=%7Bt%3D"+seaID+"%7D", nil, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var page1 map[string]any
|
||||
resp.decode(t, &page1)
|
||||
nextCursor, _ := page1["next_cursor"].(string)
|
||||
require.NotEmpty(t, nextCursor, "expected a next_cursor for page 2")
|
||||
|
||||
// Exclude "sky": {!,t=sky}
|
||||
resp = h.doJSON("GET", "/files?filter=%7B%21%2Ct%3D"+skyID+"%7D", nil, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
|
||||
uses := h.tagUses(ctx)
|
||||
require.Len(t, uses, 2, "expected one row per filtered tag")
|
||||
assert.True(t, uses[seaID], "included tag should be is_included=true")
|
||||
assert.False(t, uses[skyID], "negated tag should be is_included=false")
|
||||
|
||||
// Page 2 (cursor present) is pagination, not a fresh filter — no new row.
|
||||
resp = h.doJSON("GET", "/files?limit=1&cursor="+nextCursor+"&filter=%7Bt%3D"+seaID+"%7D",
|
||||
nil, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
assert.Equal(t, 2, h.countTagUses(ctx), "pagination should not add tag_uses rows")
|
||||
}
|
||||
|
||||
// TestTagSortByCategoryThenName verifies the category_name sort groups tags by
|
||||
// category and orders them by their own name within each category, with
|
||||
// uncategorized tags last (NULLS LAST).
|
||||
func TestTagSortByCategoryThenName(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
h := setupSuite(t)
|
||||
adminToken := h.login("admin", "admin")
|
||||
|
||||
mkCategory := func(name string) string {
|
||||
resp := h.doJSON("POST", "/categories", map[string]any{"name": name}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var c map[string]any
|
||||
resp.decode(t, &c)
|
||||
return c["id"].(string)
|
||||
}
|
||||
mkTag := func(name string, categoryID *string) {
|
||||
body := map[string]any{"name": name}
|
||||
if categoryID != nil {
|
||||
body["category_id"] = *categoryID
|
||||
}
|
||||
resp := h.doJSON("POST", "/tags", body, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
alpha := mkCategory("Alpha")
|
||||
bravo := mkCategory("Bravo")
|
||||
|
||||
// Insert out of order to prove the sort, not insertion order, decides output.
|
||||
mkTag("zebra", &alpha)
|
||||
mkTag("mid", &bravo)
|
||||
mkTag("solo", nil) // uncategorized
|
||||
mkTag("ant", &alpha)
|
||||
|
||||
resp := h.doJSON("GET", "/tags?sort=category_name&order=asc", nil, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var page map[string]any
|
||||
resp.decode(t, &page)
|
||||
|
||||
items := page["items"].([]any)
|
||||
names := make([]string, len(items))
|
||||
for i, it := range items {
|
||||
names[i] = it.(map[string]any)["name"].(string)
|
||||
}
|
||||
// Alpha (ant, zebra) → Bravo (mid) → uncategorized (solo) last.
|
||||
assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names)
|
||||
}
|
||||
|
||||
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
|
||||
func TestBulkTagAutoRule(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
||||
@@ -62,6 +62,10 @@ type FileRepo interface {
|
||||
|
||||
// RecordView appends a view-history row (activity.file_views) for the user.
|
||||
RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error
|
||||
// RecordTagUses logs the tags referenced in a filter DSL to
|
||||
// activity.tag_uses, flagging each included or excluded. Best-effort
|
||||
// analytics — callers may ignore the error.
|
||||
RecordTagUses(ctx context.Context, userID int16, filterDSL string) error
|
||||
}
|
||||
|
||||
// TagRepo is the persistence interface for tags.
|
||||
|
||||
@@ -461,7 +461,20 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
// files the caller may see (unless they are an admin).
|
||||
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
||||
params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx)
|
||||
return s.files.List(ctx, params)
|
||||
|
||||
page, err := s.files.List(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log tag usage when a filter is first applied — not on pagination (cursor)
|
||||
// or an anchored return, so a single browse counts once. Best-effort
|
||||
// analytics; a failed write never breaks the listing.
|
||||
if params.Filter != "" && params.Cursor == "" && params.Anchor == nil && params.ViewerID != 0 {
|
||||
_ = s.files.RecordTagUses(ctx, params.ViewerID, params.Filter)
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// AuthorizeView ensures the caller may view the file. Returns ErrNotFound if the
|
||||
|
||||
@@ -54,5 +54,11 @@ function tagSortKey(t: Tag, field: TagSortField): string {
|
||||
*/
|
||||
export function sortTags(tags: Tag[], { sort, order }: SortState<TagSortField>): Tag[] {
|
||||
const dir = order === 'asc' ? 1 : -1;
|
||||
return [...tags].sort((a, b) => dir * tagSortKey(a, sort).localeCompare(tagSortKey(b, sort)));
|
||||
return [...tags].sort((a, b) => {
|
||||
const primary = dir * tagSortKey(a, sort).localeCompare(tagSortKey(b, sort));
|
||||
if (primary !== 0 || sort !== 'category_name') return primary;
|
||||
// Same category: break the tie by the tag's own name (same direction), so
|
||||
// tags are grouped by category then alphabetical — matching the server.
|
||||
return dir * (a.name ?? '').localeCompare(b.name ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,6 +159,20 @@
|
||||
void remove(tag.id);
|
||||
assignedFocusIdx = Math.min(assignedFocusIdx, assignedTags.length - 2);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// Staged exit: a non-empty filter clears first; once empty, Escape
|
||||
// releases focus. Stop propagation so neither step reaches the page's
|
||||
// window handler — only the *next* Escape (with focus already gone) does,
|
||||
// and that one closes the popup.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (search) {
|
||||
search = '';
|
||||
assignedFocusIdx = -1;
|
||||
} else {
|
||||
assignedFocusIdx = -1;
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
||||
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
||||
import type { File, Tag } from '$lib/api/types';
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +27,7 @@
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let poolPickerOpen = $state(false);
|
||||
|
||||
// Tags are loaded lazily — the Tags section sits below a full-viewport
|
||||
// preview, so fetching them on open just hammers the DB for data the user
|
||||
@@ -184,10 +186,29 @@
|
||||
}
|
||||
|
||||
// ---- Keyboard ----
|
||||
let viewerPage = $state<HTMLElement>();
|
||||
let tagsSection = $state<HTMLElement>();
|
||||
let pendingTagFocus = false;
|
||||
|
||||
// Bring the preview back to the top of the scroll container (the overlay, or
|
||||
// the page in the standalone route). scrollIntoView resolves the right
|
||||
// scroller in either case. Called when Escape leaves the tag filter.
|
||||
function revealPreview() {
|
||||
viewerPage?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// While the pool picker is open it owns the keyboard: Escape closes it
|
||||
// (even from its search field), and every other key is swallowed so the
|
||||
// viewer's shortcuts don't fire behind the modal. Typing still works —
|
||||
// non-Escape keys aren't prevented, only ignored here.
|
||||
if (poolPickerOpen) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
poolPickerOpen = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
// Letter keys are matched by physical position (e.code) so j/k/e work on any
|
||||
@@ -242,7 +263,7 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="viewer-page">
|
||||
<div class="viewer-page" bind:this={viewerPage}>
|
||||
<!-- Top bar -->
|
||||
<div class="top-bar">
|
||||
<button class="back-btn" onclick={onClose} aria-label="Back to files">
|
||||
@@ -257,6 +278,32 @@
|
||||
</svg>
|
||||
</button>
|
||||
<span class="filename">{file?.original_name ?? ''}</span>
|
||||
{#if file}
|
||||
<button
|
||||
class="pool-btn"
|
||||
onclick={() => (poolPickerOpen = true)}
|
||||
aria-label="Add to pool"
|
||||
title="Add to pool"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="11"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M10 8.5v4M8 10.5h4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
@@ -377,7 +424,7 @@
|
||||
<section class="section" use:tagsSentinel bind:this={tagsSection}>
|
||||
<div class="field-label">Tags</div>
|
||||
{#if tagsLoaded}
|
||||
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
||||
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} onExit={revealPreview} />
|
||||
{:else}
|
||||
<p class="tags-loading">Loading tags…</p>
|
||||
{/if}
|
||||
@@ -401,6 +448,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if poolPickerOpen && file}
|
||||
<PoolPicker fileIds={[file.id!]} onClose={() => (poolPickerOpen = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.viewer-page {
|
||||
display: flex;
|
||||
@@ -449,6 +500,25 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pool-btn {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pool-btn:hover {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Preview ---- */
|
||||
.preview-wrap {
|
||||
position: relative;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api/client';
|
||||
import type { Pool, PoolOffsetPage } from '$lib/api/types';
|
||||
|
||||
interface Props {
|
||||
/** Files to add to the chosen pool. */
|
||||
fileIds: string[];
|
||||
/** Called after a successful add (before close) — e.g. to clear a selection. */
|
||||
onAdded?: (poolId: string) => void;
|
||||
/** Close the picker without adding. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { fileIds, onAdded, onClose }: Props = $props();
|
||||
|
||||
let pools = $state<Pool[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
let addError = $state('');
|
||||
let search = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await api.get<PoolOffsetPage>('/pools?limit=200&sort=name&order=asc');
|
||||
pools = res.items ?? [];
|
||||
} catch {
|
||||
loadError = 'Failed to load pools';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = $derived(
|
||||
search.trim()
|
||||
? pools.filter((p) => p.name?.toLowerCase().includes(search.toLowerCase()))
|
||||
: pools
|
||||
);
|
||||
|
||||
async function add(poolId: string) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
addError = '';
|
||||
try {
|
||||
await api.post(`/pools/${poolId}/files`, { file_ids: fileIds });
|
||||
onAdded?.(poolId);
|
||||
onClose();
|
||||
} catch {
|
||||
addError = 'Failed to add to pool';
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let count = $derived(fileIds.length);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="picker-backdrop" role="presentation" onclick={onClose}></div>
|
||||
<div class="picker-sheet" class:busy role="dialog" aria-label="Add to pool">
|
||||
<div class="picker-header">
|
||||
<span class="picker-title">Add {count} file{count !== 1 ? 's' : ''} to pool</span>
|
||||
<button class="picker-close" onclick={onClose} aria-label="Close">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="picker-search-wrap">
|
||||
<input
|
||||
class="picker-search"
|
||||
type="search"
|
||||
placeholder="Search pools…"
|
||||
bind:value={search}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="picker-empty">Loading…</p>
|
||||
{:else if loadError}
|
||||
<p class="picker-error">{loadError}</p>
|
||||
{:else}
|
||||
{#if addError}
|
||||
<p class="picker-error">{addError}</p>
|
||||
{/if}
|
||||
{#if filtered.length === 0}
|
||||
<p class="picker-empty">No pools found.</p>
|
||||
{:else}
|
||||
<ul class="picker-list">
|
||||
{#each filtered as pool (pool.id)}
|
||||
<li>
|
||||
<button class="picker-item" onclick={() => pool.id && add(pool.id)}>
|
||||
<span class="picker-item-name">{pool.name}</span>
|
||||
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.picker-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 111;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: 14px 14px 0 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
max-height: 70dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-up 0.18s ease-out;
|
||||
}
|
||||
|
||||
.picker-sheet.busy {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.picker-close:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.picker-search-wrap {
|
||||
padding: 0 14px 10px;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.picker-search:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 8px 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 11px 10px;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picker-item:hover {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.picker-item-name {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.picker-item-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picker-empty,
|
||||
.picker-error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picker-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,12 @@
|
||||
fileTags: Tag[];
|
||||
onAdd: (tagId: string) => Promise<void>;
|
||||
onRemove: (tagId: string) => Promise<void>;
|
||||
/** Called when Escape leaves an already-empty filter, so the viewer can
|
||||
* scroll the preview back into view. */
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
let { fileTags, onAdd, onRemove }: Props = $props();
|
||||
let { fileTags, onAdd, onRemove, onExit }: Props = $props();
|
||||
|
||||
let allTags = $state<Tag[]>([]);
|
||||
let search = $state('');
|
||||
@@ -110,11 +113,18 @@
|
||||
assignedFocusIdx = Math.min(assignedFocusIdx, filteredAssigned.length - 2);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// Let the keyboard leave the field: blur back to the page so arrow keys
|
||||
// and Escape reach the viewer again (e.g. a second Esc closes it).
|
||||
e.preventDefault();
|
||||
if (search) {
|
||||
// Non-empty filter: just clear it, keeping focus for more editing.
|
||||
search = '';
|
||||
assignedFocusIdx = -1;
|
||||
return;
|
||||
}
|
||||
// Empty: blur back to the page (so arrow keys and a further Escape reach
|
||||
// the viewer) and let it scroll the preview back into view.
|
||||
assignedFocusIdx = -1;
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
onExit?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
||||
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
||||
import { tick, flushSync } from 'svelte';
|
||||
import { parseDslFilter } from '$lib/utils/dsl';
|
||||
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
||||
import type { File, FileCursorPage } from '$lib/api/types';
|
||||
import { appSettings } from '$lib/stores/appSettings';
|
||||
|
||||
// What the section cache stores for the Files grid. `resetKey` guards against
|
||||
@@ -207,44 +208,14 @@
|
||||
}
|
||||
|
||||
// ---- Add to pool picker ----
|
||||
// The picker itself (load, search, add) lives in PoolPicker; here we just
|
||||
// gate it open and clear the selection once files land in a pool.
|
||||
let poolPickerOpen = $state(false);
|
||||
let pools = $state<Pool[]>([]);
|
||||
let poolsLoading = $state(false);
|
||||
let poolPickerSearch = $state('');
|
||||
let poolPickerError = $state('');
|
||||
|
||||
async function openPoolPicker() {
|
||||
function openPoolPicker() {
|
||||
poolPickerOpen = true;
|
||||
poolPickerError = '';
|
||||
poolsLoading = true;
|
||||
poolPickerSearch = '';
|
||||
try {
|
||||
const res = await api.get<PoolOffsetPage>('/pools?limit=200&sort=name&order=asc');
|
||||
pools = res.items ?? [];
|
||||
} catch {
|
||||
poolPickerError = 'Failed to load pools';
|
||||
} finally {
|
||||
poolsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addToPool(poolId: string) {
|
||||
const ids = [...$selectionStore.ids];
|
||||
poolPickerOpen = false;
|
||||
selectionStore.exit();
|
||||
try {
|
||||
await api.post(`/pools/${poolId}/files`, { file_ids: ids });
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
let filteredPools = $derived(
|
||||
poolPickerSearch.trim()
|
||||
? pools.filter((p) => p.name?.toLowerCase().includes(poolPickerSearch.toLowerCase()))
|
||||
: pools
|
||||
);
|
||||
|
||||
function handleUploaded(file: File) {
|
||||
files = [file, ...files];
|
||||
}
|
||||
@@ -904,52 +875,11 @@
|
||||
{/if}
|
||||
|
||||
{#if poolPickerOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div>
|
||||
<div class="picker-sheet" role="dialog" aria-label="Add to pool">
|
||||
<div class="picker-header">
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="picker-search-wrap">
|
||||
<input
|
||||
class="picker-search"
|
||||
type="search"
|
||||
placeholder="Search pools…"
|
||||
bind:value={poolPickerSearch}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
{#if poolPickerError}
|
||||
<p class="picker-error">{poolPickerError}</p>
|
||||
{:else if poolsLoading}
|
||||
<p class="picker-empty">Loading…</p>
|
||||
{:else if filteredPools.length === 0}
|
||||
<p class="picker-empty">No pools found.</p>
|
||||
{:else}
|
||||
<ul class="picker-list">
|
||||
{#each filteredPools as pool (pool.id)}
|
||||
<li>
|
||||
<button class="picker-item" onclick={() => pool.id && addToPool(pool.id)}>
|
||||
<span class="picker-item-name">{pool.name}</span>
|
||||
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<PoolPicker
|
||||
fileIds={[...$selectionStore.ids]}
|
||||
onAdded={() => selectionStore.exit()}
|
||||
onClose={() => (poolPickerOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if confirmDeleteFiles}
|
||||
@@ -1035,7 +965,7 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- Pool picker ---- */
|
||||
/* ---- Bottom-sheet shell (shared by the tag editor sheet) ---- */
|
||||
.picker-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1095,75 +1025,4 @@
|
||||
.picker-close:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.picker-search-wrap {
|
||||
padding: 0 14px 10px;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.picker-search:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 8px 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 11px 10px;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picker-item:hover {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.picker-item-name {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.picker-item-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picker-empty,
|
||||
.picker-error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picker-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user