mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 07:27:51 +00:00
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:
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();
|
||||
|
||||
Reference in new issue
Block a user