all repos — weimar @ 9b2b569d75975b2ae6868e6708a18ee78a305125

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

feat: add gem system — like/react with diamond button, sort and filter

Adds image_gems table (migration 10), ToggleGem/GetUserGemStatus queries,
gem_count column on images, HandleToggleGem API, and frontend gem button
centered at bottom of viewer. Browse page has GEMS↓ sort and GEMMED filter.
Maxwell Jensen maxwelljensen@posteo.net
Thu, 14 May 2026 17:59:00 +0200
commit

9b2b569d75975b2ae6868e6708a18ee78a305125

parent

453d1fa348ab96e179dc278cf0d13e8f9d0c7121

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

@@ -11,6 +11,8 @@

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

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

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

@@ -86,6 +88,12 @@

authUser := UserFromContext(r.Context()) isOwner := authUser != nil && authUser.ID == img.UploadedBy + // Check gem status for authenticated users. + gemmed := false + if authUser != nil { + gemmed, _ = a.DB.GetUserGemStatus(r.Context(), id, authUser.ID) + } + writeJSON(w, http.StatusOK, map[string]any{ "id": img.ID, "filename": img.Filename,

@@ -100,10 +108,47 @@ "width": img.Width,

"height": img.Height, "mime_type": img.MimeType, "innate_tags": img.InnateTags, + "duration": img.Duration, + "gem_count": img.GemCount, + "gemmed": gemmed, "created_at": img.CreatedAt, "updated_at": img.UpdatedAt, "tags": img.Tags, "tags_detail": tagsDetail, + }) +} + +// HandleToggleGem toggles the gem status for an image by the current user. +// Returns {"gemmed": bool, "gem_count": int}. +func (a *API) HandleToggleGem(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid image id") + return + } + + // Verify image exists. + if _, err := a.DB.GetImageByID(r.Context(), id); err != nil { + writeError(w, http.StatusNotFound, "image not found") + return + } + + gemmed, gemCount, err := a.DB.ToggleGem(r.Context(), id, user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to toggle gem") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "gemmed": gemmed, + "gem_count": gemCount, }) }
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, false, false) 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, false, false) if len(list.Images) != 2 { t.Fatalf("expected 2 images, got %d", len(list.Images)) }

@@ -523,7 +523,7 @@ }

for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := d.ListImages(ctx, nil, tt.uploader, 10, 0) + result, err := d.ListImages(ctx, nil, tt.uploader, 10, 0, false, false) if err != nil { t.Fatalf("ListImages: %v", err) }

@@ -576,7 +576,7 @@ }

for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0) + result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0, false, false) if err != nil { t.Fatalf("ListImages: %v", err) }

@@ -733,7 +733,7 @@ }

for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := d.ListImages(ctx, tt.tags, "", 10, 0) + result, err := d.ListImages(ctx, tt.tags, "", 10, 0, false, false) if err != nil { t.Fatalf("ListImages: %v", err) }

@@ -785,7 +785,7 @@ }

for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0) + result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0, false, false) if err != nil { t.Fatalf("ListImages: %v", err) }
M internal/db/models.gointernal/db/models.go

@@ -34,6 +34,8 @@ Height int `json:"height"`

MimeType string `json:"mime_type"` InnateTags []string `json:"innate_tags,omitempty"` Duration float64 `json:"duration,omitempty"` + GemCount int `json:"gem_count,omitempty"` + Gemmed bool `json:"gemmed,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Tags []string `json:"tags,omitempty"`
M internal/db/queries.gointernal/db/queries.go

@@ -204,10 +204,10 @@ innateTagsJSON := marshalStrings(img.InnateTags)

res, err := d.db.ExecContext(ctx, `INSERT INTO images (filename, storage_path, thumbnail_path, uploaded_by, - file_size, width, height, mime_type, innate_tags, duration, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file_size, width, height, mime_type, innate_tags, duration, gem_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, img.Filename, img.StoragePath, img.ThumbnailPath, img.UploadedBy, - img.FileSize, img.Width, img.Height, img.MimeType, innateTagsJSON, img.Duration, t, t, + img.FileSize, img.Width, img.Height, img.MimeType, innateTagsJSON, img.Duration, 0, t, t, ) if err != nil { return nil, fmt.Errorf("create image: %w", err)

@@ -224,7 +224,7 @@ func (d *DB) GetImageByID(ctx context.Context, id int64) (*Image, error) {

row := d.db.QueryRowContext(ctx, `SELECT id, filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, - innate_tags, duration, created_at, updated_at + innate_tags, duration, gem_count, created_at, updated_at FROM images WHERE id = ?`, id) img, err := scanImage(row) if err != nil {

@@ -243,20 +243,21 @@

// 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). Tag filtering also matches innate tags stored in the -// innate_tags JSON column. Order is newest-first by created_at. -func (d *DB) ListImages(ctx context.Context, filterTags []string, filterUploader string, limit, offset int) (*ImageList, error) { +// innate_tags JSON column. If sortGems is true, results are ordered by +// gem_count DESC then created_at DESC. If filterGems is true, only images +// with gem_count > 0 are returned. +func (d *DB) ListImages(ctx context.Context, filterTags []string, filterUploader string, limit, offset int, sortGems, filterGems bool) (*ImageList, error) { // Count query countQuery := `SELECT COUNT(DISTINCT i.id) FROM images i` // Data query dataQuery := `SELECT i.id, i.filename, i.storage_path, i.thumbnail_path, i.uploaded_by, i.file_size, i.width, i.height, i.mime_type, - i.innate_tags, i.duration, i.created_at, i.updated_at + i.innate_tags, i.duration, i.gem_count, i.created_at, i.updated_at FROM images i` var args []any var tagJoin string - var uploaderWhere string - var tagWhere string + var conditions []string if len(filterTags) > 0 { placeholders := make([]string, len(filterTags))

@@ -270,23 +271,22 @@ SELECT image_id, tag FROM image_tags

UNION ALL SELECT i.id, j.value FROM images i, json_each(COALESCE(i.innate_tags, '[]')) j ) all_tags ON i.id = all_tags.image_id` - tagWhere = `all_tags.tag IN (` + strings.Join(placeholders, ",") + `)` + conditions = append(conditions, `all_tags.tag IN (`+strings.Join(placeholders, ",")+`)`) } if filterUploader != "" { - uploaderWhere = `i.uploaded_by = (SELECT id FROM users WHERE username = ?)` + conditions = append(conditions, `i.uploaded_by = (SELECT id FROM users WHERE username = ?)`) args = append(args, filterUploader) + } + + if filterGems { + conditions = append(conditions, `i.gem_count > 0`) } // 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 + whereClause := strings.Join(conditions, " AND ") + if whereClause != "" { + whereClause = ` WHERE ` + whereClause } countQuery += tagJoin + whereClause

@@ -296,7 +296,14 @@ if len(filterTags) > 0 {

dataQuery += ` GROUP BY i.id HAVING COUNT(DISTINCT all_tags.tag) = ` + fmt.Sprintf("%d", len(filterTags)) } - dataQuery += ` ORDER BY i.created_at DESC LIMIT ? OFFSET ?` + // ORDER BY. + if sortGems { + dataQuery += ` ORDER BY i.gem_count DESC, i.created_at DESC` + } else { + dataQuery += ` ORDER BY i.created_at DESC` + } + + dataQuery += ` LIMIT ? OFFSET ?` argsData := make([]any, len(args)) copy(argsData, args) argsData = append(argsData, limit, offset)

@@ -360,7 +367,7 @@ func (d *DB) ListAllImages(ctx context.Context) ([]Image, error) {

rows, err := d.db.QueryContext(ctx, `SELECT id, filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, - innate_tags, duration, created_at, updated_at + innate_tags, duration, gem_count, created_at, updated_at FROM images ORDER BY id`) if err != nil { return nil, fmt.Errorf("list all images: %w", err)

@@ -396,6 +403,61 @@ width, height, duration, now(), id)

return err } +// ─────────────────────────── Gems ─────────────────────────── + +// ToggleGem adds or removes a gem from an image for a user. +// Returns the new gem status (true=gemmed) and updated gem count. +func (d *DB) ToggleGem(ctx context.Context, imageID, userID int64) (bool, int, error) { + var count int + err := d.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM image_gems WHERE image_id = ? AND user_id = ?`, + imageID, userID).Scan(&count) + if err != nil { + return false, 0, fmt.Errorf("check gem: %w", err) + } + + if count > 0 { + _, err = d.db.ExecContext(ctx, + `DELETE FROM image_gems WHERE image_id = ? AND user_id = ?`, + imageID, userID) + if err != nil { + return false, 0, fmt.Errorf("remove gem: %w", err) + } + _, err = d.db.ExecContext(ctx, + `UPDATE images SET gem_count = MAX(0, gem_count - 1), updated_at = ? WHERE id = ?`, + now(), imageID) + if err != nil { + return false, 0, fmt.Errorf("decrement gem count: %w", err) + } + } else { + _, err = d.db.ExecContext(ctx, + `INSERT INTO image_gems (image_id, user_id, created_at) VALUES (?, ?, ?)`, + imageID, userID, now()) + if err != nil { + return false, 0, fmt.Errorf("add gem: %w", err) + } + _, err = d.db.ExecContext(ctx, + `UPDATE images SET gem_count = gem_count + 1, updated_at = ? WHERE id = ?`, + now(), imageID) + if err != nil { + return false, 0, fmt.Errorf("increment gem count: %w", err) + } + } + + var gemCount int + d.db.QueryRowContext(ctx, `SELECT gem_count FROM images WHERE id = ?`, imageID).Scan(&gemCount) + return count == 0, gemCount, nil +} + +// GetUserGemStatus returns true if the user has gemmed the specified image. +func (d *DB) GetUserGemStatus(ctx context.Context, imageID, userID int64) (bool, error) { + var count int + err := d.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM image_gems WHERE image_id = ? AND user_id = ?`, + imageID, userID).Scan(&count) + return count > 0, err +} + type imageScanner interface { Scan(dest ...any) error }

@@ -407,7 +469,7 @@ var duration sql.NullFloat64

if err := s.Scan( &img.ID, &img.Filename, &img.StoragePath, &thumbnailPath, &img.UploadedBy, &img.FileSize, &img.Width, &img.Height, &img.MimeType, - &innateTags, &duration, &createdAt, &updatedAt, + &innateTags, &duration, &img.GemCount, &createdAt, &updatedAt, ); err != nil { return nil, err }
M internal/db/sqlite.gointernal/db/sqlite.go

@@ -145,6 +145,19 @@ sql: `

ALTER TABLE images ADD COLUMN duration REAL DEFAULT 0; `, }, + { + version: 10, + sql: ` + CREATE TABLE IF NOT EXISTS image_gems ( + image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + PRIMARY KEY (image_id, user_id) + ); + ALTER TABLE images ADD COLUMN gem_count INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_image_gems_user ON image_gems(user_id); + `, + }, } for _, m := range migrations {
M internal/server/server.gointernal/server/server.go

@@ -65,6 +65,9 @@ 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)) + // Gems (authenticated). + mux.HandleFunc("POST /api/images/{id}/gem", s.requireAuth(h.HandleToggleGem)) + // Tags (public for browse suggestions). mux.HandleFunc("GET /api/tags/suggest", s.requireAuthOrPublic(h.HandleTagSuggest))
M web/src/lib/ImageViewer.svelteweb/src/lib/ImageViewer.svelte

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

<script lang="ts"> - import { imageDownloadUrl, getImage, addTags, removeTags, suggestTags, type Image, type TagDetail } from '$lib/api'; + import { imageDownloadUrl, getImage, addTags, removeTags, suggestTags, toggleGem, type Image, type TagDetail } from '$lib/api'; import Avatar from '$lib/Avatar.svelte'; let {

@@ -18,6 +18,8 @@ let showControls = $state(true);

let tagDetails = $state<TagDetail[]>([]); let innateTagList = $state<string[]>([]); let isOwner = $state(false); + let gemmed = $state(false); + let gemCount = $state(0); let idleTimer: ReturnType<typeof setTimeout> | undefined = $state(); let tagInput = $state('');

@@ -34,11 +36,25 @@ try {

const detail = await getImage(current.id); tagDetails = detail.tags_detail; innateTagList = detail.innate_tags || []; + gemmed = detail.gemmed || false; + gemCount = detail.gem_count || 0; isOwner = detail.is_owner; } catch { tagDetails = []; innateTagList = []; + gemmed = false; + gemCount = 0; isOwner = false; + } + } + + async function handleToggleGem() { + try { + const result = await toggleGem(current.id); + gemmed = result.gemmed; + gemCount = result.gem_count; + } catch (err) { + console.error('gem toggle failed:', err); } }

@@ -251,6 +267,15 @@ <span class="viewer-counter">{currentIndex + 1} / {images.length}</span>

</div> </div> + <div class="viewer-gem" class:controls-hidden={!showControls}> + <button class="gem-btn" class:gemmed onclick={handleToggleGem} aria-label="Toggle gem"> + <svg class="gem-icon" viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2"> + <polygon points="6,3 18,3 22,9 12,21 2,9" stroke-linejoin="round"/> + </svg> + <span class="gem-count">{gemCount}</span> + </button> + </div> + {#if tagDetails.length > 0 || innateTagList.length > 0} <div class="viewer-tags" class:controls-hidden={!showControls}> {#each tagDetails as t (t.tag)}

@@ -493,6 +518,58 @@ display: flex;

align-items: center; gap: 1rem; flex-shrink: 0; + } + + .viewer-gem { + position: absolute; + bottom: 5.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 15; + transition: opacity 0.3s; + pointer-events: none; + } + .gem-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + background: rgba(0, 0, 0, 0.55); + border: 2px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.7); + padding: 0.8rem 1rem 0.6rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + font: inherit; + line-height: 1; + pointer-events: auto; + } + .gem-btn:hover { + background: rgba(0, 0, 0, 0.75); + border-color: rgba(255, 255, 255, 0.5); + color: #fff; + } + .gem-btn.gemmed { + border-color: #c62828; + color: #c62828; + background: rgba(198, 40, 40, 0.2); + } + .gem-btn.gemmed:hover { + background: rgba(198, 40, 40, 0.35); + } + .gem-icon { + display: block; + transition: transform 0.15s; + } + .gem-btn:hover .gem-icon { + transform: scale(1.15); + } + .gem-count { + font-family: 'Oswald', sans-serif; + font-size: 1rem; + font-weight: 600; + min-width: 1.2rem; + text-align: center; } .viewer-download { display: inline-block;
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -22,6 +22,8 @@ height: number;

mime_type: string; innate_tags?: string[]; duration?: number; + gem_count?: number; + gemmed?: boolean; created_at: string; updated_at: string; tags: string[];

@@ -91,7 +93,7 @@ export async function logout(): Promise<void> {

await request('/auth/logout', { method: 'POST' }); } -export async function listImages(tags: string[] = [], uploader = '', page = 1, perPage = 50): Promise<ImageList> { +export async function listImages(tags: string[] = [], uploader = '', page = 1, perPage = 50, sortGems = false, filterGems = false): Promise<ImageList> { const params = new URLSearchParams(); params.set('page', String(page)); params.set('per_page', String(perPage));

@@ -101,6 +103,12 @@ }

if (uploader) { params.set('uploader', uploader); } + if (sortGems) { + params.set('sort', 'gems'); + } + if (filterGems) { + params.set('gems', 'true'); + } return request<ImageList>(`/images?${params}`); }

@@ -166,3 +174,9 @@ method: 'DELETE',

body: JSON.stringify({ tags }), }); } + +export async function toggleGem(imageId: number): Promise<{ gemmed: boolean; gem_count: number }> { + return request<{ gemmed: boolean; gem_count: number }>(`/images/${imageId}/gem`, { + method: 'POST', + }); +}
M web/src/routes/browse/+page.svelteweb/src/routes/browse/+page.svelte

@@ -12,6 +12,8 @@ let filterTag = $state('');

let filterUploader = $state(''); let page = $state(1); let viewerIndex = $state(-1); + let sortGems = $state(false); + let gemsOnly = $state(false); const perPage = 60; let tagSuggestions = $state<Tag[]>([]);

@@ -60,7 +62,7 @@ loading = true;

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

@@ -281,6 +283,8 @@ <button class="btn" onclick={applyFilter}>FILTER</button>

{#if filterTag || filterUploader} <button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button> {/if} + <button class="btn" class:btn-active={sortGems} onclick={() => { sortGems = !sortGems; applyFilter(); }} style="min-width:80px">GEMS&darr;</button> + <button class="btn" class:btn-active={gemsOnly} onclick={() => { gemsOnly = !gemsOnly; applyFilter(); }} style="min-width:80px">GEMMED</button> </div> </div>

@@ -482,6 +486,9 @@ .btn-clear {

background: #666; } .btn-clear:hover { + background: #c62828; + } + .btn-active { background: #c62828; }
M web/src/routes/image/[id]/+page.svelteweb/src/routes/image/[id]/+page.svelte

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

<script lang="ts"> import { onMount } from 'svelte'; - import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api'; + import { getImage, addTags, removeTags, suggestTags, toggleGem, imageDownloadUrl, type ImageDetail } from '$lib/api'; let { params } = $props(); let id = $derived(parseInt(params.id));

@@ -219,9 +219,25 @@ {/if}

</div> {/if} - <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}> - DOWNLOAD ORIGINAL - </a> + <div class="meta-actions"> + <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}> + DOWNLOAD ORIGINAL + </a> + <button class="gem-btn" class:gemmed={img.gemmed} onclick={async () => { + try { + const r = await toggleGem(img.id); + img.gemmed = r.gemmed; + img.gem_count = r.gem_count; + } catch (err) { + console.error('gem toggle failed:', err); + } + }} aria-label="Toggle gem"> + <svg class="gem-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"> + <polygon points="6,3 18,3 22,9 12,21 2,9" stroke-linejoin="round"/> + </svg> + <span class="gem-count">{img.gem_count || 0}</span> + </button> + </div> </div> </div>

@@ -403,6 +419,47 @@ }

.btn:hover { background: #b71c1c; text-decoration: none; + } + .meta-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + } + .gem-btn { + display: flex; + align-items: center; + gap: 0.25rem; + background: none; + border: 2px solid #1a1a1a; + color: #1a1a1a; + padding: 0.45rem 0.6rem 0.45rem 0.5rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + font: inherit; + line-height: 1; + } + .gem-btn:hover { + border-color: #c62828; + color: #c62828; + } + .gem-btn.gemmed { + border-color: #c62828; + color: #c62828; + background: rgba(198,40,40,0.08); + } + .gem-btn.gemmed:hover { + background: rgba(198,40,40,0.15); + } + .gem-icon { + display: block; + } + .gem-count { + font-family: 'Oswald', sans-serif; + font-size: 0.85rem; + font-weight: 600; + min-width: 1rem; + text-align: center; } .back {