Webapp: media tags (#1791)

- Tags editor in LightboxDetails (lightbox + /media/:id): chip input
with autocomplete over the user's existing tags, comma/paste splitting,
duplicate rejection, owner gating via session, and serialized+coalesced
SetMediaFileTags snapshot saves with optimistic chips and error revert
(components/tags/TagChipInput.tsx, TagsSection.tsx)
- Tag browser: "Tags" tab in the library with name/most-used sort, tag
view at /library/tag_… reusing the media grid, breadcrumb header with
rename (FolderNameDialog) and delete (confirm modal), tag context menu
- Sidebar: tags rows under the Assets group below folders, with
expand/collapse chevrons on both Folders (default open) and Tags
(default closed), animated via framer-motion; tag icons use violet to
distinguish them from folders
- New library-tags-store mirroring the folders store (cursor-paged media
caches, optimistic rename/delete with toast revert); shared item mappers
extracted to library-media-map.ts to avoid a circular import; media
deletion now purges tag caches; bulk-selection bar resolves thumbnails
from tag caches too
- Topbar breadcrumbs map /library/tags and tag views to "Tags"
- Tags show as word cloud
This commit is contained in:
BFlat authored and GitHub committed 2026-07-15 21:57:21 +07:00
1 parent 9f69eb162a
commit 77cee77258
11 files changed
+1437 -142

No files matched your search

@@ -183,8 +183,9 @@ export function App() {
<Route path="/media/:id" element={<Media />} />
<Route path="/library" element={<Library />} />
<Route path="/library/folders" element={<Library />} />
{/* `: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. */}
<Route path="/library/:slug" element={<Library />} />
<Route path="/referrals" element={<Referrals />} />
<Route path="/onboarding" element={<Onboarding />} />
@@ -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({
</div>
</div>
)}
<TagsSection mediaToken={mediaToken} creator={creator} />
</>
)}
</div>
@@ -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({
<span>Folders</span>
</Link>
</SidebarMenuButton>
{inLibraryArea && rootFolders.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse folders" : "Expand folders"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon
icon={faChevronRight}
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
)}
</SidebarMenuItem>
{inLibraryArea &&
rootFolders.map((folder) => (
<SidebarMenuItem
key={folder.id}
className="group-data-[collapsible=icon]:hidden"
<AnimatePresence initial={false}>
{inLibraryArea && expanded && rootFolders.length > 0 && (
<motion.li
key="folder-rows"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_EMPHASIS }}
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
>
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => 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"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode ? { color: folder.colorCode } : undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{rootFolders.map((folder) => (
<SidebarMenuItem key={folder.id}>
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => 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"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode
? { color: folder.colorCode }
: undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</ul>
</motion.li>
)}
</AnimatePresence>
<LibraryTagsNav pathname={pathname} onNavClick={onNavClick} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -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 (
<>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/library/tags"}
tooltip="Tags"
>
<Link to="/library/tags" onClick={onNavClick}>
<FontAwesomeIcon icon={faTags} />
<span>Tags</span>
</Link>
</SidebarMenuButton>
{inLibraryArea && topTags.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse tags" : "Expand tags"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon
icon={faChevronRight}
className={`text-[10px] transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
)}
</SidebarMenuItem>
<AnimatePresence initial={false}>
{inLibraryArea && expanded && topTags.length > 0 && (
<motion.li
key="tag-rows"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_EMPHASIS }}
className="list-none overflow-hidden group-data-[collapsible=icon]:hidden"
>
<ul className="flex w-full min-w-0 flex-col gap-0.5">
{topTags.map((tag) => (
<SidebarMenuItem key={tag.token}>
<SidebarMenuButton
isActive={pathname === `/library/${tag.token}`}
tooltip={tag.value}
onClick={() => {
navigate(`/library/${tag.token}`);
onNavClick();
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
tagToken: tag.token,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5"
>
<FontAwesomeIcon
icon={faTag}
className="text-violet-400"
/>
<span className="truncate">{tag.value}</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40 transition-opacity group-hover/menu-item:opacity-0">
{tag.useCount}
</span>
</SidebarMenuButton>
{/* Hover-revealed rename/delete menu (swaps with the count). */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({
tagToken: tag.token,
x: rect.left,
y: rect.bottom + 4,
});
}}
aria-label={`Options for tag "${tag.value}"`}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<FontAwesomeIcon icon={faEllipsis} className="text-xs" />
</button>
</SidebarMenuItem>
))}
</ul>
</motion.li>
)}
</AnimatePresence>
</>
);
}
@@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const flashTimeout = useRef<ReturnType<typeof setTimeout> | 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<string>();
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="relative">
<div
className={`flex flex-wrap items-center gap-1.5 rounded-xl bg-black/20 border border-white/5 px-3 py-2.5 ${
disabled ? "" : "cursor-text"
}`}
onClick={() => inputRef.current?.focus()}
>
{chips.map((value) =>
disabled ? (
<span key={value.toLowerCase()} className={chipClass(value)}>
<FontAwesomeIcon icon={faTag} className="h-2.5 w-2.5 text-white/40" />
{value}
</span>
) : (
<button
key={value.toLowerCase()}
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(value);
}}
title={`Remove "${value}"`}
className={`group/tag ${chipClass(value)} hover:bg-red/15 hover:text-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red`}
>
{value}
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
),
)}
{!disabled && (
<input
ref={inputRef}
value={draft}
onChange={(e) => {
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"
/>
)}
</div>
{dropdownOpen && (
<div className="absolute left-0 right-0 top-full z-30 mt-1 max-h-48 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl">
{filtered.map((suggestion, index) => (
<button
key={suggestion.value.toLowerCase()}
type="button"
// preventDefault so the input's blur doesn't fire before the click.
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickSuggestion(suggestion.value)}
onMouseEnter={() => setHighlightIndex(index)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-white transition-colors ${
highlight === index ? "bg-ui-controls/60" : "hover:bg-ui-controls/40"
}`}
>
<FontAwesomeIcon icon={faTag} className="text-[10px] text-white/40" />
<span className="truncate">{suggestion.value}</span>
<span className="ml-auto text-[11px] text-white/40">
{suggestion.useCount}
</span>
</button>
))}
</div>
)}
</div>
);
}
@@ -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<TagDetails[] | null>(null);
const mediaTokenRef = useRef(mediaToken);
mediaTokenRef.current = mediaToken;
/** Last server-confirmed tag set — the revert target on save failure. */
const lastAckedRef = useRef<TagDetails[]>([]);
const pendingRef = useRef<PendingSave | null>(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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faTag} />
<span>Tags</span>
</div>
{isOwner && tags.length >= 2 && (
<button
onClick={handleClear}
className="text-xs text-white/60 hover:text-white transition-colors"
>
Clear
</button>
)}
</div>
<TagChipInput
chips={tags.map((t) => t.tag_value)}
suggestions={suggestions}
disabled={!isOwner}
onAdd={handleAdd}
onRemove={handleRemove}
/>
</div>
);
}
@@ -32,13 +32,15 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
};
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",
},
];
}
@@ -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<string, string | undefined> = {};
const folderInFlight: Record<string, boolean> = {};
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<LibraryFoldersState>(
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<LibraryFoldersState>(
}),
);
/** 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<boolean> {
try {
const res = await mediaFilesApi.DeleteMediaFileByToken({
@@ -586,6 +517,13 @@ export async function deleteLibraryMedia(mediaToken: string): Promise<boolean> {
}
return { folderMediaItems: next };
});
useLibraryTagsStore.setState((s) => {
const next: Record<string, GalleryItem[]> = {};
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) {
@@ -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);
@@ -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<string, GalleryItem[]>;
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<string, boolean>;
// 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<void>;
setActiveTag: (token: string | null) => void;
loadTagMedia: (tagToken: string, reset?: boolean) => Promise<void>;
renameTag: (tagToken: string, newValue: string) => Promise<void>;
deleteTag: (tagToken: string) => Promise<void>;
/** 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<string, string | undefined> = {};
const tagInFlight: Record<string, boolean> = {};
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<LibraryTagsState>((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 }),
}));
@@ -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<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(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<string, GalleryItem[]> = {};
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<string, GalleryItem[]> = {};
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: (
<p className="text-sm text-white/70">
Removes this tag from {count} file{count === 1 ? "" : "s"}. Files stay
in your library.
</p>
),
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() {
/>
<span className="relative z-10">Unfoldered</span>
</Link>
<Link
to="/library/tags"
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tab === "tags"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{tab === "tags" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={faTag}
className="relative z-10 text-xs"
/>
<span className="relative z-10">Tags</span>
</Link>
</div>
{(tab === "unsorted" || tab === "folderless") && (
<button
@@ -1137,6 +1277,79 @@ export default function Library() {
New folder
</Button>
)}
{tab === "tags" && !activeTagToken && tags.length > 0 && (
<>
{tagView === "list" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
{(
[
["count", "Most used"],
["name", "Name"],
] as const
).map(([id, label]) => (
<button
key={id}
onClick={() => setTagSort(id)}
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tagSort === id
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{tagSort === id && (
<motion.span
layoutId="library-tag-sort-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
}}
/>
)}
<span className="relative z-10">{label}</span>
</button>
))}
</div>
)}
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
{(
[
["cloud", faCloud, "Cloud"],
["list", faList, "List"],
] as const
).map(([id, icon, label]) => (
<button
key={id}
onClick={() => setTagView(id)}
title={label}
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tagView === id
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{tagView === id && (
<motion.span
layoutId="library-tag-view-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
}}
/>
)}
<FontAwesomeIcon
icon={icon}
className="relative z-10 text-xs"
/>
<span className="relative z-10 hidden sm:inline">
{label}
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
@@ -1180,6 +1393,42 @@ export default function Library() {
</button>
</div>
)}
{/* Breadcrumb (inside a tag) */}
{tab === "tags" && activeTagToken && (
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<button
onClick={() => navigate("/library/tags")}
className="text-white/50 hover:text-white text-sm transition-colors"
>
Tags
</button>
<span className="text-white/30">/</span>
<h1 className="text-lg sm:text-xl font-medium text-white truncate max-w-[16rem]">
{activeTag?.value ?? "Tag"}
</h1>
{activeTag && (
<span className="text-white/40 text-sm pt-1 ps-1.5">
{activeTag.useCount} file
{activeTag.useCount === 1 ? "" : "s"}
</span>
)}
<button
onClick={() => 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"
>
<FontAwesomeIcon icon={faPencil} className="text-xs" />
</button>
<button
onClick={() => 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"
>
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
</button>
</div>
)}
</div>
</div>
@@ -1272,6 +1521,105 @@ export default function Library() {
)}
</>
)
) : tab === "tags" && !activeTagToken ? (
/* ── Tags tab: all tags ── */
!tagsLoaded ? (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-8 w-8 text-white/60" />
</div>
) : tags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
<FontAwesomeIcon
icon={faTags}
className="text-2xl text-white/40"
/>
</div>
<p className="text-white/40 text-sm">No tags yet.</p>
<p className="text-white/30 text-xs max-w-xs text-center">
Open any image or video and add tags in the details panel —
they'll show up here.
</p>
</div>
) : tagView === "cloud" ? (
<TagCloud
tags={tags}
onOpen={(token) => navigate(`/library/${token}`)}
onMenu={(cloudTagToken, x, y) =>
setTagContextMenu({ tagToken: cloudTagToken, x, y })
}
/>
) : (
<div className="flex flex-wrap gap-2">
{sortedTags.map((t) => (
<div
key={t.token}
className="flex items-center rounded-full bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
>
<button
type="button"
onClick={() => 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"
>
<FontAwesomeIcon
icon={faTag}
className="text-xs text-violet-400"
/>
<span className="max-w-[14rem] truncate">{t.value}</span>
<span className="text-xs text-white/40">
{t.useCount}
</span>
</button>
<button
type="button"
onClick={(e) => {
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"
>
<FontAwesomeIcon icon={faEllipsis} className="text-xs" />
</button>
</div>
))}
</div>
)
) : tab === "tags" && activeTagToken ? (
/* ── Inside a tag ── */
tagContentLoading && displayItems.length === 0 ? (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-8 w-8 text-white/60" />
</div>
) : displayItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-white/40 text-sm">
No files carry this tag.
</p>
</div>
) : (
<>
{mediaGrid}
{tagLoadingMore && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8 text-white/60" />
</div>
)}
</>
)
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
<div>
<div
@@ -1413,6 +1761,18 @@ export default function Library() {
onClose={() => setRenameTarget(null)}
/>
{/* Rename tag dialog */}
<FolderNameDialog
isOpen={!!tagRenameTarget}
title="Rename tag"
initialValue={
tags.find((t) => 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(
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => setTagContextMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setTagContextMenu(null);
}}
/>
<div
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
style={{ left: tagContextMenu.x, top: tagContextMenu.y }}
>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => setTagRenameTarget(tagContextMenu.tagToken)}
>
<FontAwesomeIcon icon={faPencil} className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
onClick={() => {
const token = tagContextMenu.tagToken;
setTagContextMenu(null);
confirmDeleteTag(token);
}}
>
<FontAwesomeIcon icon={faTrashCan} className="w-4" />
<span>Delete tag</span>
</button>
</div>
</>,
document.body,
)}
</div>
);
}
// ── 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 (
<div className="mx-auto flex max-w-4xl flex-wrap items-baseline justify-center gap-x-4 gap-y-2.5 sm:gap-x-5 sm:gap-y-3 px-4 py-10 sm:py-16 select-none">
{words.map(({ tag, weight }, index) => (
<motion.button
key={tag.token}
type="button"
// Staggered pop-in on mount (delay capped so huge clouds don't drag),
// then a springy lift + tilt on hover. Transitions live on the
// targets so the entrance delay never slows down hover response.
initial={{ opacity: 0, scale: 0.5 }}
animate={{
opacity: 1,
scale: 1,
transition: {
delay: Math.min(index * 0.025, 0.6),
duration: 0.3,
ease: EASE_EMPHASIS,
},
}}
whileHover={{
scale: 1.15,
y: -3,
// Alternate tilt direction per word so neighbors don't sync up.
rotate: hashString(tag.token) % 2 === 0 ? 2.5 : -2.5,
transition: { type: "spring", stiffness: 400, damping: 15 },
}}
whileTap={{ scale: 0.92 }}
onClick={() => 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}
</motion.button>
))}
</div>
);
}
@@ -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();