From 949382f28c7447769cfd90fec645f981237bc069 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 13:21:43 +0100 Subject: [PATCH] feat: related-tags atlas in Explore view Hovering a tag in Explore now loads and displays co-occurring tags as a weighted cloud. New `get_related_tags` SQL self-join (db.rs/commands.rs), `loadRelatedTags` store action with per-folder keyed cache, and TagCloud atlas UI with ResizeObserver-driven layout and RAF animation. Explore tag limit raised to 180; tag cloud auto-refreshes 700ms after new AI tagging completes. --- src-tauri/src/commands.rs | 36 ++- src-tauri/src/db.rs | 28 +++ src-tauri/src/lib.rs | 1 + src/components/TagCloud.tsx | 480 +++++++++++++++++++++++++++++------- src/dev/mockBackend.ts | 16 +- src/dev/mockFixtures.ts | 90 ++++++- src/store.ts | 61 ++++- 7 files changed, 608 insertions(+), 104 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 48595ca..445f10c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -180,6 +180,19 @@ pub struct GetExploreTagsParams { pub limit: Option, } +#[derive(Deserialize)] +pub struct GetRelatedTagsParams { + pub tag: String, + pub folder_id: Option, + pub limit: Option, +} + +#[derive(Serialize)] +pub struct RelatedTagEntry { + pub tag: String, + pub shared_count: i64, +} + #[derive(Deserialize)] pub struct SearchTagsAutocompleteParams { pub query: String, @@ -1277,7 +1290,28 @@ pub async fn get_explore_tags( params: GetExploreTagsParams, ) -> Result, String> { let conn = db.get().map_err(|e| e.to_string())?; - db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)) + db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(180)) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_related_tags( + db: State<'_, DbState>, + params: GetRelatedTagsParams, +) -> Result, String> { + let tag = params.tag.trim(); + if tag.is_empty() { + return Ok(vec![]); + } + + let conn = db.get().map_err(|e| e.to_string())?; + db::get_related_tags(&conn, tag, params.folder_id, params.limit.unwrap_or(16)) + .map(|entries| { + entries + .into_iter() + .map(|(tag, shared_count)| RelatedTagEntry { tag, shared_count }) + .collect() + }) .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 361fa85..6e51895 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2436,6 +2436,34 @@ pub fn get_explore_tags( Ok(rows) } +pub fn get_related_tags( + conn: &Connection, + tag: &str, + folder_id: Option, + limit: usize, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT other.tag, COUNT(DISTINCT other.image_id) AS shared_count + FROM image_tags base + JOIN image_tags other ON other.image_id = base.image_id + JOIN images i ON i.id = base.image_id + WHERE base.tag = ?1 + AND other.tag != base.tag + AND (?2 IS NULL OR i.folder_id = ?2) + GROUP BY other.tag + ORDER BY shared_count DESC, other.tag ASC + LIMIT ?3", + )?; + + let rows = stmt + .query_map(params![tag, folder_id, limit as i64], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })? + .collect::>>()?; + + Ok(rows) +} + type FailedEmbeddingRow = (i64, String, String, Option); pub fn get_failed_embedding_images( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 880c95d..c5d167b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -197,6 +197,7 @@ pub fn run() { commands::get_worker_states, commands::get_tag_cloud, commands::get_explore_tags, + commands::get_related_tags, commands::get_images_by_ids, commands::get_failed_embedding_images, commands::get_failed_tagging_images, diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index fc586d3..9b9002a 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -1,6 +1,6 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; -import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; +import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -224,59 +224,361 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag ); } -// Actual tag cloud — word size driven by log-scaled frequency -function TagWord({ - entry, - index, - logMin, - logRange, - onSearch, -}: { +interface AtlasNode { entry: ExploreTagEntry; index: number; - logMin: number; - logRange: number; + x: number; + y: number; + w: number; + h: number; + fontSize: number; + ratio: number; + accent: string; + driftX: number; + driftY: number; +} + +interface TagAnchor { + x: number; + y: number; +} + +const TAG_ATLAS_MAX_VISIBLE = 132; +const TAG_ATLAS_DENSE_THRESHOLD = 120; + +function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] { + if (!entries.length || containerW <= 0 || containerH <= 0) return []; + + const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1))); + const logMin = Math.min(...logs); + const logRange = Math.max(...logs) - logMin || 1; + const cx = containerW / 2; + const cy = containerH * 0.48; + const spreadX = containerW * 0.47; + const spreadY = containerH * 0.46; + const accents = isLight ? LIGHT_ACCENTS : ACCENTS; + + const nodes = entries.map((entry, index) => { + const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange; + const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1; + const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale; + const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2; + const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26); + const w = Math.min(containerW * maxWidthRatio, textWidth); + const h = fontSize * 1.18 + 14; + const radialRatio = Math.sqrt((index + 0.5) / entries.length); + const angle = index * GOLDEN_ANGLE; + return { + entry, + index, + x: cx + Math.cos(angle) * radialRatio * spreadX, + y: cy + Math.sin(angle) * radialRatio * spreadY, + w, + h, + fontSize, + ratio, + accent: accents[index % accents.length], + driftX: (seeded(index + 101) - 0.5) * 7, + driftY: (seeded(index + 113) - 0.5) * 6, + }; + }); + + const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10; + const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7; + const margin = 28; + for (let iter = 0; iter < 140; iter++) { + for (let a = 0; a < nodes.length; a++) { + const na = nodes[a]; + for (let b = a + 1; b < nodes.length; b++) { + const nb = nodes[b]; + const dx = nb.x - na.x; + const dy = nb.y - na.y; + const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx); + const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy); + if (overlapX <= 0 || overlapY <= 0) continue; + if (overlapX < overlapY) { + const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); + nb.x += push; + na.x -= push; + } else { + const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); + nb.y += push; + na.y -= push; + } + } + } + for (const node of nodes) { + node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin); + node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin); + if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) { + node.y += (seeded(node.index + iter + 211) - 0.5) * 2; + } + if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) { + node.x += (seeded(node.index + iter + 223) - 0.5) * 2; + } + } + } + + for (let iter = 0; iter < 5; iter++) { + const weighted = nodes.reduce( + (acc, node) => { + const weight = 1 + Math.pow(node.ratio, 1.35) * 9; + return { + x: acc.x + node.x * weight, + y: acc.y + node.y * weight, + weight: acc.weight + weight, + }; + }, + { x: 0, y: 0, weight: 0 }, + ); + const offsetX = containerW * 0.48 - weighted.x / weighted.weight; + const offsetY = containerH * 0.44 - weighted.y / weighted.weight; + if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break; + for (const node of nodes) { + node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin); + node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin); + } + } + + return nodes; +} + +function TagAtlas({ + entries, + onSearch, + loadRelatedTags, +}: { + entries: ExploreTagEntry[]; onSearch: (tag: string) => void; + loadRelatedTags: (tag: string) => Promise; }) { const theme = useGalleryStore((state) => state.theme); const isLight = theme === "subtle-light"; - const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; - const fontSize = 11 + ratio * 28; // 11px – 39px - const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length]; - const tilt = (seeded(index + 5) - 0.5) * 7; - // Faint low-frequency words read fine as subtle white-on-dark, but the same low - // opacity is unreadable on the light theme's cream, so raise the floor there. - const minOpacity = isLight ? 0.6 : 0.4; + const reducedMotion = useReducedMotion(); + const canvasRef = useRef(null); + const buttonRefs = useRef(new Map()); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + const [activeTag, setActiveTag] = useState(null); + const [relatedTags, setRelatedTags] = useState([]); + const [anchors, setAnchors] = useState>({}); + const visibleEntries = useMemo( + () => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries), + [entries], + ); + + useLayoutEffect(() => { + const el = canvasRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setCanvasSize({ w: r.width, h: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!activeTag) { + setRelatedTags([]); + return; + } + let cancelled = false; + void loadRelatedTags(activeTag).then((entries) => { + if (!cancelled) setRelatedTags(entries); + }); + return () => { + cancelled = true; + }; + }, [activeTag, loadRelatedTags]); + + const nodes = useMemo( + () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight), + [visibleEntries, canvasSize.w, canvasSize.h, isLight], + ); + const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]); + const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined; + const minOpacity = isLight ? 0.62 : 0.42; + const visibleConnections = useMemo( + () => + activeNode + ? relatedTags + .map((related) => { + const node = nodeByTag.get(related.tag); + return node ? { node, related } : null; + }) + .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null) + .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10) + : [], + [activeNode, nodeByTag, relatedTags, visibleEntries.length], + ); + const activeAnchor = activeTag ? anchors[activeTag] : undefined; + const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count)); + const connectedByTag = useMemo( + () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])), + [visibleConnections], + ); + + const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => { + if (element) { + buttonRefs.current.set(tag, element); + } else { + buttonRefs.current.delete(tag); + } + }, []); + + const measureAnchors = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !activeTag) { + setAnchors({}); + return; + } + + const canvasRect = canvas.getBoundingClientRect(); + const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]; + const nextAnchors: Record = {}; + for (const tag of tagsToMeasure) { + const element = buttonRefs.current.get(tag); + if (!element) continue; + const rect = element.getBoundingClientRect(); + nextAnchors[tag] = { + x: rect.left - canvasRect.left + rect.width / 2, + y: rect.top - canvasRect.top + rect.height / 2, + }; + } + setAnchors(nextAnchors); + }, [activeTag, visibleConnections]); + + useLayoutEffect(() => { + if (!activeTag) { + setAnchors({}); + return; + } + + let firstFrame = 0; + let secondFrame = 0; + const settleTimer = window.setTimeout(measureAnchors, 180); + firstFrame = window.requestAnimationFrame(() => { + measureAnchors(); + secondFrame = window.requestAnimationFrame(measureAnchors); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + window.clearTimeout(settleTimer); + }; + }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]); return ( - - onSearch(entry.tag)} - > - 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }} - > - {entry.tag} - - - {entry.count.toLocaleString()} - - - +
+ + {nodes.map((node) => { + const isActive = activeTag === node.entry.tag; + const connectedRelated = connectedByTag.get(node.entry.tag); + const isRelated = connectedRelated !== undefined; + const dimmed = activeTag !== null && !isActive && !isRelated; + const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity); + return ( + + + + ); + })} +
); } @@ -290,6 +592,33 @@ function Spinner() { ); } +function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) { + const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies"; + const subtitle = + mode === "visual" + ? "Grouping similar images into browsable clusters." + : "Collecting tags from the current library scope."; + + return ( +
+
+
+ + {title} +
+

{subtitle}

+
+ +
+
+
+ ); +} + // Separate component so its useLayoutEffect fires when the canvas is actually // mounted — not at TagCloud mount time when the container may still be hidden // behind a loading state. @@ -502,6 +831,7 @@ export function TagCloud() { const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); + const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const searchForTag = useGalleryStore((state) => state.searchForTag); const renameTag = useGalleryStore((state) => state.renameTag); @@ -515,17 +845,10 @@ export function TagCloud() { else void loadExploreTags(); }, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]); - const { logMin, logRange } = useMemo(() => { - if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 }; - const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1))); - const lo = Math.min(...logs); - const hi = Math.max(...logs); - return { logMin: lo, logRange: hi - lo || 1 }; - }, [exploreTagEntries]); - const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading; const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; + const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE); return (
@@ -541,7 +864,9 @@ export function TagCloud() { : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` - : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` + : visibleTagCount < entryCount + ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` + : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` : exploreMode === "visual" ? "No clusters — images need embeddings first" : "No tags — run the AI tagger or add tags manually"} @@ -583,11 +908,8 @@ export function TagCloud() {
- {loading ? ( -
- - {exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"} -
+ {loading && !hasEntries ? ( + ) : !hasEntries ? (

@@ -597,30 +919,20 @@ export function TagCloud() {

) : exploreMode === "visual" ? ( - - ) : manageTags ? ( - - ) : ( - /* Tag cloud — words sized by log-scaled frequency, wrapped freely */ -
-
- {exploreTagEntries.map((entry, index) => ( - - ))} -
+
+
+ ) : manageTags ? ( +
+ +
+ ) : ( + )}
); diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 872d6e6..a4d8ddd 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -241,7 +241,21 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "get_tag_cloud": return db.scenario === "empty" ? [] : db.tagCloud; case "get_explore_tags": - return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 48)); + return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180)); + case "get_related_tags": { + const relatedCounts = new Map(); + for (const imageTags of Object.values(db.tagsByImageId)) { + if (!imageTags.some((tag) => tag.tag === p.tag)) continue; + for (const tag of imageTags) { + if (tag.tag === p.tag) continue; + relatedCounts.set(tag.tag, (relatedCounts.get(tag.tag) ?? 0) + 1); + } + } + return Array.from(relatedCounts.entries()) + .map(([tag, shared_count]) => ({ tag, shared_count })) + .sort((left, right) => right.shared_count - left.shared_count || left.tag.localeCompare(right.tag)) + .slice(0, Number(p.limit ?? 18)); + } case "suggest_image_tags": return ["select", "warm light"]; case "rename_tag": diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index fcb94c1..8cd5e8e 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -57,6 +57,68 @@ const tags = [ "blue hour", "select", ]; + +const hugeTagThemes = [ + "alpine", + "archive", + "botanical", + "cinematic", + "coastal", + "editorial", + "industrial", + "interior", + "macro", + "night", + "portrait", + "street", + "travel", + "urban", + "wildlife", + "workspace", +]; + +const hugeTagSubjects = [ + "glass", + "steel", + "water", + "stone", + "mist", + "foliage", + "market", + "signage", + "neon", + "shadow", + "reflection", + "texture", + "silhouette", + "motion", + "symmetry", + "detail", + "warm light", + "blue hour", + "gold accent", + "soft focus", + "hard light", + "negative space", + "foreground", + "background", + "wide angle", + "close crop", + "environment", + "still life", + "natural light", + "available light", +]; + +const hugeTags = [ + ...tags, + ...Array.from({ length: hugeTagThemes.length * hugeTagSubjects.length }, (_, index) => { + const theme = hugeTagThemes[index % hugeTagThemes.length]; + const subject = hugeTagSubjects[Math.floor(index / hugeTagThemes.length) % hugeTagSubjects.length]; + return `${theme} ${subject}`; + }), + ...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`), +]; const aiRatings: AiRating[] = ["general", "sensitive", "questionable"]; function daysAgo(days: number): string { @@ -136,17 +198,23 @@ function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { return images; } -function makeTags(images: ImageRecord[]): Record { +function tagVocabularyForScenario(scenario: MockScenario): string[] { + return scenario === "huge" ? hugeTags : tags; +} + +function makeTags(images: ImageRecord[], scenario: MockScenario): Record { + const vocabulary = tagVocabularyForScenario(scenario); + const tagCount = scenario === "huge" ? 9 : 3; return Object.fromEntries( images.map((image, index) => [ image.id, - [0, 1, 2].map((offset) => ({ + Array.from({ length: tagCount }, (_, offset) => ({ id: image.id * 10 + offset, image_id: image.id, - tag: tags[(index + offset) % tags.length], + tag: vocabulary[(index * (offset + 3) + offset * 29) % vocabulary.length], source: offset === 0 ? "user" : "ai", ai_model: offset === 0 ? null : "wd-vit-tagger-v3", - confidence: offset === 0 ? null : 0.72 + offset * 0.07, + confidence: offset === 0 ? null : Math.min(0.99, 0.72 + offset * 0.03), created_at: daysAgo(index + offset), })), ]), @@ -197,16 +265,18 @@ function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] { }); } -function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] { - return tags.map((tag, index) => { +function makeExploreTags(images: ImageRecord[], scenario: MockScenario): ExploreTagEntry[] { + const vocabulary = tagVocabularyForScenario(scenario); + return vocabulary.map((tag, index) => { const representative = images[index % Math.max(images.length, 1)]; + const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3; return { tag, - count: Math.max(3, Math.round(images.length / (index + 3))), + count: Math.max(1, Math.round(images.length / divisor)), representative_image_id: representative?.id ?? index + 1, thumbnail_path: representative?.thumbnail_path ?? null, }; - }); + }).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); } function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] { @@ -234,9 +304,9 @@ export function createMockDb(scenario: MockScenario): MockDb { images, albums, albumImageIds, - tagsByImageId: makeTags(images), + tagsByImageId: makeTags(images, scenario), tagCloud: makeTagCloud(images), - exploreTags: makeExploreTags(images), + exploreTags: makeExploreTags(images, scenario), backgroundJobs: makeProgress(folders, scenario), duplicateGroups: makeDuplicateGroups(images, scenario), duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null, diff --git a/src/store.ts b/src/store.ts index 3e92035..e1c9bb3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -217,6 +217,11 @@ export interface ExploreTagEntry { thumbnail_path: string | null; } +export interface RelatedTagEntry { + tag: string; + shared_count: number; +} + export interface DuplicateGroup { file_hash: string; file_size: number; @@ -376,6 +381,7 @@ interface GalleryState { exploreTagEntries: ExploreTagEntry[]; exploreTagLoading: boolean; exploreTagsFolderId: number | null | undefined; + relatedTagsByKey: Record; indexingProgress: Record; mediaJobProgress: Record; cacheDir: string; @@ -480,8 +486,9 @@ interface GalleryState { closeImage: () => void; setView: (view: ActiveView) => void; setExploreMode: (mode: ExploreMode) => void; - loadTagCloud: () => Promise; - loadExploreTags: () => Promise; + loadTagCloud: (options?: { force?: boolean }) => Promise; + loadExploreTags: (options?: { force?: boolean }) => Promise; + loadRelatedTags: (tag: string) => Promise; showVisualCluster: (imageIds: number[]) => Promise; searchForTag: (tag: string) => void; loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise; @@ -615,6 +622,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24; let galleryRequestToken = 0; let tagCloudRequestToken = 0; let exploreTagRequestToken = 0; +let exploreTagRefreshTimer: ReturnType | null = null; function initialAiCaptionsEnabled(): boolean { if (typeof window === "undefined") return false; @@ -862,6 +870,7 @@ export const useGalleryStore = create((set, get) => ({ exploreTagEntries: [], exploreTagLoading: false, exploreTagsFolderId: undefined, + relatedTagsByKey: {}, indexingProgress: {}, mediaJobProgress: {}, cacheDir: "", @@ -1388,10 +1397,11 @@ export const useGalleryStore = create((set, get) => ({ setExploreMode: (exploreMode) => set({ exploreMode }), - loadTagCloud: async () => { + loadTagCloud: async (options) => { const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); + const force = options?.force ?? false; // Skip if already loaded for this folder and not currently loading - if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { + if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { return; } const requestToken = ++tagCloudRequestToken; @@ -1409,19 +1419,20 @@ export const useGalleryStore = create((set, get) => ({ } }, - loadExploreTags: async () => { + loadExploreTags: async (options) => { const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); - if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { + const force = options?.force ?? false; + if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { return; } const requestToken = ++exploreTagRequestToken; set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); try { const entries = await invoke("get_explore_tags", { - params: { folder_id: selectedFolderId, limit: 48 }, + params: { folder_id: selectedFolderId, limit: 180 }, }); if (requestToken !== exploreTagRequestToken) return; - set({ exploreTagEntries: entries, exploreTagLoading: false }); + set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} }); } catch (error) { if (requestToken !== exploreTagRequestToken) return; console.error("Failed to load explore tags:", error); @@ -1888,6 +1899,28 @@ export const useGalleryStore = create((set, get) => ({ void invoke("set_notifications_paused", { paused }).catch(() => {}); }, + loadRelatedTags: async (tag) => { + const trimmed = tag.trim(); + if (!trimmed) return []; + + const { selectedFolderId, relatedTagsByKey } = get(); + const key = `${selectedFolderId ?? "all"}:${trimmed}`; + if (relatedTagsByKey[key]) { + return relatedTagsByKey[key]; + } + + const entries = await invoke("get_related_tags", { + params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 }, + }); + set((state) => ({ + relatedTagsByKey: { + ...state.relatedTagsByKey, + [key]: entries, + }, + })); + return entries; + }, + setTheme: (theme) => { window.localStorage.setItem(THEME_KEY, theme); document.documentElement.dataset.theme = theme; @@ -2887,6 +2920,18 @@ export const useGalleryStore = create((set, get) => ({ const unlistenThumbnails = await listen("media-updated", (event) => { const batch = event.payload; + const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); + if (taggingUpdated) { + set({ exploreTagsFolderId: undefined }); + if (get().activeView === "explore") { + if (!exploreTagRefreshTimer) { + exploreTagRefreshTimer = setTimeout(() => { + exploreTagRefreshTimer = null; + void get().loadExploreTags({ force: true }); + }, 700); + } + } + } set((state) => { const selectedImageUpdate =