diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx index 10d18c1d13..8a143a039e 100644 --- a/frontend/apps/artcraft-webapp/src/app/app.tsx +++ b/frontend/apps/artcraft-webapp/src/app/app.tsx @@ -183,8 +183,9 @@ export function App() { } /> } /> } /> - {/* `:slug` is a media-class filter (images/videos/meshes) OR a - folder token (prefixed `folder_`); the page disambiguates. */} + {/* `:slug` is a media-class filter (images/videos/meshes), a + folder token (prefixed `folder_`), the static `tags` tab, or + a tag token (prefixed `tag_`); the page disambiguates. */} } /> } /> } /> diff --git a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx index dbd0a7f55a..9fcf08b820 100644 --- a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx +++ b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx @@ -34,6 +34,7 @@ import { getProviderIconByName, } from "@storyteller/model-list"; import { downloadMediaFile } from "../../lib/download-media"; +import { TagsSection } from "../tags/TagsSection"; import { formatAspectRatio, formatDuration, @@ -430,6 +431,8 @@ export function LightboxDetails({ )} + + )} diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx index f8752e0f88..cb794eb485 100644 --- a/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx +++ b/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx @@ -1,14 +1,17 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBorderAll, + faChevronRight, faFolder, faFolderOpen, faPlus, faStar, } from "@fortawesome/pro-solid-svg-icons"; import { compareFolders } from "@storyteller/ui-gallery-modal"; +import { EASE_EMPHASIS } from "../../lib/motion"; import { SidebarGroup, SidebarGroupContent, @@ -18,6 +21,7 @@ import { SidebarMenuItem, } from "../ui/sidebar"; import { useLibraryFoldersStore } from "../../pages/library/library-folders-store"; +import { LibraryTagsNav } from "./library-tags-nav"; /** * The "Library" sidebar section: Unsorted + Folders entries, plus the user's @@ -36,17 +40,23 @@ export function LibraryFoldersNav({ pathname === "/library/folders" || pathname.startsWith("/library/folder_"); const onFolderless = pathname === "/library/folderless"; + const onTags = + pathname === "/library/tags" || pathname.startsWith("/library/tag_"); const onUnsorted = !onFolders && !onFolderless && + !onTags && (pathname === "/library" || pathname.startsWith("/library/")); - const inLibraryArea = onUnsorted || onFolders || onFolderless; + const inLibraryArea = onUnsorted || onFolders || onFolderless || onTags; const folders = useLibraryFoldersStore((s) => s.folders); const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId); const openNewFolderModal = useLibraryFoldersStore((s) => s.openNewFolderModal); const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu); + // Folders are the primary organization tool — default expanded. + const [expanded, setExpanded] = useState(true); + const rootFolders = useMemo( () => folders.filter((f) => !f.parentId).sort(compareFolders), [folders], @@ -118,46 +128,74 @@ export function LibraryFoldersNav({ Folders + {inLibraryArea && rootFolders.length > 0 && ( + + )} - {inLibraryArea && - rootFolders.map((folder) => ( - + {inLibraryArea && expanded && rootFolders.length > 0 && ( + - goToFolder(folder.id)} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ - folderId: folder.id, - x: e.clientX, - y: e.clientY, - }); - }} - className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground" - > - - {folder.name} - {folder.hasStar && ( - - )} - - - ))} +
    + {rootFolders.map((folder) => ( + + goToFolder(folder.id)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ + folderId: folder.id, + x: e.clientX, + y: e.clientY, + }); + }} + className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground" + > + + {folder.name} + {folder.hasStar && ( + + )} + + + ))} +
+ + )} + + + diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/library-tags-nav.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/library-tags-nav.tsx new file mode 100644 index 0000000000..1dc3c5cb63 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/components/sidebar/library-tags-nav.tsx @@ -0,0 +1,146 @@ +import { useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faChevronRight, + faEllipsis, + faTag, + faTags, +} from "@fortawesome/pro-solid-svg-icons"; +import { SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { + compareTagsByUseCount, + useLibraryTagsStore, +} from "../../pages/library/library-tags-store"; +import { EASE_EMPHASIS } from "../../lib/motion"; + +const MAX_SIDEBAR_TAGS = 10; + +/** + * The "Tags" rows of the Assets sidebar group, rendered below the folders + * list: a Tags entry plus the user's most-used tags (collapsed by default) + * while in the library area. Hidden entirely until the user has tags — the + * tag editor in the media details panel is where tags get created. + * Right-click opens the library page's tag context menu (same contract as + * the folder rows). Renders bare menu items; the parent supplies the + * `SidebarMenu` list. + */ +export function LibraryTagsNav({ + pathname, + onNavClick, +}: { + pathname: string; + onNavClick: () => void; +}) { + const navigate = useNavigate(); + const tags = useLibraryTagsStore((s) => s.tags); + const tagsLoaded = useLibraryTagsStore((s) => s.tagsLoaded); + const setContextMenu = useLibraryTagsStore((s) => s.setContextMenu); + + // Folders are the primary organization tool — tags start collapsed. + const [expanded, setExpanded] = useState(false); + + const inLibraryArea = + pathname === "/library" || pathname.startsWith("/library/"); + + const topTags = useMemo( + () => [...tags].sort(compareTagsByUseCount).slice(0, MAX_SIDEBAR_TAGS), + [tags], + ); + + if (!tagsLoaded || tags.length === 0) return null; + + return ( + <> + + + + + Tags + + + {inLibraryArea && topTags.length > 0 && ( + + )} + + + + {inLibraryArea && expanded && topTags.length > 0 && ( + +
    + {topTags.map((tag) => ( + + { + navigate(`/library/${tag.token}`); + onNavClick(); + }} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ + tagToken: tag.token, + x: e.clientX, + y: e.clientY, + }); + }} + className="pl-5" + > + + {tag.value} + + {tag.useCount} + + + {/* Hover-revealed rename/delete menu (swaps with the count). */} + + + ))} +
+
+ )} +
+ + ); +} diff --git a/frontend/apps/artcraft-webapp/src/components/tags/TagChipInput.tsx b/frontend/apps/artcraft-webapp/src/components/tags/TagChipInput.tsx new file mode 100644 index 0000000000..3d5dcf38ea --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/components/tags/TagChipInput.tsx @@ -0,0 +1,214 @@ +import { useMemo, useRef, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTag, faXmark } from "@fortawesome/pro-solid-svg-icons"; + +export interface TagSuggestion { + value: string; + useCount: number; +} + +interface TagChipInputProps { + /** Current tag values, in display casing. */ + chips: string[]; + /** The user's existing tags, offered as autocomplete while typing. */ + suggestions: TagSuggestion[]; + /** Read-only mode: chips render without remove affordance or input. */ + disabled?: boolean; + onAdd: (values: string[]) => void; + onRemove: (value: string) => void; +} + +const MAX_SUGGESTIONS = 8; + +/** + * Removable tag chips with an inline autocomplete input. Enter / comma / blur + * commit the draft, pasting comma-separated text adds every entry, Backspace + * on an empty draft removes the last chip. Duplicates (case-insensitive) are + * rejected locally with a brief flash on the existing chip. + */ +export function TagChipInput({ + chips, + suggestions, + disabled, + onAdd, + onRemove, +}: TagChipInputProps) { + const [draft, setDraft] = useState(""); + const [focused, setFocused] = useState(false); + // -1 = free-typing (Enter commits the draft, not a suggestion). + const [highlightIndex, setHighlightIndex] = useState(-1); + const [flashValue, setFlashValue] = useState(null); + const inputRef = useRef(null); + const flashTimeout = useRef | undefined>( + undefined, + ); + + const chipsLower = useMemo( + () => new Set(chips.map((c) => c.toLowerCase())), + [chips], + ); + + const filtered = useMemo(() => { + const query = draft.trim().toLowerCase(); + if (!query) return []; + return suggestions + .filter( + (s) => + s.value.toLowerCase().includes(query) && + !chipsLower.has(s.value.toLowerCase()), + ) + .slice(0, MAX_SUGGESTIONS); + }, [draft, suggestions, chipsLower]); + + const dropdownOpen = focused && filtered.length > 0; + const highlight = Math.min(highlightIndex, filtered.length - 1); + + const flashChip = (value: string) => { + clearTimeout(flashTimeout.current); + setFlashValue(value); + flashTimeout.current = setTimeout(() => setFlashValue(null), 800); + }; + + // Commit raw text: split on commas, trim, reject values already present + // (flashing the existing chip so the rejection is visible). + const commit = (raw: string) => { + const values = raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + const fresh: string[] = []; + const seen = new Set(); + for (const value of values) { + const lower = value.toLowerCase(); + if (chipsLower.has(lower)) { + flashChip(chips.find((c) => c.toLowerCase() === lower) ?? value); + continue; + } + if (seen.has(lower)) continue; + seen.add(lower); + fresh.push(value); + } + if (fresh.length > 0) onAdd(fresh); + setDraft(""); + setHighlightIndex(-1); + }; + + const pickSuggestion = (value: string) => { + onAdd([value]); + setDraft(""); + setHighlightIndex(-1); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + if (dropdownOpen && highlight >= 0) { + pickSuggestion(filtered[highlight].value); + } else if (draft.trim()) { + commit(draft); + } + } else if (e.key === "Backspace" && draft === "" && chips.length > 0) { + onRemove(chips[chips.length - 1]); + } else if (e.key === "ArrowDown" && dropdownOpen) { + e.preventDefault(); + setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp" && dropdownOpen) { + e.preventDefault(); + setHighlightIndex((i) => Math.max(i - 1, -1)); + } else if (e.key === "Escape") { + setHighlightIndex(-1); + inputRef.current?.blur(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const text = e.clipboardData.getData("text"); + if (text.includes(",")) { + e.preventDefault(); + commit(draft + text); + } + }; + + const chipClass = (value: string) => + `flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[11px] font-medium text-white/80 transition-all ${ + flashValue === value ? "ring-2 ring-primary" : "" + }`; + + return ( +
+
inputRef.current?.focus()} + > + {chips.map((value) => + disabled ? ( + + + {value} + + ) : ( + + ), + )} + {!disabled && ( + { + setDraft(e.target.value); + setHighlightIndex(-1); + }} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + if (draft.trim()) commit(draft); + }} + placeholder={chips.length === 0 ? "Add tags (comma separated)" : "Add tag"} + className="min-w-[7rem] flex-1 bg-transparent py-0.5 text-sm text-white placeholder:text-white/40 focus:outline-none" + /> + )} +
+ + {dropdownOpen && ( +
+ {filtered.map((suggestion, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/components/tags/TagsSection.tsx b/frontend/apps/artcraft-webapp/src/components/tags/TagsSection.tsx new file mode 100644 index 0000000000..3371adeac4 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/components/tags/TagsSection.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTag } from "@fortawesome/pro-solid-svg-icons"; +import { TagsApi, type TagDetails, type UserInfo } from "@storyteller/api"; +import { toast } from "../toast/toast"; +import { useSession } from "../../lib/session"; +import { + compareTagsByUseCount, + useLibraryTagsStore, +} from "../../pages/library/library-tags-store"; +import { TagChipInput } from "./TagChipInput"; + +interface TagsSectionProps { + mediaToken?: string | null; + creator?: UserInfo | null; +} + +/** A queued save: the full desired tag set for one media file. */ +interface PendingSave { + token: string; + values: string[]; +} + +/** + * The "Tags" section of the media details panel. Fetches the file's tags, + * lets the owner edit them as chips (with autocomplete over their existing + * tags), and renders read-only chips for everyone else. Hidden entirely for + * non-owners when the file has no tags. + * + * Saves send one snapshot `SetMediaFileTags` per commit, serialized and + * coalesced: while a call is in flight only the latest desired tag set is + * remembered, so rapid edits collapse into a single follow-up request and + * add/remove can never interleave. + */ +export function TagsSection({ mediaToken, creator }: TagsSectionProps) { + const tagsApi = useMemo(() => new TagsApi(), []); + const { user, loggedIn } = useSession(); + const isOwner = + loggedIn && !!creator?.username && creator.username === user?.username; + + // null until the first fetch resolves — the section doesn't render (and + // can't be edited) before we know the file's current tags. + const [tags, setTags] = useState(null); + const mediaTokenRef = useRef(mediaToken); + mediaTokenRef.current = mediaToken; + /** Last server-confirmed tag set — the revert target on save failure. */ + const lastAckedRef = useRef([]); + const pendingRef = useRef(null); + const savingRef = useRef(false); + + const storeTags = useLibraryTagsStore((s) => s.tags); + const loadTags = useLibraryTagsStore((s) => s.loadTags); + const upsertTags = useLibraryTagsStore((s) => s.upsertTags); + + useEffect(() => { + setTags(null); + lastAckedRef.current = []; + pendingRef.current = null; + if (!mediaToken) return; + let cancelled = false; + tagsApi.ListMediaFileTags({ mediaFileToken: mediaToken }).then((res) => { + if (cancelled) return; + const fetched = res.success && res.data ? res.data : []; + setTags(fetched); + lastAckedRef.current = fetched; + }); + return () => { + cancelled = true; + }; + }, [mediaToken, tagsApi]); + + // Preload the user's tag list once for autocomplete (client-side filtering; + // no per-keystroke network). + useEffect(() => { + if (isOwner && !useLibraryTagsStore.getState().tagsLoaded) loadTags(); + }, [isOwner, loadTags]); + + const suggestions = useMemo( + () => + [...storeTags] + .sort(compareTagsByUseCount) + .map((t) => ({ value: t.value, useCount: t.useCount })), + [storeTags], + ); + + const runQueue = async () => { + savingRef.current = true; + while (pendingRef.current) { + const { token, values } = pendingRef.current; + pendingRef.current = null; + const res = await tagsApi.SetMediaFileTags({ + mediaFileToken: token, + tags: values, + }); + if (res.success && res.data) { + // Fresh use counts for the sidebar / autocomplete. + upsertTags(res.data.tags); + if (mediaTokenRef.current === token) { + lastAckedRef.current = res.data.tags; + // Don't clobber newer optimistic chips a queued edit represents. + if (!pendingRef.current) setTags(res.data.tags); + } + } else { + toast.error(res.errorMessage || "Failed to save tags."); + if (mediaTokenRef.current === token) { + pendingRef.current = null; + setTags(lastAckedRef.current); + } + } + } + savingRef.current = false; + }; + + const enqueueSave = (values: string[]) => { + if (!mediaToken) return; + pendingRef.current = { token: mediaToken, values }; + if (!savingRef.current) void runQueue(); + }; + + const handleAdd = (values: string[]) => { + const next = [ + ...(tags ?? []), + ...values.map((value) => ({ + tag_token: "", + tag_value: value, + tag_value_lowercase: value.toLowerCase(), + use_count: 0, + })), + ]; + setTags(next); + enqueueSave(next.map((t) => t.tag_value)); + }; + + const handleRemove = (value: string) => { + const next = (tags ?? []).filter((t) => t.tag_value !== value); + setTags(next); + enqueueSave(next.map((t) => t.tag_value)); + }; + + const handleClear = () => { + setTags([]); + enqueueSave([]); + }; + + if (!mediaToken || tags === null) return null; + if (!isOwner && tags.length === 0) return null; + + return ( +
+
+
+ + Tags +
+ {isOwner && tags.length >= 2 && ( + + )} +
+ t.tag_value)} + suggestions={suggestions} + disabled={!isOwner} + onAdd={handleAdd} + onRemove={handleRemove} + /> +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx index 0d1b87f01f..a334e031b8 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx @@ -32,13 +32,15 @@ const ROUTE_CRUMBS: Record = { }; function resolveCrumbs(pathname: string): Crumb[] { - // Library has sub-tabs (Unsorted / Folders) that live under /library/* rather - // than being separate top-level pages. + // Library has sub-tabs (Unsorted / Folders / Tags) that live under /library/* + // rather than being separate top-level pages. if (pathname === "/library" || pathname.startsWith("/library/")) { const onFolders = pathname === "/library/folders" || pathname.startsWith("/library/folder_"); const onFolderless = pathname === "/library/folderless"; + const onTags = + pathname === "/library/tags" || pathname.startsWith("/library/tag_"); return [ { label: "Library", href: "/library" }, { @@ -46,7 +48,9 @@ function resolveCrumbs(pathname: string): Crumb[] { ? "Folders" : onFolderless ? "Unfoldered" - : "All Assets", + : onTags + ? "Tags" + : "All Assets", }, ]; } diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts b/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts index 738a8c4ba4..66c5184d76 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts +++ b/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts @@ -1,19 +1,14 @@ import { create } from "zustand"; -import { - FoldersApi, - MediaFilesApi, - type FolderInfo, - type FolderMediaFileListItem, -} from "@storyteller/api"; +import { FoldersApi, MediaFilesApi, type FolderInfo } from "@storyteller/api"; import type { GalleryItem } from "@storyteller/ui-gallery-modal"; import { mapFolderInfo, galleryItemToCollageUrl, mergeCollageUrls, } from "@storyteller/ui-gallery-modal"; -import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common"; -import { is3DMediaClass } from "@storyteller/ui-generation-list"; import { toast } from "../../components/toast/toast"; +import { errMsg, mapLeanListItemToGalleryItem } from "./library-media-map"; +import { useLibraryTagsStore } from "./library-tags-store"; // ── Bulk selection store ──────────────────────────────────────────────────── // Kept in its own module store (not page state) so each gallery tile can @@ -134,67 +129,6 @@ const FOLDER_PAGE_SIZE = 60; const folderCursors: Record = {}; const folderInFlight: Record = {}; -const getLabel = (item: any): string => { - if (item.maybe_title) return item.maybe_title; - switch (item.media_class) { - case "image": - return "Image Generation"; - case "video": - return "Video Generation"; - case "dimensional": - case "mesh": - return "3D Mesh"; - case "splat": - return "3D World"; - default: - return "Generation"; - } -}; - -/** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */ -export function mapRawToGalleryItem(item: any): GalleryItem { - const thumbnail = is3DMediaClass(item.media_class) - ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) - : getMediaThumbnail(item.media_links, item.media_class, { - size: THUMBNAIL_SIZES.LARGE, - }); - return { - id: item.token, - label: getLabel(item), - thumbnail, - thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template, - fullImage: item.media_links?.cdn_url ?? null, - createdAt: item.created_at, - mediaClass: item.media_class || "image", - // The user-media list carries `origin_category`; the folderless list - // carries `is_user_upload` instead. - isUpload: item.origin_category === "upload" || item.is_user_upload === true, - batchImageToken: item.maybe_batch_token, - }; -} - -/** Map a `FolderMediaFileListItem` → GalleryItem (folder view; carries media_links inline). */ -function mapFolderListItemToGalleryItem( - item: FolderMediaFileListItem, -): GalleryItem { - const thumbnail = is3DMediaClass(item.media_class) - ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) - : getMediaThumbnail(item.media_links, item.media_class, { - size: THUMBNAIL_SIZES.LARGE, - }); - return { - id: item.token, - label: getLabel(item), - thumbnail, - thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template ?? undefined, - fullImage: item.media_links?.cdn_url ?? null, - createdAt: item.created_at, - mediaClass: item.media_class || "image", - isUpload: !!item.is_user_upload, - batchImageToken: item.maybe_batch_token ?? undefined, - }; -} - // Shared API-folder → UI-folder mapper (UiFolder ≡ GalleryFolder; coalesce // parentId so the optional GalleryFolder field satisfies UiFolder). const mapFolder = (f: FolderInfo): UiFolder => { @@ -202,9 +136,6 @@ const mapFolder = (f: FolderInfo): UiFolder => { return { ...g, parentId: g.parentId ?? null }; }; -const errMsg = (err: unknown) => - err instanceof Error ? err.message : String(err); - // NB: only `collageUrls` is touched — `coverUrl` is the user's *custom* cover // (`maybe_custom_cover_thumbnail`) and must never be derived from the collage, // or the chip would render one full-bleed image instead of the 2×2 grid. @@ -309,7 +240,7 @@ export const useLibraryFoldersStore = create( const nextCursor = listRes.pagination?.maybe_cursor ?? undefined; folderCursors[folderId] = nextCursor; // The list item carries media_links/cover, so map directly — no batch-get. - const ordered = listRes.data.map(mapFolderListItemToGalleryItem); + const ordered = listRes.data.map(mapLeanListItemToGalleryItem); set((s) => { const existing = reset ? [] : (s.folderMediaItems[folderId] ?? []); const seen = new Set(existing.map((i) => i.id)); @@ -571,7 +502,7 @@ export const useLibraryFoldersStore = create( }), ); -/** Delete a media file and drop it from any cached folder views. */ +/** Delete a media file and drop it from any cached folder / tag views. */ export async function deleteLibraryMedia(mediaToken: string): Promise { try { const res = await mediaFilesApi.DeleteMediaFileByToken({ @@ -586,6 +517,13 @@ export async function deleteLibraryMedia(mediaToken: string): Promise { } return { folderMediaItems: next }; }); + useLibraryTagsStore.setState((s) => { + const next: Record = {}; + for (const [k, items] of Object.entries(s.tagMediaItems)) { + next[k] = items.filter((it) => it.id !== mediaToken); + } + return { tagMediaItems: next }; + }); } return res.success; } catch (err) { diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library-media-map.ts b/frontend/apps/artcraft-webapp/src/pages/library/library-media-map.ts new file mode 100644 index 0000000000..47dc413ccc --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/library/library-media-map.ts @@ -0,0 +1,77 @@ +import type { + FolderMediaFileListItem, + TagMediaFileListItem, +} from "@storyteller/api"; +import type { GalleryItem } from "@storyteller/ui-gallery-modal"; +import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common"; +import { is3DMediaClass } from "@storyteller/ui-generation-list"; + +// Media-row → GalleryItem mappers shared by the library page and its folder / +// tag stores (kept out of the stores so they can't import each other). + +/** Rows from the lean folder- and tag-scoped media listings (same wire shape). */ +type LeanMediaListItem = FolderMediaFileListItem | TagMediaFileListItem; + +const getLabel = (item: any): string => { + if (item.maybe_title) return item.maybe_title; + switch (item.media_class) { + case "image": + return "Image Generation"; + case "video": + return "Video Generation"; + case "dimensional": + case "mesh": + return "3D Mesh"; + case "splat": + return "3D World"; + default: + return "Generation"; + } +}; + +/** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */ +export function mapRawToGalleryItem(item: any): GalleryItem { + const thumbnail = is3DMediaClass(item.media_class) + ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) + : getMediaThumbnail(item.media_links, item.media_class, { + size: THUMBNAIL_SIZES.LARGE, + }); + return { + id: item.token, + label: getLabel(item), + thumbnail, + thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template, + fullImage: item.media_links?.cdn_url ?? null, + createdAt: item.created_at, + mediaClass: item.media_class || "image", + // The user-media list carries `origin_category`; the folderless list + // carries `is_user_upload` instead. + isUpload: item.origin_category === "upload" || item.is_user_upload === true, + batchImageToken: item.maybe_batch_token, + }; +} + +/** Map a lean folder/tag list row → GalleryItem (carries media_links inline). */ +export function mapLeanListItemToGalleryItem( + item: LeanMediaListItem, +): GalleryItem { + const thumbnail = is3DMediaClass(item.media_class) + ? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null) + : getMediaThumbnail(item.media_links, item.media_class, { + size: THUMBNAIL_SIZES.LARGE, + }); + return { + id: item.token, + label: getLabel(item), + thumbnail, + thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template ?? undefined, + fullImage: item.media_links?.cdn_url ?? null, + createdAt: item.created_at, + mediaClass: item.media_class || "image", + isUpload: !!item.is_user_upload, + batchImageToken: item.maybe_batch_token ?? undefined, + }; +} + +export const errMsg = (err: unknown): string => + err instanceof Error ? err.message : String(err); diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library-tags-store.ts b/frontend/apps/artcraft-webapp/src/pages/library/library-tags-store.ts new file mode 100644 index 0000000000..6099af122b --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/library/library-tags-store.ts @@ -0,0 +1,210 @@ +import { create } from "zustand"; +import { TagsApi, type TagDetails } from "@storyteller/api"; +import type { GalleryItem } from "@storyteller/ui-gallery-modal"; +import { toast } from "../../components/toast/toast"; +import { errMsg, mapLeanListItemToGalleryItem } from "./library-media-map"; + +// ── Types ────────────────────────────────────────────────────────────────── + +/** Tag shape the UI navigates + renders by. */ +export interface UiTag { + token: string; + value: string; + /** Lowercased value — the tag's unique key within the account. */ + valueLower: string; + /** How many media files currently carry this tag. */ + useCount: number; +} + +interface LibraryTagsState { + tags: UiTag[]; + tagsLoaded: boolean; + activeTagToken: string | null; + /** Resolved media items per tag, cached so reopening is instant. */ + tagMediaItems: Record; + tagContentLoading: boolean; + /** Bottom spinner while paginating the open tag's media. */ + tagLoadingMore: boolean; + /** Whether the open tag has more media pages to load. */ + tagHasMore: Record; + // Dialog state — rendered by the library page, triggered from sidebar or page. + renameTarget: string | null; + contextMenu: { tagToken: string; x: number; y: number } | null; + + loadTags: () => Promise; + setActiveTag: (token: string | null) => void; + loadTagMedia: (tagToken: string, reset?: boolean) => Promise; + renameTag: (tagToken: string, newValue: string) => Promise; + deleteTag: (tagToken: string) => Promise; + /** Merge canonical tags returned by add/set calls (fresh use counts). */ + upsertTags: (tags: TagDetails[]) => void; + setRenameTarget: (tagToken: string | null) => void; + setContextMenu: ( + menu: { tagToken: string; x: number; y: number } | null, + ) => void; +} + +// ── Singletons + mappers ──────────────────────────────────────────────────── + +const tagsApi = new TagsApi(); + +// Tag media is paginated via cursor; one scroll page at a time. +const TAG_PAGE_SIZE = 60; +// Non-reactive per-tag cursor + in-flight guard (singleton store). +const tagCursors: Record = {}; +const tagInFlight: Record = {}; + +const mapTag = (t: TagDetails): UiTag => ({ + token: t.tag_token, + value: t.tag_value, + valueLower: t.tag_value_lowercase, + useCount: t.use_count, +}); + +/** Sort by use count (desc), ties alphabetically — sidebar / "Most used" order. */ +export const compareTagsByUseCount = (a: UiTag, b: UiTag): number => + b.useCount - a.useCount || a.valueLower.localeCompare(b.valueLower); + +// ── Store ─────────────────────────────────────────────────────────────────── + +export const useLibraryTagsStore = create((set, get) => ({ + tags: [], + tagsLoaded: false, + activeTagToken: null, + tagMediaItems: {}, + tagContentLoading: false, + tagLoadingMore: false, + tagHasMore: {}, + renameTarget: null, + contextMenu: null, + + loadTags: async () => { + try { + const all: TagDetails[] = []; + let cursor: string | undefined = undefined; + for (let page = 0; page < 50; page++) { + const res = await tagsApi.ListTags({ cursor }); + if (!res.success || !res.data) break; + all.push(...res.data); + const next = res.pagination?.maybe_cursor; + if (!next) break; + cursor = next ?? undefined; + } + set({ tags: all.map(mapTag), tagsLoaded: true }); + } catch (err) { + console.error("Failed to load tags:", err); + set({ tagsLoaded: true }); + } + }, + + setActiveTag: (token) => { + set({ activeTagToken: token, contextMenu: null }); + if (token) get().loadTagMedia(token, true); + }, + + // Resolve a tag's media one cursor page at a time. `reset` starts over. + loadTagMedia: async (tagToken, reset = false) => { + if (tagInFlight[tagToken]) return; + if (!reset && get().tagHasMore[tagToken] === false) return; + tagInFlight[tagToken] = true; + if (reset) { + tagCursors[tagToken] = undefined; + set((s) => ({ + tagContentLoading: true, + tagHasMore: { ...s.tagHasMore, [tagToken]: true }, + })); + } else { + set({ tagLoadingMore: true }); + } + try { + const listRes = await tagsApi.ListMediaFilesWithTag({ + tagToken, + cursor: reset ? undefined : tagCursors[tagToken], + limit: TAG_PAGE_SIZE, + }); + if (!listRes.success || !listRes.data) return; + const nextCursor = listRes.pagination?.maybe_cursor ?? undefined; + tagCursors[tagToken] = nextCursor; + const ordered = listRes.data.map(mapLeanListItemToGalleryItem); + set((s) => { + const existing = reset ? [] : (s.tagMediaItems[tagToken] ?? []); + const seen = new Set(existing.map((i) => i.id)); + const merged = [...existing, ...ordered.filter((i) => !seen.has(i.id))]; + return { + tagMediaItems: { ...s.tagMediaItems, [tagToken]: merged }, + tagHasMore: { ...s.tagHasMore, [tagToken]: !!nextCursor }, + }; + }); + } catch (err) { + console.error("Failed to load tag media:", err); + } finally { + tagInFlight[tagToken] = false; + set({ tagContentLoading: false, tagLoadingMore: false }); + } + }, + + renameTag: async (tagToken, newValue) => { + const trimmed = newValue.trim(); + if (!trimmed) return; + set((s) => ({ + tags: s.tags.map((t) => + t.token === tagToken + ? { ...t, value: trimmed, valueLower: trimmed.toLowerCase() } + : t, + ), + })); + try { + const res = await tagsApi.RenameTag({ tagToken, newTagValue: trimmed }); + if (!res.success) { + toast.error(res.errorMessage || "Failed to rename tag."); + get().loadTags(); + } + } catch (err) { + toast.error(`Failed to rename tag: ${errMsg(err)}`); + get().loadTags(); + } + }, + + deleteTag: async (tagToken) => { + // Optimistic: drop the tag and forget its media cache. Files keep living + // in the library — only the links go away. + set((s) => { + const nextMedia = { ...s.tagMediaItems }; + delete nextMedia[tagToken]; + const nextHasMore = { ...s.tagHasMore }; + delete nextHasMore[tagToken]; + return { + tags: s.tags.filter((t) => t.token !== tagToken), + tagMediaItems: nextMedia, + tagHasMore: nextHasMore, + activeTagToken: + s.activeTagToken === tagToken ? null : s.activeTagToken, + }; + }); + try { + const res = await tagsApi.DeleteTag({ tagToken }); + if (!res.success) { + toast.error(res.errorMessage || "Failed to delete tag."); + get().loadTags(); + } + } catch (err) { + toast.error(`Failed to delete tag: ${errMsg(err)}`); + get().loadTags(); + } + }, + + upsertTags: (incoming) => { + if (incoming.length === 0) return; + set((s) => { + const byToken = new Map(s.tags.map((t) => [t.token, t])); + for (const raw of incoming) { + byToken.set(raw.tag_token, mapTag(raw)); + } + return { tags: Array.from(byToken.values()) }; + }); + }, + + setRenameTarget: (tagToken) => + set({ renameTarget: tagToken, contextMenu: null }), + setContextMenu: (menu) => set({ contextMenu: menu }), +})); diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx index 3141638b9b..faf3b78d33 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx @@ -48,15 +48,25 @@ import { faPlus, faXmark, faStar, + faTag, + faTags, + faEllipsis, + faCloud, + faList, } from "@fortawesome/pro-solid-svg-icons"; import { Lightbox } from "../../components/lightbox/lightbox"; import { useLibraryFoldersStore, useLibrarySelectionStore, - mapRawToGalleryItem, deleteLibraryMedia, type UiFolder, } from "./library-folders-store"; +import { mapRawToGalleryItem } from "./library-media-map"; +import { + compareTagsByUseCount, + useLibraryTagsStore, + type UiTag, +} from "./library-tags-store"; const PAGE_SIZE = 60; @@ -152,25 +162,30 @@ const GRID_CLASS = // ── Component ────────────────────────────────────────────────────────────── export default function Library() { - // `:slug` is either a media-class filter (images/videos/meshes) or a folder - // token (prefixed `folder_`). `/library/folders` (static) has no slug. + // `:slug` is either a media-class filter (images/videos/meshes), a folder + // token (prefixed `folder_`), the static `tags` tab, or a tag token + // (prefixed `tag_`). `/library/folders` (static) has no slug. const { slug } = useParams<{ slug?: string }>(); const { pathname } = useLocation(); const navigate = useNavigate(); const folderToken = slug?.startsWith("folder_") ? slug : undefined; - const filterParam = slug && !folderToken ? slug : undefined; + const tagToken = slug?.startsWith("tag_") ? slug : undefined; + const onTagsRoute = slug === "tags" || !!tagToken; + const filterParam = slug && !folderToken && !onTagsRoute ? slug : undefined; const activeFilter = filterParam ? (ROUTE_TO_FILTER[filterParam] ?? "all") : "all"; - // Top-level tab derived from the route: All Assets (flat library), - // Folders, or Unfoldered (files in no folder at all). + // Top-level tab derived from the route: All Assets (flat library), Folders, + // Unfoldered (files in no folder at all), or Tags. const onFoldersRoute = pathname === "/library/folders" || !!folderToken; const onFolderlessRoute = slug === "folderless"; - const tab: "unsorted" | "folders" | "folderless" = onFoldersRoute + const tab: "unsorted" | "folders" | "folderless" | "tags" = onFoldersRoute ? "folders" : onFolderlessRoute ? "folderless" - : "unsorted"; + : onTagsRoute + ? "tags" + : "unsorted"; const [username, setUsername] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(null); @@ -241,6 +256,39 @@ export default function Library() { const setRenameTarget = useLibraryFoldersStore((s) => s.setRenameTarget); const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu); + // ── Tags store ──────────────────────────────────────────────────────────── + const tags = useLibraryTagsStore((s) => s.tags); + const tagsLoaded = useLibraryTagsStore((s) => s.tagsLoaded); + const activeTagToken = useLibraryTagsStore((s) => s.activeTagToken); + const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems); + const tagContentLoading = useLibraryTagsStore((s) => s.tagContentLoading); + const tagLoadingMore = useLibraryTagsStore((s) => s.tagLoadingMore); + const loadTags = useLibraryTagsStore((s) => s.loadTags); + const setActiveTag = useLibraryTagsStore((s) => s.setActiveTag); + const loadTagMedia = useLibraryTagsStore((s) => s.loadTagMedia); + const renameTagAction = useLibraryTagsStore((s) => s.renameTag); + const deleteTagAction = useLibraryTagsStore((s) => s.deleteTag); + const tagRenameTarget = useLibraryTagsStore((s) => s.renameTarget); + const tagContextMenu = useLibraryTagsStore((s) => s.contextMenu); + const setTagRenameTarget = useLibraryTagsStore((s) => s.setRenameTarget); + const setTagContextMenu = useLibraryTagsStore((s) => s.setContextMenu); + + const activeTag = activeTagToken + ? (tags.find((t) => t.token === activeTagToken) ?? null) + : null; + + // All-tags view mode + sort; local state, not routes. The cloud is the + // default — size encodes use count, so the sort toggle only applies to list. + const [tagView, setTagView] = useState<"cloud" | "list">("cloud"); + const [tagSort, setTagSort] = useState<"count" | "name">("count"); + const sortedTags = useMemo( + () => + tagSort === "count" + ? [...tags].sort(compareTagsByUseCount) + : [...tags].sort((a, b) => a.valueLower.localeCompare(b.valueLower)), + [tags, tagSort], + ); + const activeFolder = activeFolderId ? (folders.find((f) => f.id === activeFolderId) ?? null) : null; @@ -292,10 +340,14 @@ export default function Library() { })(); }, []); - // Load the folder tree once we know who the user is. + // Load the folder tree + tag list once we know who the user is (the tag + // list also feeds the sidebar nav and the editor's autocomplete). useEffect(() => { - if (username) loadFolders(); - }, [username, loadFolders]); + if (username) { + loadFolders(); + loadTags(); + } + }, [username, loadFolders, loadTags]); // The URL owns *which* folder is open (`/library/:token`); mirror it into the // store. Read via getState() + inequality guard so this never loops. @@ -306,6 +358,14 @@ export default function Library() { } }, [folderToken, setActiveFolder]); + // Same for the open tag (`/library/tag_…`). + useEffect(() => { + const target = tagToken ?? null; + if (useLibraryTagsStore.getState().activeTagToken !== target) { + setActiveTag(target); + } + }, [tagToken, setActiveTag]); + // ── Root media loading (library view, no folder open) ───────────────────── const loadItems = useCallback( async (reset = false) => { @@ -359,8 +419,7 @@ export default function Library() { meshSplatCursorRef.current = response.pagination?.maybe_next ?? undefined; setHasMore( - newItems.length >= PAGE_SIZE && - !!response.pagination?.maybe_next, + newItems.length >= PAGE_SIZE && !!response.pagination?.maybe_next, ); } } else { @@ -401,13 +460,22 @@ export default function Library() { setInitialLoading(false); isLoadingRef.current = false; }, - [username, activeFilter, pageIndex, api, mediaFilesApi, foldersApi, folderlessClass], + [ + username, + activeFilter, + pageIndex, + api, + mediaFilesApi, + foldersApi, + folderlessClass, + ], ); // Initial load + filter / tab change useEffect(() => { if (!username) return; - if (tab === "folders") return; // Folder views load via the folders store. + // Folder / tag views load via their own stores. + if (tab === "folders" || tab === "tags") return; setAllItems([]); setPageIndex(0); meshSplatCursorRef.current = undefined; @@ -430,7 +498,9 @@ export default function Library() { : (scroller as HTMLElement); const scrollBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (scrollBottom >= 500) return; - if (activeFolderId) { + if (activeTagToken) { + loadTagMedia(activeTagToken, false); + } else if (activeFolderId) { loadFolderMedia(activeFolderId, false); } else if ( (tab === "unsorted" || tab === "folderless") && @@ -442,12 +512,22 @@ export default function Library() { }; scroller.addEventListener("scroll", handleScroll, { passive: true }); return () => scroller.removeEventListener("scroll", handleScroll); - }, [activeFolderId, tab, hasMore, loadItems, loadFolderMedia]); + }, [ + activeFolderId, + activeTagToken, + tab, + hasMore, + loadItems, + loadFolderMedia, + loadTagMedia, + ]); // ── Drag media → folder ─────────────────────────────────────────────────── - const displayItems = activeFolderId - ? (folderMediaItems[activeFolderId] ?? []) - : allItems; + const displayItems = activeTagToken + ? (tagMediaItems[activeTagToken] ?? []) + : activeFolderId + ? (folderMediaItems[activeFolderId] ?? []) + : allItems; const displayItemsRef = useRef(displayItems); displayItemsRef.current = displayItems; @@ -789,7 +869,7 @@ export default function Library() { const handleItemDeleted = useCallback((id: string) => { setAllItems((prev) => prev.filter((item) => item.id !== id)); useLibrarySelectionStore.getState().removeIds([id]); - // Also drop it from any cached folder views (e.g. deleted via the lightbox). + // Also drop it from any cached folder/tag views (e.g. deleted via the lightbox). useLibraryFoldersStore.setState((s) => { const next: Record = {}; for (const [k, items] of Object.entries(s.folderMediaItems)) { @@ -797,6 +877,13 @@ export default function Library() { } return { folderMediaItems: next }; }); + useLibraryTagsStore.setState((s) => { + const next: Record = {}; + for (const [k, items] of Object.entries(s.tagMediaItems)) { + next[k] = items.filter((it) => it.id !== id); + } + return { tagMediaItems: next }; + }); }, []); const handleCardClick = useCallback((item: GalleryItem) => { @@ -875,6 +962,38 @@ export default function Library() { }); }; + // ── Tag dialog handlers ─────────────────────────────────────────────────── + const submitTagRename = (name: string) => { + if (tagRenameTarget) renameTagAction(tagRenameTarget, name); + setTagRenameTarget(null); + }; + + const confirmDeleteTag = (tagTokenToDelete: string) => { + const tag = tags.find((t) => t.token === tagTokenToDelete); + const count = tag?.useCount ?? 0; + showActionReminder({ + reminderType: "default", + title: `Delete tag "${tag?.value ?? "tag"}"?`, + message: ( +

+ Removes this tag from {count} file{count === 1 ? "" : "s"}. Files stay + in your library. +

+ ), + primaryActionText: "Delete", + secondaryActionText: "Cancel", + primaryActionBtnClassName: "bg-red text-white hover:bg-red/90", + onPrimaryAction: async () => { + try { + await deleteTagAction(tagTokenToDelete); + if (tagToken === tagTokenToDelete) navigate("/library/tags"); + } finally { + isActionReminderOpen.value = false; + } + }, + }); + }; + // ── Not logged in / loading auth ────────────────────────────────────────── if (isLoggedIn === false) { return ( @@ -1051,6 +1170,27 @@ export default function Library() { /> Unfoldered + + {tab === "tags" && ( + + )} + + Tags + {(tab === "unsorted" || tab === "folderless") && ( + ))} + + )} +
+ {( + [ + ["cloud", faCloud, "Cloud"], + ["list", faList, "List"], + ] as const + ).map(([id, icon, label]) => ( + + ))} +
+ + )} @@ -1180,6 +1393,42 @@ export default function Library() { )} + + {/* Breadcrumb (inside a tag) */} + {tab === "tags" && activeTagToken && ( +
+ + / +

+ {activeTag?.value ?? "Tag"} +

+ {activeTag && ( + + {activeTag.useCount} file + {activeTag.useCount === 1 ? "" : "s"} + + )} + + +
+ )} @@ -1272,6 +1521,105 @@ export default function Library() { )} ) + ) : tab === "tags" && !activeTagToken ? ( + /* ── Tags tab: all tags ── */ + !tagsLoaded ? ( +
+ +
+ ) : tags.length === 0 ? ( +
+
+ +
+

No tags yet.

+

+ Open any image or video and add tags in the details panel — + they'll show up here. +

+
+ ) : tagView === "cloud" ? ( + navigate(`/library/${token}`)} + onMenu={(cloudTagToken, x, y) => + setTagContextMenu({ tagToken: cloudTagToken, x, y }) + } + /> + ) : ( +
+ {sortedTags.map((t) => ( +
+ + +
+ ))} +
+ ) + ) : tab === "tags" && activeTagToken ? ( + /* ── Inside a tag ── */ + tagContentLoading && displayItems.length === 0 ? ( +
+ +
+ ) : displayItems.length === 0 ? ( +
+

+ No files carry this tag. +

+
+ ) : ( + <> + {mediaGrid} + {tagLoadingMore && ( +
+ +
+ )} + + ) ) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
setRenameTarget(null)} /> + {/* Rename tag dialog */} + t.token === tagRenameTarget)?.value ?? "" + } + confirmLabel="Rename" + onConfirm={submitTagRename} + onClose={() => setTagRenameTarget(null)} + /> + {/* Folder context menu (portaled) */} {contextMenu && createPortal( @@ -1496,6 +1856,133 @@ export default function Library() { , document.body, )} + + {/* Tag context menu (portaled) */} + {tagContextMenu && + createPortal( + <> +
setTagContextMenu(null)} + onContextMenu={(e) => { + e.preventDefault(); + setTagContextMenu(null); + }} + /> +
+ + +
+ , + document.body, + )} +
+ ); +} + +// ── Tag cloud ───────────────────────────────────────────────────────────────── +// The default all-tags view: font size and emphasis encode use count, and a +// deterministic hash shuffle spreads big words among small ones (a stable +// order — no reflow between renders) for the classic word-cloud look. + +const hashString = (s: string): number => { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +}; + +function TagCloud({ + tags, + onOpen, + onMenu, +}: { + tags: UiTag[]; + onOpen: (token: string) => void; + onMenu: (tagToken: string, x: number, y: number) => void; +}) { + const words = useMemo(() => { + const counts = tags.map((t) => t.useCount); + const min = Math.min(...counts); + const span = Math.max(1, Math.max(...counts) - min); + return [...tags] + .sort((a, b) => hashString(a.token) - hashString(b.token)) + .map((tag) => ({ + tag, + // sqrt compresses the top end so one mega-tag doesn't dwarf the rest. + weight: Math.sqrt((tag.useCount - min) / span), + })); + }, [tags]); + + return ( +
+ {words.map(({ tag, weight }, index) => ( + onOpen(tag.token)} + onContextMenu={(e) => { + e.preventDefault(); + onMenu(tag.token, e.clientX, e.clientY); + }} + title={`${tag.useCount} file${tag.useCount === 1 ? "" : "s"} — right-click to rename or delete`} + // vw cap keeps the biggest words from overflowing narrow screens; + // on desktop widths the px size always wins the min(). + style={{ + fontSize: `min(${Math.round(16 + weight * 40)}px, ${(5 + weight * 7).toFixed(1)}vw)`, + }} + className={`max-w-full truncate leading-none transition-colors hover:text-violet-400 ${ + weight > 0.66 + ? "font-bold text-white" + : weight > 0.33 + ? "font-semibold text-white/70" + : "font-medium text-white/40" + }`} + > + {tag.value} + + ))}
); } @@ -1586,6 +2073,7 @@ function BulkSelectionBar({ }: BulkSelectionBarProps) { const ids = useLibrarySelectionStore((s) => s.ids); const folderMediaItems = useLibraryFoldersStore((s) => s.folderMediaItems); + const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems); const [popoverOpen, setPopoverOpen] = useState(false); // Same ordering as the sidebar / folder cards: starred first, then @@ -1600,12 +2088,15 @@ function BulkSelectionBar({ setPopoverOpen(false); }, [activeFolderId]); - // Resolve selected items from everything loaded (root library + folder + // Resolve selected items from everything loaded (root library + folder/tag // caches) — the selection survives navigation, so selected items may not be // part of the currently displayed view. const selectedItems = useMemo(() => { const byId = new Map(allItems.map((it) => [it.id, it] as const)); - for (const arr of Object.values(folderMediaItems)) { + for (const arr of [ + ...Object.values(folderMediaItems), + ...Object.values(tagMediaItems), + ]) { for (const it of arr) { if (!byId.has(it.id)) byId.set(it.id, it); } @@ -1613,7 +2104,7 @@ function BulkSelectionBar({ return Array.from(ids) .map((id) => byId.get(id)) .filter((it): it is GalleryItem => !!it); - }, [allItems, folderMediaItems, ids]); + }, [allItems, folderMediaItems, tagMediaItems, ids]); if (ids.size === 0) return null; const clear = () => useLibrarySelectionStore.getState().clear();