all repos — weimar @ 088c2e33b9850fd8e9a0a65e6876d579b0f5433f

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

feat: autocomplete suggestions for tag and uploader filters

Tag filter shows usage count, uploader filter shows upload count.
Both endpoints use requireAuthOrPublic for unauthenticated browse.
200ms debounce on input. Backend uses LIKE prefix match ordered by
count descending.

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

088c2e33b9850fd8e9a0a65e6876d579b0f5433f

parent

3d9ddf2add48689bb513ed4f59a427849dfe0805

M internal/api/tags.gointernal/api/tags.go

@@ -25,3 +25,23 @@ }

writeJSON(w, http.StatusOK, tags) } + +func (a *API) HandleUserSuggest(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q == "" { + writeJSON(w, http.StatusOK, []any{}) + return + } + + users, err := a.DB.SuggestUploaders(r.Context(), q, 10) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to suggest users") + return + } + + if users == nil { + users = []db.Tag{} + } + + writeJSON(w, http.StatusOK, users) +}
M internal/db/queries.gointernal/db/queries.go

@@ -456,6 +456,33 @@ return tags, rows.Err()

} // SuggestTags returns tags matching a prefix, ordered by usage count descending. +// SuggestUploaders returns usernames matching a prefix, ordered by upload count descending. +func (d *DB) SuggestUploaders(ctx context.Context, prefix string, limit int) ([]Tag, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT u.username, COUNT(i.id) AS cnt + FROM users u + LEFT JOIN images i ON i.uploaded_by = u.id + WHERE u.username LIKE ? + GROUP BY u.id, u.username + ORDER BY cnt DESC, u.username + LIMIT ?`, + prefix+"%", limit) + if err != nil { + return nil, fmt.Errorf("suggest uploaders: %w", err) + } + defer rows.Close() + + users := make([]Tag, 0) + for rows.Next() { + var t Tag + if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil { + return nil, err + } + users = append(users, t) + } + return users, rows.Err() +} + func (d *DB) SuggestTags(ctx context.Context, prefix string, limit int) ([]Tag, error) { rows, err := d.db.QueryContext(ctx, `SELECT tag, usage_count FROM tags WHERE tag LIKE ? ORDER BY usage_count DESC LIMIT ?`,
M internal/server/server.gointernal/server/server.go

@@ -58,8 +58,11 @@ mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail))

mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags)) mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags)) - // Tags (authenticated). - mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest)) + // Tags (public for browse suggestions). + mux.HandleFunc("GET /api/tags/suggest", s.requireAuthOrPublic(h.HandleTagSuggest)) + + // Users (public for browse suggestions). + mux.HandleFunc("GET /api/users/suggest", s.requireAuthOrPublic(h.HandleUserSuggest)) // Users (authenticated). mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -127,6 +127,10 @@ export async function suggestTags(query: string): Promise<Tag[]> {

return request<Tag[]>(`/tags/suggest?q=${encodeURIComponent(query)}`); } +export async function suggestUploaders(query: string): Promise<Tag[]> { + return request<Tag[]>(`/users/suggest?q=${encodeURIComponent(query)}`); +} + export async function uploadAvatar(file: File): Promise<{ avatar_url: string }> { const fd = new FormData(); fd.append('avatar', file);
M web/src/routes/browse/+page.svelteweb/src/routes/browse/+page.svelte

@@ -1,6 +1,6 @@

<script lang="ts"> import { onMount, onDestroy } from 'svelte'; - import { listImages, thumbnailUrl, type Image } from '$lib/api'; + import { listImages, suggestTags, suggestUploaders, thumbnailUrl, type Image, type Tag } from '$lib/api'; import ImageViewer from '$lib/ImageViewer.svelte'; import { goto } from '$app/navigation';

@@ -14,6 +14,12 @@ let page = $state(1);

let viewerIndex = $state(-1); const perPage = 60; + let tagSuggestions = $state<Tag[]>([]); + let uploaderSuggestions = $state<Tag[]>([]); + let showTagSugs = $state(false); + let showUploaderSugs = $state(false); + let sugTimer: ReturnType<typeof setTimeout> | undefined; + onMount(() => { const mainEl = document.querySelector('.main') as HTMLElement; if (mainEl) {

@@ -42,6 +48,7 @@ if (mainEl) {

mainEl.style.padding = ''; mainEl.style.maxWidth = ''; } + clearTimeout(sugTimer); }); async function loadImages() {

@@ -70,9 +77,63 @@ }

function applyFilter() { page = 1; + hideSugs(); loadImages(); } + function hideSugs() { + showTagSugs = false; + showUploaderSugs = false; + } + + function onTagInput() { + clearTimeout(sugTimer); + const q = filterTag.trim(); + if (q.length < 1) { + tagSuggestions = []; + showTagSugs = false; + return; + } + sugTimer = setTimeout(async () => { + try { + tagSuggestions = await suggestTags(q); + showTagSugs = tagSuggestions.length > 0; + } catch { + tagSuggestions = []; + showTagSugs = false; + } + }, 200); + } + + function onUploaderInput() { + clearTimeout(sugTimer); + const q = filterUploader.trim(); + if (q.length < 1) { + uploaderSuggestions = []; + showUploaderSugs = false; + return; + } + sugTimer = setTimeout(async () => { + try { + uploaderSuggestions = await suggestUploaders(q); + showUploaderSugs = uploaderSuggestions.length > 0; + } catch { + uploaderSuggestions = []; + showUploaderSugs = false; + } + }, 200); + } + + function selectTagSug(s: Tag) { + filterTag = s.tag; + applyFilter(); + } + + function selectUploaderSug(s: Tag) { + filterUploader = s.tag; + applyFilter(); + } + function openViewer(index: number) { viewerIndex = index; history.replaceState(null, '', '/browse?image=' + images[index].id);

@@ -94,18 +155,46 @@ <span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>

</div> <div class="header-line"></div> <div class="controls"> - <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(); }} - /> + <div class="sug-wrap"> + <input + type="text" + placeholder="Filter by tag..." + bind:value={filterTag} + oninput={onTagInput} + onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} + onblur={() => setTimeout(() => { showTagSugs = false; }, 200)} + /> + {#if showTagSugs} + <ul class="sugs" role="listbox"> + {#each tagSuggestions as s} + <li role="option" tabindex="0" onmousedown={() => selectTagSug(s)}> + <span class="sug-label">{s.tag}</span> + <span class="sug-count">{s.usage_count}</span> + </li> + {/each} + </ul> + {/if} + </div> + <div class="sug-wrap"> + <input + type="text" + placeholder="Filter by uploader..." + bind:value={filterUploader} + oninput={onUploaderInput} + onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} + onblur={() => setTimeout(() => { showUploaderSugs = false; }, 200)} + /> + {#if showUploaderSugs} + <ul class="sugs" role="listbox"> + {#each uploaderSuggestions as s} + <li role="option" tabindex="0" onmousedown={() => selectUploaderSug(s)}> + <span class="sug-label">{s.tag}</span> + <span class="sug-count">{s.usage_count}</span> + </li> + {/each} + </ul> + {/if} + </div> <button class="btn" onclick={applyFilter}>FILTER</button> {#if filterTag || filterUploader} <button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button>

@@ -198,6 +287,15 @@ }

.controls { display: flex; gap: 0.5rem; + align-items: flex-start; + } + .sug-wrap { + position: relative; + flex: 1; + max-width: 280px; + } + .sug-wrap input { + width: 100%; } .controls input { padding: 0.5rem 0.75rem;

@@ -206,13 +304,54 @@ border-radius: 0;

font-family: 'Inter', sans-serif; font-size: 0.85rem; font-weight: 500; - flex: 1; - max-width: 280px; background: #fff; } .controls input:focus { outline: none; border-color: #c62828; + } + + .sugs { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + list-style: none; + margin: 0; + padding: 0; + background: #fff; + border: 2px solid #1a1a1a; + border-top: none; + max-height: 220px; + overflow-y: auto; + } + .sugs li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.75rem; + cursor: pointer; + font-family: 'Inter', sans-serif; + font-size: 0.82rem; + font-weight: 500; + gap: 0.5rem; + } + .sugs li:hover { + background: #f0f0f0; + } + .sug-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .sug-count { + flex-shrink: 0; + font-family: 'Oswald', sans-serif; + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.05em; + color: #888; } .btn {