all repos — weimar @ 3d9ddf2add48689bb513ed4f59a427849dfe0805

Unnamed repository; edit this file 'description' to name the repository.

feat: filter images by uploader with clickable uploader names

Adds `?uploader=<username>` query param support to GET /api/images.
Uploader names are clickable throughout the UI (browse cards, viewer,
detail page) to filter the gallery to that uploader's media.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 21:19:18 +0200
commit

3d9ddf2add48689bb513ed4f59a427849dfe0805

parent

cd624b1b90061a10147950a7633dd7579c78a61d

M internal/api/images.gointernal/api/images.go

@@ -10,6 +10,7 @@ )

func (a *API) HandleListImages(w http.ResponseWriter, r *http.Request) { tags := r.URL.Query()["tags"] + uploader := r.URL.Query().Get("uploader") page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 {

@@ -21,7 +22,7 @@ perPage = 20

} offset := (page - 1) * perPage - result, err := a.DB.ListImages(r.Context(), tags, perPage, offset) + result, err := a.DB.ListImages(r.Context(), tags, uploader, perPage, offset) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list images") return
M internal/db/db_test.gointernal/db/db_test.go

@@ -318,7 +318,7 @@ })

} // Test full list. - list, err := d.ListImages(ctx, nil, 10, 0) + list, err := d.ListImages(ctx, nil, "", 10, 0) if err != nil { t.Fatalf("ListImages: %v", err) }

@@ -330,7 +330,7 @@ t.Fatalf("total = %d, want 5", list.Total)

} // Test pagination (newest-first). - list, _ = d.ListImages(ctx, nil, 2, 0) + list, _ = d.ListImages(ctx, nil, "", 2, 0) if len(list.Images) != 2 { t.Fatalf("expected 2 images, got %d", len(list.Images)) }
M internal/db/queries.gointernal/db/queries.go

@@ -236,10 +236,10 @@ img.Tags = tags

return img, nil } -// ListImages returns a paginated list of images, optionally filtered by tags. -// Multiple tags are AND-ed (image must have all specified tags). -// Order is newest-first by created_at. -func (d *DB) ListImages(ctx context.Context, filterTags []string, limit, offset int) (*ImageList, error) { +// ListImages returns a paginated list of images, optionally filtered by tags +// and/or uploader username. Multiple tags are AND-ed (image must have all +// specified tags). Order is newest-first by created_at. +func (d *DB) ListImages(ctx context.Context, filterTags []string, filterUploader string, limit, offset int) (*ImageList, error) { // Count query countQuery := `SELECT COUNT(DISTINCT i.id) FROM images i` // Data query

@@ -249,6 +249,14 @@ i.created_at, i.updated_at

FROM images i` var args []any + var tagJoin string + var uploaderWhere string + var tagWhere string + + if filterUploader != "" { + uploaderWhere = `i.uploaded_by = (SELECT id FROM users WHERE username = ?)` + args = append(args, filterUploader) + } if len(filterTags) > 0 { placeholders := make([]string, len(filterTags))

@@ -256,12 +264,26 @@ for j, t := range filterTags {

placeholders[j] = "?" args = append(args, t) } - having := fmt.Sprintf("HAVING COUNT(DISTINCT it.tag) = %d", len(filterTags)) + tagJoin = ` JOIN image_tags it ON i.id = it.image_id` + tagWhere = `it.tag IN (` + strings.Join(placeholders, ",") + `)` + } - join := ` JOIN image_tags it ON i.id = it.image_id WHERE it.tag IN (` + - strings.Join(placeholders, ",") + `)` - countQuery += join - dataQuery += join + ` GROUP BY i.id ` + having + // Build WHERE clause. + var whereClause string + switch { + case tagWhere != "" && uploaderWhere != "": + whereClause = ` WHERE ` + tagWhere + ` AND ` + uploaderWhere + case tagWhere != "": + whereClause = ` WHERE ` + tagWhere + case uploaderWhere != "": + whereClause = ` WHERE ` + uploaderWhere + } + + countQuery += tagJoin + whereClause + dataQuery += tagJoin + whereClause + + if len(filterTags) > 0 { + dataQuery += ` GROUP BY i.id HAVING COUNT(DISTINCT it.tag) = ` + fmt.Sprintf("%d", len(filterTags)) } dataQuery += ` ORDER BY i.created_at DESC LIMIT ? OFFSET ?`
M web/src/lib/ImageViewer.svelteweb/src/lib/ImageViewer.svelte

@@ -216,7 +216,7 @@ <div class="viewer-info" class:controls-hidden={!showControls}>

<div class="viewer-info-left"> <div class="viewer-uploader"> <Avatar src={current.avatar_url} username={current.uploader_username} size={80} /> - <span class="viewer-uploader-name">{current.uploader_username}</span> + <a href="/browse?uploader={encodeURIComponent(current.uploader_username)}" class="viewer-uploader-name" onclick={() => onclose()}>{current.uploader_username}</a> </div> <div class="viewer-filename">{current.filename}</div> <div class="viewer-meta">

@@ -414,6 +414,11 @@ color: #fff;

font-family: 'Inter', sans-serif; font-weight: 600; font-size: 0.95rem; + text-decoration: none; + } + .viewer-uploader-name:hover { + color: #ef5350; + text-decoration: underline; } .viewer-filename {
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -89,12 +89,15 @@ export async function logout(): Promise<void> {

await request('/auth/logout', { method: 'POST' }); } -export async function listImages(tags: string[] = [], page = 1, perPage = 50): Promise<ImageList> { +export async function listImages(tags: string[] = [], uploader = '', page = 1, perPage = 50): Promise<ImageList> { const params = new URLSearchParams(); params.set('page', String(page)); params.set('per_page', String(perPage)); for (const t of tags) { params.append('tags', t); + } + if (uploader) { + params.set('uploader', uploader); } return request<ImageList>(`/images?${params}`); }
M web/src/routes/browse/+page.svelteweb/src/routes/browse/+page.svelte

@@ -9,6 +9,7 @@ let total = $state(0);

let loading = $state(true); let error = $state(''); let filterTag = $state(''); + let filterUploader = $state(''); let page = $state(1); let viewerIndex = $state(-1); const perPage = 60;

@@ -21,6 +22,7 @@ mainEl.style.maxWidth = 'none';

} const params = new URLSearchParams(window.location.search); filterTag = params.get('tag') || ''; + filterUploader = params.get('uploader') || ''; loadImages().then(() => { const imageParam = params.get('image'); if (imageParam) {

@@ -47,7 +49,7 @@ loading = true;

error = ''; try { const tags = filterTag ? [filterTag] : []; - const result = await listImages(tags, page, perPage); + const result = await listImages(tags, filterUploader, page, perPage); images = result.images; total = result.total; } catch (err) {

@@ -78,7 +80,10 @@ }

function closeViewer() { viewerIndex = -1; - history.replaceState(null, '', filterTag ? '/browse?tag=' + encodeURIComponent(filterTag) : '/browse'); + const params = new URLSearchParams(); + if (filterTag) params.set('tag', filterTag); + if (filterUploader) params.set('uploader', filterUploader); + history.replaceState(null, '', params.toString() ? '/browse?' + params.toString() : '/browse'); } </script>

@@ -93,11 +98,17 @@ <input

type="text" placeholder="Filter by tag..." bind:value={filterTag} + onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} + /> + <input + type="text" + placeholder="Filter by uploader..." + bind:value={filterUploader} onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} /> <button class="btn" onclick={applyFilter}>FILTER</button> - {#if filterTag} - <button class="btn btn-clear" onclick={() => { filterTag = ''; applyFilter(); }}>CLEAR</button> + {#if filterTag || filterUploader} + <button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button> {/if} </div> </div>

@@ -108,8 +119,12 @@ {:else if error}

<p class="state-msg error">{error}</p> {:else if images.length === 0} <div class="empty"> - {#if filterTag} - <p>No results for tag <strong>&ldquo;{filterTag}&rdquo;</strong>.</p> + {#if filterTag || filterUploader} + <p>No results + {#if filterTag}for tag <strong>&ldquo;{filterTag}&rdquo;</strong>{/if} + {#if filterTag && filterUploader} and {/if} + {#if filterUploader}by uploader <strong>&ldquo;{filterUploader}&rdquo;</strong>{/if}. + </p> {:else} <p>The archive is empty.</p> <a href="/upload" class="btn">UPLOAD MEDIA</a>

@@ -132,7 +147,7 @@ <div class="card-user">

{#if img.avatar_url} <img src={img.avatar_url} alt="" class="card-avatar" /> {/if} - #{img.id} &middot; {img.uploader_username} + #{img.id} &middot; <span class="user-link" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); filterUploader = img.uploader_username; applyFilter(); }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); filterUploader = img.uploader_username; applyFilter(); } }}>{img.uploader_username}</span> </div> </div> </button>

@@ -335,6 +350,21 @@ height: 1rem;

border-radius: 0; object-fit: cover; flex-shrink: 0; + } + + .user-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: rgba(255, 255, 255, 0.55); + cursor: pointer; + text-decoration: none; + pointer-events: auto; + } + .user-link:hover { + color: #ef5350; + text-decoration: underline; } .pagination {
M web/src/routes/image/[id]/+page.svelteweb/src/routes/image/[id]/+page.svelte

@@ -115,7 +115,7 @@ <div class="uploader-row">

{#if img.avatar_url} <img src={img.avatar_url} alt="" class="avatar-img" /> {/if} - <span class="meta-value">{img.uploader_username}</span> + <a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a> </div> </div> <div class="meta-block">

@@ -272,6 +272,15 @@ display: flex;

align-items: center; gap: 0.4rem; } + .uploader-link { + color: #1a1a1a; + text-decoration: none; + } + .uploader-link:hover { + color: #c62828; + text-decoration: underline; + } + .avatar-img { width: 1.5rem; height: 1.5rem;