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") && (
)}
+
+ {/* Breadcrumb (inside a tag) */}
+ {tab === "tags" && activeTagToken && (
+
+ navigate("/library/tags")}
+ className="text-white/50 hover:text-white text-sm transition-colors"
+ >
+ Tags
+
+ /
+
+ {activeTag?.value ?? "Tag"}
+
+ {activeTag && (
+
+ {activeTag.useCount} file
+ {activeTag.useCount === 1 ? "" : "s"}
+
+ )}
+ setTagRenameTarget(activeTagToken)}
+ className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
+ title="Rename tag"
+ >
+
+
+ confirmDeleteTag(activeTagToken)}
+ className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
+ title="Delete tag"
+ >
+
+
+
+ )}
@@ -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) => (
+
+ navigate(`/library/${t.token}`)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ setTagContextMenu({
+ tagToken: t.token,
+ x: e.clientX,
+ y: e.clientY,
+ });
+ }}
+ className="flex items-center gap-2 pl-4 pr-1 py-2 text-sm font-medium text-white"
+ >
+
+ {t.value}
+
+ {t.useCount}
+
+
+ {
+ const rect = e.currentTarget.getBoundingClientRect();
+ setTagContextMenu({
+ tagToken: t.token,
+ x: rect.left,
+ y: rect.bottom + 4,
+ });
+ }}
+ aria-label={`Options for tag "${t.value}"`}
+ title="Rename or delete"
+ className="mr-1.5 flex h-6 w-6 items-center justify-center rounded-full text-white/40 hover:bg-white/10 hover:text-white transition-colors"
+ >
+
+
+
+ ))}
+
+ )
+ ) : 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);
+ }}
+ />
+
+ setTagRenameTarget(tagContextMenu.tagToken)}
+ >
+
+ Rename
+
+ {
+ const token = tagContextMenu.tagToken;
+ setTagContextMenu(null);
+ confirmDeleteTag(token);
+ }}
+ >
+
+ Delete tag
+
+
+ >,
+ 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();