all repos — weimar @ 3ab9da929b53350b9e5a860642317e5a8f5b3750

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

feat: make tags clickable with usage counts, larger and more prominent

- Tags in ImageViewer and detail page are now clickable pill buttons
- Clicking a tag navigates to /browse?tag=<tagname>
  with the filter auto-applied on mount
- Browse page reads ?tag= URL param on initial load
- Tags display usage count badge (sorted most→least frequent)
- Larger pill sizing with hover effects in both viewer and detail page
- New GetImageTagsWithCounts DB function joins tags+image_tags
  for usage counts returned in GET /api/images/{id} as tags_detail

đŸ’˜ Generated with Crush

Assisted-by: Crush:deepseek-reasoner
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 16:02:30 +0200
commit

3ab9da929b53350b9e5a860642317e5a8f5b3750

parent

a338ad5884f38dd2d5e241df42fd565a97f27f72

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

@@ -3,6 +3,8 @@

import ( "net/http" "strconv" + + "github.com/mjensen/weimar/internal/db" ) func (a *API) HandleListImages(w http.ResponseWriter, r *http.Request) {

@@ -55,5 +57,24 @@ writeError(w, http.StatusNotFound, "image not found")

return } - writeJSON(w, http.StatusOK, img) + tagsDetail, err := a.DB.GetImageTagsWithCounts(r.Context(), id) + if err != nil { + tagsDetail = make([]db.Tag, 0) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "id": img.ID, + "filename": img.Filename, + "storage_path": img.StoragePath, + "thumbnail_path": img.ThumbnailPath, + "uploaded_by": img.UploadedBy, + "file_size": img.FileSize, + "width": img.Width, + "height": img.Height, + "mime_type": img.MimeType, + "created_at": img.CreatedAt, + "updated_at": img.UpdatedAt, + "tags": img.Tags, + "tags_detail": tagsDetail, + }) }
M internal/db/queries.gointernal/db/queries.go

@@ -398,6 +398,31 @@ }

return tags, rows.Err() } +// GetImageTagsWithCounts returns tags and their usage counts for a given image, +// ordered by most frequent first. +func (d *DB) GetImageTagsWithCounts(ctx context.Context, imageID int64) ([]Tag, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT t.tag, t.usage_count + FROM tags t + JOIN image_tags it ON it.tag = t.tag + WHERE it.image_id = ? + ORDER BY t.usage_count DESC, t.tag`, imageID) + if err != nil { + return nil, fmt.Errorf("get image tags with counts: %w", err) + } + defer rows.Close() + + tags := make([]Tag, 0) + for rows.Next() { + var t Tag + if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil { + return nil, err + } + tags = append(tags, t) + } + return tags, rows.Err() +} + // SuggestTags returns tags matching a prefix, ordered by usage count descending. func (d *DB) SuggestTags(ctx context.Context, prefix string, limit int) ([]Tag, error) { rows, err := d.db.QueryContext(ctx,
M web/src/lib/ImageViewer.svelteweb/src/lib/ImageViewer.svelte

@@ -1,5 +1,5 @@

<script lang="ts"> - import { thumbnailUrl, imageDownloadUrl, type Image } from '$lib/api'; + import { thumbnailUrl, imageDownloadUrl, getImage, type Image, type TagDetail } from '$lib/api'; let { images,

@@ -11,17 +11,29 @@ initialIndex?: number;

onclose: () => void; } = $props(); - let currentIndex = $state(initialIndex); // eslint-disable-line svelte/state_referenced_locally + let currentIndex = $state(initialIndex); let loading = $state(true); let showControls = $state(true); + let tagDetails = $state<TagDetail[]>([]); let idleTimer: ReturnType<typeof setTimeout> | undefined = $state(); let current = $derived(images[currentIndex]); + let totalPillCount = $derived(tagDetails.reduce((sum, t) => sum + t.usage_count, 0)); + + async function loadTagDetails() { + try { + const detail = await getImage(current.id); + tagDetails = detail.tags_detail; + } catch { + tagDetails = []; + } + } function loadImage() { loading = true; showControls = true; if (idleTimer) clearTimeout(idleTimer); + loadTagDetails(); } function resetIdleTimer() {

@@ -56,12 +68,12 @@ loadImage();

} } - function handleImgLoad() { + function handleMediaReady() { loading = false; resetIdleTimer(); } - function handleImgError() { + function handleMediaError() { loading = false; }

@@ -76,6 +88,12 @@ if (bytes < 1024) return bytes + ' B';

if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } + + // Load initial tag details. + $effect(() => { + current.id; + loadTagDetails(); + }); </script> <svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />

@@ -112,7 +130,7 @@ class:controls-hidden={!showControls}

>&rsaquo;</button> {/if} - <!-- Image --> + <!-- Media --> <div class="viewer-content" class:loading> {#if current.mime_type.startsWith('video/')} <!-- svelte-ignore a11y_media_has_caption -->

@@ -121,16 +139,16 @@ src={imageDownloadUrl(current.id)}

controls autoplay class="viewer-media" - onloadeddata={handleImgLoad} - onerror={handleImgError} + onloadeddata={handleMediaReady} + onerror={handleMediaError} ></video> {:else} <img src={imageDownloadUrl(current.id)} alt={current.filename} class="viewer-media" - onload={handleImgLoad} - onerror={handleImgError} + onload={handleMediaReady} + onerror={handleMediaError} /> {/if} </div>

@@ -144,9 +162,6 @@ {current.width}&times;{current.height}

&middot; {formatSize(current.file_size)} &middot; {current.mime_type} </span> - {#if current.tags.length > 0} - <span class="viewer-tags">{current.tags.join(', ')}</span> - {/if} </div> <div class="viewer-info-right"> <a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">

@@ -155,6 +170,22 @@ </a>

<span class="viewer-counter">{currentIndex + 1} / {images.length}</span> </div> </div> + + <!-- Tags bar --> + {#if tagDetails.length > 0} + <div class="viewer-tags" class:controls-hidden={!showControls}> + {#each tagDetails as t (t.tag)} + <button + class="tag-pill" + title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}" + onclick={() => { onclose(); window.location.href = '/browse?tag=' + encodeURIComponent(t.tag); }} + > + <span class="tag-name">{t.tag}</span> + <span class="tag-count">{t.usage_count}</span> + </button> + {/each} + </div> + {/if} </div> <style>

@@ -246,11 +277,11 @@ display: flex;

align-items: center; justify-content: center; max-width: 90vw; - max-height: 85vh; + max-height: 70vh; } .viewer-media { max-width: 90vw; - max-height: 85vh; + max-height: 70vh; object-fit: contain; border-radius: 4px; transition: opacity 0.3s;

@@ -265,7 +296,7 @@ bottom: 0;

left: 0; right: 0; background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); - padding: 2rem 1.5rem 1.25rem; + padding: 2.5rem 1.5rem 1rem; display: flex; justify-content: space-between; align-items: flex-end;

@@ -290,13 +321,6 @@ .viewer-meta {

color: rgba(255, 255, 255, 0.6); font-size: 0.8rem; } - .viewer-tags { - color: rgba(255, 255, 255, 0.5); - font-size: 0.8rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } .viewer-info-right { display: flex; align-items: center;

@@ -326,5 +350,52 @@

.controls-hidden { opacity: 0; pointer-events: none; + } + + /* Tags bar */ + .viewer-tags { + position: absolute; + top: 4.5rem; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem; + max-width: 80vw; + transition: opacity 0.3s; + z-index: 5; + } + .tag-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 24px; + padding: 0.35rem 0.75rem 0.35rem 0.9rem; + font: inherit; + font-size: 0.9rem; + color: #fff; + white-space: nowrap; + backdrop-filter: blur(4px); + cursor: pointer; + transition: background 0.15s; + } + .tag-pill:hover { + background: rgba(255, 255, 255, 0.22); + } + .tag-name { + font-weight: 500; + } + .tag-count { + background: rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 0.1rem 0.45rem; + font-size: 0.78rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + min-width: 1.3rem; + text-align: center; } </style>
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -33,6 +33,15 @@ tag: string;

usage_count: number; } +export interface TagDetail { + tag: string; + usage_count: number; +} + +export interface ImageDetail extends Image { + tags_detail: TagDetail[]; +} + export interface ApiError { error: string; }

@@ -78,8 +87,8 @@ }

return request<ImageList>(`/images?${params}`); } -export async function getImage(id: number): Promise<Image> { - return request<Image>(`/images/${id}`); +export async function getImage(id: number): Promise<ImageDetail> { + return request<ImageDetail>(`/images/${id}`); } export function imageDownloadUrl(id: number): string {
M web/src/routes/browse/+page.svelteweb/src/routes/browse/+page.svelte

@@ -12,7 +12,11 @@ let page = $state(1);

let viewerIndex = $state(-1); const perPage = 60; - onMount(() => loadImages()); + onMount(() => { + const params = new URLSearchParams(window.location.search); + filterTag = params.get('tag') || ''; + loadImages(); + }); async function loadImages() { loading = true;
M web/src/routes/image/[id]/+page.svelteweb/src/routes/image/[id]/+page.svelte

@@ -1,11 +1,11 @@

<script lang="ts"> import { onMount } from 'svelte'; - import { getImage, imageDownloadUrl, thumbnailUrl, type Image } from '$lib/api'; + import { getImage, imageDownloadUrl, thumbnailUrl, type ImageDetail } from '$lib/api'; let { params } = $props(); let id = $derived(parseInt(params.id)); - let img = $state<Image | null>(null); + let img = $state<ImageDetail | null>(null); let loading = $state(true); let error = $state('');

@@ -58,8 +58,15 @@ <dt>Type</dt>

<dd>{img.mime_type}</dd> <dt>Tags</dt> <dd> - {#if img.tags.length > 0} - {img.tags.join(', ')} + {#if img.tags_detail.length > 0} + <div class="tag-pills"> + {#each img.tags_detail as t (t.tag)} + <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}"> + <span class="tag-name">{t.tag}</span> + <span class="tag-count">{t.usage_count}</span> + </a> + {/each} + </div> {:else} <span class="dim">none</span> {/if}

@@ -134,6 +141,43 @@ }

.back { margin-top: 2rem; } + + .tag-pills { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.25rem; + } + .tag-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: #1a1a2e; + border-radius: 20px; + padding: 0.3rem 0.65rem 0.3rem 0.8rem; + font-size: 0.85rem; + color: #eee; + text-decoration: none; + white-space: nowrap; + transition: background 0.15s; + } + .tag-pill:hover { + background: #16213e; + } + .tag-name { + font-weight: 500; + } + .tag-count { + background: rgba(255, 255, 255, 0.15); + border-radius: 10px; + padding: 0.06rem 0.4rem; + font-size: 0.72rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.75); + min-width: 1.2rem; + text-align: center; + } + .error { color: #c0392b; }