feat: add innate tag system for media characteristics Automatically assigns tags based on file type, dimensions, and video duration: "image"/"video"/"gif"/"lowres"/"highres"/">10 sec"/"<10 sec". Stored as JSON in images.innate_tags, searchable via tag filter, clickable in UI with distinguished blue styling, and included in tag autocomplete suggestions.
jump to
@@ -865,3 +865,114 @@ if len(users) != 0 {
t.Fatalf("expected empty, got %v", users) } } + +// ─────────────────────────── Innate Tags ────────────────────── + +func TestHandleUpload_IncludesInnateTags(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "test.jpg") + part.Write(jpegData) + w.Close() + + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + + resp := ww.Result() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String()) + } + + var img db.Image + json.NewDecoder(resp.Body).Decode(&img) + if len(img.InnateTags) == 0 { + t.Fatal("expected non-empty innate_tags on uploaded image") + } + found := false + for _, it := range img.InnateTags { + if it == "image" { + found = true + break + } + } + if !found { + t.Fatalf("expected innate tag 'image', got %v", img.InnateTags) + } +} + +func TestHandleListImages_FilterByInnateTag(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + // Upload an image with tags. + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "sunset.jpg") + part.Write(jpegData) + w.WriteField("tags", "landscape, sunset") + w.Close() + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + if ww.Result().StatusCode != http.StatusCreated { + t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String()) + } + + // Filter by innate tag "image". + listReq := authenticatedRequest(t, "GET", "/api/images?tags=image", token, nil) + listW := httptest.NewRecorder() + a.HandleListImages(listW, listReq) + + resp := listW.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("list status = %d, want 200", resp.StatusCode) + } + + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + images := result["images"].([]any) + if len(images) != 1 { + t.Fatalf("got %d images filtering by innate tag 'image', want 1; response=%v", len(images), result) + } +} + +func TestHandleListImages_InnateTagNoMatch(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "test.jpg") + part.Write(jpegData) + w.Close() + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + if ww.Result().StatusCode != http.StatusCreated { + t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String()) + } + + // Filter by a non-matching innate tag. + listReq := authenticatedRequest(t, "GET", "/api/images?tags=highres", token, nil) + listW := httptest.NewRecorder() + a.HandleListImages(listW, listReq) + + resp := listW.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("list status = %d, want 200", resp.StatusCode) + } + + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + images := result["images"].([]any) + if len(images) != 0 { + t.Fatalf("got %d images filtering by 'highres', want 0; response=%v", len(images), result) + } +}
@@ -99,6 +99,7 @@ "file_size": img.FileSize,
"width": img.Width, "height": img.Height, "mime_type": img.MimeType, + "innate_tags": img.InnateTags, "created_at": img.CreatedAt, "updated_at": img.UpdatedAt, "tags": img.Tags,
@@ -116,6 +116,13 @@ }
} } + // Compute innate tags based on media characteristics. + videoPath := "" + if storage.IsVideo(mimeType) { + videoPath = a.Storage.AbsPath(storagePath) + } + innateTags := storage.ComputeInnateTags(mimeType, width, height, videoPath) + // Create DB record. img, err := a.DB.CreateImage(r.Context(), &db.Image{ Filename: header.Filename,@@ -126,6 +133,7 @@ FileSize: int64(len(data)),
Width: width, Height: height, MimeType: mimeType, + InnateTags: innateTags, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to save metadata")
@@ -640,3 +640,158 @@ }
}) } } + +// ─── Innate Tags ──────────────────────────────────────────────────────── + +func TestCreateImageWithInnateTags(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + u, _ := d.CreateUser(ctx, "innate_user", hashPassword(t, "pw"), false) + + img, err := d.CreateImage(ctx, &Image{ + Filename: "innate.jpg", + StoragePath: "a/b/c/innate.jpg", + UploadedBy: u.ID, + FileSize: 1024, + Width: 1920, + Height: 1080, + MimeType: "image/jpeg", + InnateTags: []string{"image", "highres"}, + }) + if err != nil { + t.Fatalf("CreateImage: %v", err) + } + + if len(img.InnateTags) != 2 { + t.Fatalf("expected 2 innate tags, got %d: %v", len(img.InnateTags), img.InnateTags) + } + if img.InnateTags[0] != "image" || img.InnateTags[1] != "highres" { + t.Fatalf("innate tags = %v, want [image highres]", img.InnateTags) + } +} + +func TestCreateImageWithEmptyInnateTags(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + u, _ := d.CreateUser(ctx, "no_innate", hashPassword(t, "pw"), false) + + img, err := d.CreateImage(ctx, &Image{ + Filename: "plain.jpg", + StoragePath: "a/b/c/plain.jpg", + UploadedBy: u.ID, + FileSize: 1024, + Width: 800, + Height: 600, + MimeType: "image/jpeg", + }) + if err != nil { + t.Fatalf("CreateImage: %v", err) + } + if len(img.InnateTags) != 0 { + t.Fatalf("expected 0 innate tags, got %d: %v", len(img.InnateTags), img.InnateTags) + } +} + +func TestListImages_TagFilterIncludesInnateTags(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + u, _ := d.CreateUser(ctx, "tagger", hashPassword(t, "pw"), false) + + // Image with regular tags only. + img1, _ := d.CreateImage(ctx, &Image{ + Filename: "1.jpg", StoragePath: "a/b/c/1.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + d.SetImageTags(ctx, img1.ID, []string{"landscape"}) + + // Image with innate tags and no user tags. + d.CreateImage(ctx, &Image{ + Filename: "2.jpg", StoragePath: "a/b/c/2.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + InnateTags: []string{"lowres"}, + }) + + // Image with both user tags and innate tags. + img3, _ := d.CreateImage(ctx, &Image{ + Filename: "3.jpg", StoragePath: "a/b/c/3.jpg", + UploadedBy: u.ID, FileSize: 1024, Width: 2560, Height: 1440, MimeType: "image/jpeg", + InnateTags: []string{"highres"}, + }) + d.SetImageTags(ctx, img3.ID, []string{"landscape"}) + + tests := []struct { + name string + tags []string + want int + }{ + {name: "filter by regular tag", tags: []string{"landscape"}, want: 2}, + {name: "filter by innate tag", tags: []string{"lowres"}, want: 1}, + {name: "filter by innate tag highres", tags: []string{"highres"}, want: 1}, + {name: "combined regular+innate tag", tags: []string{"landscape", "highres"}, want: 1}, + {name: "no match", tags: []string{"nonexistent"}, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := d.ListImages(ctx, tt.tags, "", 10, 0) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(result.Images) != tt.want { + t.Errorf("got %d images, want %d; tags=%v", len(result.Images), tt.want, tt.tags) + } + }) + } +} + +func TestListImages_TagFilterInnateAndUploader(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false) + u2, _ := d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false) + + img1, _ := d.CreateImage(ctx, &Image{ + Filename: "a.jpg", StoragePath: "a/b/c/a.jpg", + UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + InnateTags: []string{"lowres"}, + }) + img2, _ := d.CreateImage(ctx, &Image{ + Filename: "b.jpg", StoragePath: "a/b/c/b.jpg", + UploadedBy: u1.ID, FileSize: 1000, Width: 2560, Height: 1440, MimeType: "image/jpeg", + InnateTags: []string{"highres"}, + }) + img3, _ := d.CreateImage(ctx, &Image{ + Filename: "c.jpg", StoragePath: "a/b/c/c.jpg", + UploadedBy: u2.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + InnateTags: []string{"lowres"}, + }) + + d.SetImageTags(ctx, img1.ID, []string{"landscape"}) + d.SetImageTags(ctx, img2.ID, []string{"landscape"}) + d.SetImageTags(ctx, img3.ID, []string{"landscape"}) + + tests := []struct { + name string + tags []string + uploader string + want int + }{ + {name: "lowres+alice", tags: []string{"lowres"}, uploader: "alice", want: 1}, + {name: "highres+alice", tags: []string{"highres"}, uploader: "alice", want: 1}, + {name: "lowres+bob", tags: []string{"lowres"}, uploader: "bob", want: 1}, + {name: "landscape+highres+alice", tags: []string{"landscape", "highres"}, uploader: "alice", want: 1}, + {name: "no match", tags: []string{"highres"}, uploader: "bob", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(result.Images) != tt.want { + t.Errorf("got %d images, want %d; tags=%v uploader=%s", len(result.Images), tt.want, tt.tags, tt.uploader) + } + }) + } +}
@@ -32,6 +32,7 @@ FileSize int64 `json:"file_size"`
Width int `json:"width"` Height int `json:"height"` MimeType string `json:"mime_type"` + InnateTags []string `json:"innate_tags,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Tags []string `json:"tags,omitempty"`
@@ -3,6 +3,7 @@
import ( "context" "database/sql" + "encoding/json" "fmt" "strings" "time"@@ -198,12 +199,15 @@
// CreateImage inserts a new image record. func (d *DB) CreateImage(ctx context.Context, img *Image) (*Image, error) { t := now() + + 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, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file_size, width, height, mime_type, innate_tags, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, img.Filename, img.StoragePath, img.ThumbnailPath, img.UploadedBy, - img.FileSize, img.Width, img.Height, img.MimeType, t, t, + img.FileSize, img.Width, img.Height, img.MimeType, innateTagsJSON, t, t, ) if err != nil { return nil, fmt.Errorf("create image: %w", err)@@ -220,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, - created_at, updated_at + innate_tags, created_at, updated_at FROM images WHERE id = ?`, id) img, err := scanImage(row) if err != nil {@@ -238,14 +242,15 @@ }
// 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. +// 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) { // 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.created_at, i.updated_at + i.innate_tags, i.created_at, i.updated_at FROM images i` var args []any@@ -259,8 +264,13 @@ for j, t := range filterTags {
placeholders[j] = "?" args = append(args, t) } - tagJoin = ` JOIN image_tags it ON i.id = it.image_id` - tagWhere = `it.tag IN (` + strings.Join(placeholders, ",") + `)` + // Union image_tags and innate_tags so both are searchable via tag filter. + tagJoin = ` JOIN ( + 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, ",") + `)` } if filterUploader != "" {@@ -283,7 +293,7 @@ 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 += ` GROUP BY i.id HAVING COUNT(DISTINCT all_tags.tag) = ` + fmt.Sprintf("%d", len(filterTags)) } dataQuery += ` ORDER BY i.created_at DESC LIMIT ? OFFSET ?`@@ -350,17 +360,20 @@ }
func scanImage(s imageScanner) (*Image, error) { var img Image - var thumbnailPath, createdAt, updatedAt sql.NullString + var thumbnailPath, innateTags, createdAt, updatedAt sql.NullString if err := s.Scan( &img.ID, &img.Filename, &img.StoragePath, &thumbnailPath, &img.UploadedBy, &img.FileSize, &img.Width, &img.Height, &img.MimeType, - &createdAt, &updatedAt, + &innateTags, &createdAt, &updatedAt, ); err != nil { return nil, err } if thumbnailPath.Valid { img.ThumbnailPath = &thumbnailPath.String } + if innateTags.Valid { + img.InnateTags = unmarshalStrings(innateTags.String) + } if createdAt.Valid { img.CreatedAt, _ = scanTime(createdAt.String) }@@ -485,8 +498,17 @@ }
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 ?`, - prefix+"%", limit) + `SELECT tag, usage_count FROM ( + SELECT tag, usage_count FROM tags WHERE tag LIKE ? + UNION ALL + SELECT j.value AS tag, COUNT(*) AS usage_count + FROM images i, json_each(COALESCE(i.innate_tags, '[]')) j + WHERE j.value LIKE ? + GROUP BY j.value + ) + ORDER BY usage_count DESC, tag + LIMIT ?`, + prefix+"%", prefix+"%", limit) if err != nil { return nil, fmt.Errorf("suggest tags: %w", err) }@@ -578,3 +600,22 @@ // normalizeTag trims whitespace, lowercases, and collapses internal whitespace.
func normalizeTag(tag string) string { return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(tag))), " ") } + +// marshalStrings marshals a string slice to a JSON array for storage in TEXT columns. +func marshalStrings(s []string) string { + if len(s) == 0 { + return "[]" + } + b, _ := json.Marshal(s) + return string(b) +} + +// unmarshalStrings unmarshals a JSON array from a TEXT column back to a string slice. +func unmarshalStrings(s string) []string { + if s == "" { + return nil + } + var result []string + json.Unmarshal([]byte(s), &result) + return result +}
@@ -133,6 +133,12 @@ sql: `
ALTER TABLE users ADD COLUMN avatar_path TEXT; `, }, + { + version: 8, + sql: ` + ALTER TABLE images ADD COLUMN innate_tags TEXT; + `, + }, } for _, m := range migrations {
@@ -8,6 +8,7 @@ _ "image/gif"
"image/jpeg" _ "image/png" "os/exec" + "strconv" "strings" "golang.org/x/image/draw"@@ -249,3 +250,72 @@ return nil, fmt.Errorf("encode JPEG: %w", err)
} return buf.Bytes(), nil } + +// getVideoDuration is the implementation used by ComputeInnateTags. +// It is a variable so tests can override it. +var getVideoDuration = func(videoPath string) float64 { + cmd := exec.Command("ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "csv=p=0", + videoPath, + ) + out, err := cmd.Output() + if err != nil { + return 0 + } + dur, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + return 0 + } + return dur +} + +// GetVideoDuration returns the duration of a video file in seconds using ffprobe. +// Returns 0 if ffprobe is unavailable or the probe fails. +func GetVideoDuration(videoPath string) float64 { + return getVideoDuration(videoPath) +} + +// ComputeInnateTags determines the set of automatically-assigned tags for a +// piece of media based on its MIME type, dimensions, and (for videos) duration. +// The videoPath argument is used only for video duration probing; pass an empty +// string for images. +func ComputeInnateTags(mimeType string, width, height int, videoPath string) []string { + tags := make([]string, 0, 4) + + isImage := IsImage(mimeType) + isVideo := IsVideo(mimeType) + + if isImage { + tags = append(tags, "image") + } + if isVideo { + tags = append(tags, "video") + } + if mimeType == "image/gif" { + tags = append(tags, "gif") + } + + // Resolution-based tags (images only). + if isImage { + if width < 256 && height < 256 { + tags = append(tags, "lowres") + } + if width > 1920 || height > 1920 { + tags = append(tags, "highres") + } + } + + // Duration-based tags (videos only). + if isVideo && videoPath != "" { + duration := GetVideoDuration(videoPath) + if duration > 10 { + tags = append(tags, ">10 sec") + } else if duration > 0 && duration < 10 { + tags = append(tags, "<10 sec") + } + } + + return tags +}
@@ -6,7 +6,6 @@ "image"
"image/color" "image/jpeg" "image/png" - "path/filepath" "testing" )@@ -192,13 +191,139 @@ func TestFFmpegAvailable(t *testing.T) {
FFmpegAvailable() } -func TestGenerateVideoThumbnail_Skipped(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "test.mp4") - output := filepath.Join(dir, "out.jpg") +func TestComputeInnateTags(t *testing.T) { + tests := []struct { + name string + mimeType string + width int + height int + want []string + }{ + { + name: "JPEG image", + mimeType: "image/jpeg", + width: 800, + height: 600, + want: []string{"image"}, + }, + { + name: "GIF", + mimeType: "image/gif", + width: 800, + height: 600, + want: []string{"image", "gif"}, + }, + { + name: "lowres image", + mimeType: "image/jpeg", + width: 100, + height: 100, + want: []string{"image", "lowres"}, + }, + { + name: "highres image", + mimeType: "image/jpeg", + width: 2560, + height: 1440, + want: []string{"image", "highres"}, + }, + { + name: "both lowres and highres (gif small)", + mimeType: "image/gif", + width: 50, + height: 50, + want: []string{"image", "gif", "lowres"}, + }, + { + name: "video", + mimeType: "video/mp4", + width: 1920, + height: 1080, + want: []string{"video"}, + }, + { + name: "PNG highres", + mimeType: "image/png", + width: 3840, + height: 2160, + want: []string{"image", "highres"}, + }, + } - err := GenerateVideoThumbnail(input, output) - if err == nil { - t.Fatal("expected error for non-existent video") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeInnateTags(tt.mimeType, tt.width, tt.height, "") + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("got[%d] = %q, want %q; full: got=%v want=%v", i, got[i], tt.want[i], got, tt.want) + } + } + }) + } +} + +func TestComputeInnateTags_VideoDuration(t *testing.T) { + tests := []struct { + name string + mimeType string + duration float64 + want []string + }{ + { + name: "short video", + mimeType: "video/mp4", + duration: 5, + want: []string{"video", "<10 sec"}, + }, + { + name: "long video", + mimeType: "video/webm", + duration: 30, + want: []string{"video", ">10 sec"}, + }, + { + name: "exactly 10 seconds - no duration tag", + mimeType: "video/mp4", + duration: 10, + want: []string{"video"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override GetVideoDuration to return our test value. + orig := getVideoDuration + getVideoDuration = func(string) float64 { return tt.duration } + defer func() { getVideoDuration = orig }() + + got := ComputeInnateTags(tt.mimeType, 640, 480, "/fake/path.mp4") + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("got[%d] = %q, want %q; full: got=%v want=%v", i, got[i], tt.want[i], got, tt.want) + } + } + }) + } +} + +func TestComputeInnateTags_EmptyPathNoPanic(t *testing.T) { + // Passing empty videoPath should not cause issues for images. + tags := ComputeInnateTags("image/jpeg", 800, 600, "") + if len(tags) != 1 || tags[0] != "image" { + t.Fatalf("got %v, want [image]", tags) + } +} + +func TestComputeInnateTags_VideoEmptyPath(t *testing.T) { + // Videos with empty path should get no duration tags. + tags := ComputeInnateTags("video/mp4", 640, 480, "") + if len(tags) != 1 || tags[0] != "video" { + t.Fatalf("got %v, want [video]", tags) } }
@@ -16,6 +16,7 @@ let currentIndex = $state(0 + initialIndex);
let loading = $state(true); let showControls = $state(true); let tagDetails = $state<TagDetail[]>([]); + let innateTagList = $state<string[]>([]); let isOwner = $state(false); let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();@@ -31,9 +32,11 @@ async function loadTagDetails() {
try { const detail = await getImage(current.id); tagDetails = detail.tags_detail; + innateTagList = detail.innate_tags || []; isOwner = detail.is_owner; } catch { tagDetails = []; + innateTagList = []; isOwner = false; } }@@ -237,7 +240,7 @@ <span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
</div> </div> - {#if tagDetails.length > 0} + {#if tagDetails.length > 0 || innateTagList.length > 0} <div class="viewer-tags" class:controls-hidden={!showControls}> {#each tagDetails as t (t.tag)} <button@@ -247,6 +250,14 @@ 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} + {#each innateTagList as t (t)} + <button + class="tag-pill innate" + onclick={() => { onclose(); window.location.href = '/browse?tag=' + encodeURIComponent(t); }} + > + <span class="tag-name">{t}</span> </button> {/each} </div>@@ -537,6 +548,14 @@ text-align: center;
} .tag-pill:hover .tag-count { background: rgba(0, 0, 0, 0.25); + } + .tag-pill.innate { + background: rgba(21, 101, 192, 0.2); + border-color: rgba(21, 101, 192, 0.4); + } + .tag-pill.innate:hover { + background: rgba(21, 101, 192, 0.5); + border-color: #1565c0; } .viewer-edit-tags {
@@ -20,6 +20,7 @@ file_size: number;
width: number; height: number; mime_type: string; + innate_tags?: string[]; created_at: string; updated_at: string; tags: string[];
@@ -141,13 +141,20 @@ </div>
<div class="meta-block tags-block"> <span class="meta-label">TAGS</span> <div class="tag-pills"> - {#if img.tags_detail.length > 0} + {#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)} {#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} + {#if img.innate_tags} + {#each img.innate_tags as t (t)} + <a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate"> + <span class="tag-name">{t}</span> + </a> + {/each} + {/if} {:else} <span class="none">none</span> {/if}@@ -327,6 +334,16 @@ text-align: center;
} .tag-pill:hover .tag-count { background: rgba(0, 0, 0, 0.2); + } + .tag-pill.innate { + background: #1565c0; + } + .tag-pill.innate:hover { + background: #1976d2; + text-decoration: none; + } + .tag-pill.innate .tag-count { + display: none; } .none { color: #bbb;