Desktop: tags in the gallery modal (#1794)

- Tags now work in the desktop app - no separate build, it all comes
through the shared gallery/lightbox components
- Open any item in the gallery → edit its tags in the details panel
(same chip editor as the webapp, with autocomplete)
- New "Tags" section in the gallery sidebar: your 5 most-used tags + an
"All tags" entry
- Click a tag to see all media with that tag in the normal grid
- "All tags" opens a word cloud (bigger = used more), click a word to
jump in
- Webapp sidebar now also caps at 5 tags with a "See all tags" link
- The tag editor is now one shared component used by both apps instead
of two copies
This commit is contained in:
BFlat authored and GitHub committed 2026-07-17 20:44:49 +07:00
1 parent 65463ae14c
commit 185bf835a1
12 files changed
+951 -245

No files matched your search

@@ -15,7 +15,7 @@ import {
} from "../../pages/library/library-tags-store";
import { EASE_EMPHASIS } from "../../lib/motion";
const MAX_SIDEBAR_TAGS = 10;
const MAX_SIDEBAR_TAGS = 5;
/**
* The "Tags" rows of the Assets sidebar group, rendered below the folders
@@ -137,6 +137,23 @@ export function LibraryTagsNav({
</button>
</SidebarMenuItem>
))}
{tags.length > MAX_SIDEBAR_TAGS && (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="See all tags">
<Link
to="/library/tags"
onClick={onNavClick}
className="pl-5 text-sidebar-foreground/60"
>
<FontAwesomeIcon icon={faEllipsis} />
<span>See all tags</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40">
{tags.length}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</ul>
</motion.li>
)}
@@ -1,76 +1,35 @@
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 { useEffect, useMemo } from "react";
import type { UserInfo } from "@storyteller/api";
import { TagsSection as SharedTagsSection } from "@storyteller/ui-lightbox-modal";
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.
* Store-wired wrapper around the shared tags editor (from
* `@storyteller/ui-lightbox-modal`, also used by the desktop gallery):
* session comes from the webapp session store, autocomplete suggestions from
* the preloaded tags store, and saves feed fresh use counts back into it so
* the sidebar and tag browser stay current.
*/
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]);
const currentUsername = loggedIn ? (user?.username ?? null) : null;
const isOwner =
!!currentUsername && creator?.username === currentUsername;
// Preload the user's tag list once for autocomplete (client-side filtering;
// no per-keystroke network).
// Preload the user's tag list once for autocomplete.
useEffect(() => {
if (isOwner && !useLibraryTagsStore.getState().tagsLoaded) loadTags();
}, [isOwner, loadTags]);
@@ -83,91 +42,13 @@ export function TagsSection({ mediaToken, creator }: TagsSectionProps) {
[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>
<SharedTagsSection
mediaToken={mediaToken}
creator={creator}
currentUsername={currentUsername}
suggestions={suggestions}
onSaved={upsertTags}
/>
);
}
@@ -76,6 +76,9 @@
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/lightbox-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
},
@@ -41,6 +41,9 @@
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/lightbox-modal"
},
{
"path": "../../libs/components/switch"
},
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../tooltip"
},
{
"path": "../../common"
},
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../tooltip/tsconfig.lib.json"
},
{
"path": "../../common/tsconfig.lib.json"
}
@@ -18,9 +18,15 @@ import {
FoldersApi,
GalleryModalApi,
MediaFilesApi,
TagsApi,
UsersApi,
} from "@storyteller/api";
import type { FolderInfo, FolderMediaFileListItem } from "@storyteller/api";
import type {
FolderInfo,
FolderMediaFileListItem,
TagDetails,
TagMediaFileListItem,
} from "@storyteller/api";
import type { GalleryFolder } from "./GalleryDraggableItem";
import { compareFolders } from "./folderUtils";
import {
@@ -74,6 +80,7 @@ import {
faEllipsis,
faPencil,
faStar,
faTag,
} from "@fortawesome/pro-solid-svg-icons";
import {
useMediaPromptTokens,
@@ -385,6 +392,71 @@ const PAGE_SIZE = 100;
// Folder media is paginated via cursor; one scroll page at a time.
const FOLDER_PAGE_SIZE = 60;
// The sidebar shows the five most-used tags; "All tags" opens the word cloud.
const SIDEBAR_TAG_LIMIT = 5;
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;
};
/**
* The all-tags browser view: a word cloud where font size and emphasis encode
* use count, and a deterministic hash shuffle spreads big words among small
* ones (stable order — no reflow between renders). Mirrors the webapp's
* library Tags tab.
*/
function TagCloudView({
tags,
onOpen,
}: {
tags: TagDetails[];
onOpen: (tagToken: string) => void;
}) {
const words = useMemo(() => {
const counts = tags.map((t) => t.use_count);
const min = Math.min(...counts);
const span = Math.max(1, Math.max(...counts) - min);
return [...tags]
.sort((a, b) => hashString(a.tag_token) - hashString(b.tag_token))
.map((tag) => ({
tag,
// sqrt compresses the top end so one mega-tag doesn't dwarf the rest.
weight: Math.sqrt((tag.use_count - min) / span),
}));
}, [tags]);
return (
<div className="flex min-h-full items-center justify-center p-8">
<div className="flex max-w-3xl flex-wrap items-baseline justify-center gap-x-5 gap-y-3 select-none">
{words.map(({ tag, weight }) => (
<button
key={tag.tag_token}
type="button"
onClick={() => onOpen(tag.tag_token)}
title={`${tag.use_count} file${tag.use_count === 1 ? "" : "s"}`}
style={{ fontSize: `${Math.round(15 + weight * 33)}px` }}
className={twMerge(
"max-w-full truncate leading-none transition-all duration-200 hover:scale-110 hover:text-violet-400",
hashString(tag.tag_token) % 2 === 0
? "hover:rotate-2"
: "hover:-rotate-2",
weight > 0.66
? "font-bold text-base-fg"
: weight > 0.33
? "font-semibold text-base-fg/70"
: "font-medium text-base-fg/40",
)}
>
{tag.tag_value}
</button>
))}
</div>
</div>
);
}
const SHOW_UPLOADS_STORAGE_KEY = "gallery-modal-show-uploads";
/** Small thumbnail used in the bulk-selection footer bar. */
@@ -618,6 +690,21 @@ export const GalleryModal = React.memo(
const folderCursorRef = useRef<Record<string, string | undefined>>({});
const folderHasMoreRef = useRef<Record<string, boolean>>({});
const folderLoadingRef = useRef<Record<string, boolean>>({});
// Tags state — sidebar tag browsing (mirrors the folder plumbing). Both
// tag views live on the assets tab: `activeTagToken` swaps the grid to
// that tag's media, `tagBrowserOpen` shows the all-tags word cloud.
const [userTags, setUserTags] = useState<TagDetails[]>([]);
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
const [activeTagToken, setActiveTagToken] = useState<string | null>(null);
const [tagMediaItems, setTagMediaItems] = useState<
Record<string, GalleryItem[]>
>({});
const [tagContentLoading, setTagContentLoading] = useState(false);
const [tagLoadingMore, setTagLoadingMore] = useState(false);
const tagCursorRef = useRef<Record<string, string | undefined>>({});
const tagHasMoreRef = useRef<Record<string, boolean>>({});
const tagLoadingRef = useRef<Record<string, boolean>>({});
// Rename state — shared by inline (header) and modal (sidebar) flows
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(
null,
@@ -714,6 +801,7 @@ export const GalleryModal = React.memo(
const api = useMemo(() => new GalleryModalApi(), []);
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
const foldersApi = useMemo(() => new FoldersApi(), []);
const tagsApi = useMemo(() => new TagsApi(), []);
// Shared API-folder → UI-folder mapper (see folderMapping.ts).
const mapFolder = mapFolderInfo;
@@ -755,6 +843,30 @@ export const GalleryModal = React.memo(
[activeFolderItems, groupItemsByDate],
);
// Tag view memos — backed by the per-tag media cache.
const activeTagItems = useMemo(
() => (activeTagToken ? (tagMediaItems[activeTagToken] ?? []) : []),
[activeTagToken, tagMediaItems],
);
const tagGroupedItems = useMemo(
() => groupItemsByDate(activeTagItems),
[activeTagItems, groupItemsByDate],
);
const activeTag = activeTagToken
? (userTags.find((t) => t.tag_token === activeTagToken) ?? null)
: null;
// Most-used-first ordering for the sidebar tag list.
const sortedTags = useMemo(
() =>
[...userTags].sort(
(a, b) =>
b.use_count - a.use_count ||
a.tag_value_lowercase.localeCompare(b.tag_value_lowercase),
),
[userTags],
);
const sidebarTags = sortedTags.slice(0, SIDEBAR_TAG_LIMIT);
// Audio tiles have no thumbnail, so they show their prompt text instead.
// Resolve it via the shared batched caches (media token → prompt token →
// prompt text) for audio items only; image/video walls skip the lookups.
@@ -910,11 +1022,12 @@ export const GalleryModal = React.memo(
[],
);
// Map a folder media-file list item → GalleryItem. The list item already
// carries media_links/cover, so no batch-get is needed (unlike `mapApiItem`,
// which reads the raw user-media list shape: origin_category / no batch token).
// Map a folder/tag media-file list item → GalleryItem (same lean wire
// shape). The list item already carries media_links/cover, so no batch-get
// is needed (unlike `mapApiItem`, which reads the raw user-media list
// shape: origin_category / no batch token).
const mapFolderListItem = useCallback(
(item: FolderMediaFileListItem): GalleryItem => ({
(item: FolderMediaFileListItem | TagMediaFileListItem): GalleryItem => ({
id: item.token,
label: getLabel(item),
thumbnail:
@@ -1615,6 +1728,69 @@ export const GalleryModal = React.memo(
[foldersApi],
);
// Load the user's tag list for the sidebar (same paging cap as folders).
const loadTags = useCallback(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;
}
setUserTags(all);
} catch (err) {
console.error("Failed to load tags:", err);
}
}, [tagsApi]);
// Resolve a tag's media one page at a time — mirrors `loadFolderMedia`.
const loadTagMedia = useCallback(
async (tagToken: string, reset = false) => {
if (tagLoadingRef.current[tagToken]) return;
if (!reset && tagHasMoreRef.current[tagToken] === false) return;
tagLoadingRef.current[tagToken] = true;
if (reset) {
tagCursorRef.current[tagToken] = undefined;
tagHasMoreRef.current[tagToken] = true;
setTagContentLoading(true);
} else {
setTagLoadingMore(true);
}
try {
const listRes = await tagsApi.ListMediaFilesWithTag({
tagToken,
cursor: reset ? undefined : tagCursorRef.current[tagToken],
limit: FOLDER_PAGE_SIZE,
});
if (!listRes.success || !listRes.data) return;
const nextCursor = listRes.pagination?.maybe_cursor ?? undefined;
tagCursorRef.current[tagToken] = nextCursor;
tagHasMoreRef.current[tagToken] = !!nextCursor;
const ordered = listRes.data.map(mapFolderListItem);
setTagMediaItems((prev) => {
const existing = reset ? [] : (prev[tagToken] ?? []);
const seen = new Set(existing.map((i) => i.id));
const merged = [
...existing,
...ordered.filter((i) => !seen.has(i.id)),
];
return { ...prev, [tagToken]: merged };
});
} catch (err) {
console.error("Failed to load tag media:", err);
} finally {
tagLoadingRef.current[tagToken] = false;
setTagContentLoading(false);
setTagLoadingMore(false);
}
},
[tagsApi, mapFolderListItem],
);
// `handleAddToFolder` is defined further down; reach it through a ref so the
// earlier `handleCreateFolder` can add the just-created folder's seed items.
const addToFolderRef = useRef<
@@ -1674,6 +1850,8 @@ export const GalleryModal = React.memo(
const handleOpenFolder = useCallback(
(folderId: string | null) => {
setActiveFolderId(folderId);
setActiveTagToken(null);
setTagBrowserOpen(false);
setRenamingFolderId(null);
setBulkSelectedIds(new Set());
setFolderMenuOpen(false);
@@ -1688,10 +1866,13 @@ export const GalleryModal = React.memo(
handleOpenFolder(null);
}, [handleOpenFolder]);
// Switch top-level tab; leaving the folder browser exits any open folder.
// Switch top-level tab; leaving the folder browser exits any open folder
// (and any open tag view).
const switchGalleryTab = useCallback((tab: "unsorted" | "folders") => {
setGalleryTab(tab);
setActiveFolderId(null);
setActiveTagToken(null);
setTagBrowserOpen(false);
setBulkSelectedIds(new Set());
setFolderMenuOpen(false);
setContextMenu(null);
@@ -1707,6 +1888,37 @@ export const GalleryModal = React.memo(
[handleOpenFolder],
);
// Open a tag view from the sidebar or the tag browser: the tag's media
// takes over the grid on the assets tab until dismissed.
const handleOpenTagFromSidebar = useCallback(
(tagToken: string) => {
setGalleryTab("unsorted");
setActiveFolderId(null);
setActiveTagToken(tagToken);
setBulkSelectedIds(new Set());
setFolderMenuOpen(false);
setContextMenu(null);
loadTagMedia(tagToken, true);
},
[loadTagMedia],
);
// Open the all-tags word cloud (the "Tags" browser view).
const handleOpenTagBrowser = useCallback(() => {
setGalleryTab("unsorted");
setActiveFolderId(null);
setActiveTagToken(null);
setTagBrowserOpen(true);
setBulkSelectedIds(new Set());
setFolderMenuOpen(false);
setContextMenu(null);
}, []);
const handleCloseTagView = useCallback(() => {
setActiveTagToken(null);
setTagBrowserOpen(false);
}, []);
// Close any open folder menus
const closeFolderMenus = useCallback(() => {
setFolderMenuOpen(false);
@@ -2118,6 +2330,7 @@ export const GalleryModal = React.memo(
: true;
if (modalIsOpen && username) {
loadFolders();
loadTags();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, isOpen, galleryModalVisibleViewMode.value, username]);
@@ -2250,7 +2463,15 @@ export const GalleryModal = React.memo(
(e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight - scrollTop - clientHeight >= 100) return;
if (activeFolderId) {
if (activeTagToken) {
// Infinite-scroll the open tag's media.
if (
tagHasMoreRef.current[activeTagToken] !== false &&
!tagLoadingRef.current[activeTagToken]
) {
loadTagMedia(activeTagToken, false);
}
} else if (activeFolderId) {
// Infinite-scroll the open folder's media.
if (
folderHasMoreRef.current[activeFolderId] !== false &&
@@ -2266,7 +2487,7 @@ export const GalleryModal = React.memo(
loadItems();
}
},
[activeFolderId, hasMore, loadItems, loadFolderMedia],
[activeTagToken, activeFolderId, hasMore, loadItems, loadFolderMedia, loadTagMedia],
);
// Folder tiles for the current location, rendered on the same grid as media
@@ -2390,7 +2611,55 @@ export const GalleryModal = React.memo(
New folder
</button>
)}
{galleryTab === "folders" && activeFolder ? (
{activeTag ? (
/* ── Tag view header (Tags / name) ── */
<div className="flex items-center gap-1.5 relative z-[51]">
<button
type="button"
onClick={handleOpenTagBrowser}
className="text-base-fg/50 hover:text-base-fg text-sm transition-colors"
>
Tags
</button>
<span className="text-base-fg/30">/</span>
<FontAwesomeIcon
icon={faTag}
className="text-sm text-violet-400"
/>
<h2 className="text-lg font-semibold truncate max-w-[16rem]">
{activeTag.tag_value}
</h2>
<span className="text-base-fg/40 text-sm">
{activeTag.use_count} file
{activeTag.use_count === 1 ? "" : "s"}
</span>
<button
type="button"
onClick={handleCloseTagView}
aria-label="Close tag view"
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors"
>
<FontAwesomeIcon icon={faXmark} className="text-sm" />
</button>
</div>
) : tagBrowserOpen ? (
/* ── All-tags browser header ── */
<div className="flex items-center gap-2 relative z-[51]">
<FontAwesomeIcon
icon={faTag}
className="text-sm text-violet-400"
/>
<h2 className="text-lg font-semibold">Tags</h2>
<button
type="button"
onClick={handleCloseTagView}
aria-label="Close tags"
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors"
>
<FontAwesomeIcon icon={faXmark} className="text-sm" />
</button>
</div>
) : galleryTab === "folders" && activeFolder ? (
/* ── Folder header (breadcrumb trail) ── */
<div className="flex items-center gap-1.5 relative z-[51] flex-wrap">
<button
@@ -2525,7 +2794,10 @@ export const GalleryModal = React.memo(
)}
</>
)}
{galleryTab === "unsorted" && activeFilter !== "uploaded" && (
{galleryTab === "unsorted" &&
!activeTagToken &&
!tagBrowserOpen &&
activeFilter !== "uploaded" && (
<div className="relative z-[51] flex items-center">
<Checkbox
id="gallery-show-uploads"
@@ -2633,7 +2905,10 @@ export const GalleryModal = React.memo(
}}
className={twMerge(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
isActive && !activeFolderId
isActive &&
!activeFolderId &&
!activeTagToken &&
!tagBrowserOpen
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
forceFilter &&
@@ -2723,6 +2998,62 @@ export const GalleryModal = React.memo(
</button>
))
)}
{/* Tags — most-used first, capped until "Show all". Hidden
entirely until the user has tags (they're created from
the item view's Tags section). */}
{userTags.length > 0 && (
<>
<div className="flex items-center justify-between px-1.5 pt-3 pb-1 flex-shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-base-fg/40">
Tags
</span>
</div>
{sidebarTags.map((tag) => (
<button
key={tag.tag_token}
type="button"
onClick={() =>
handleOpenTagFromSidebar(tag.tag_token)
}
className={twMerge(
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
activeTagToken === tag.tag_token
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
)}
>
<FontAwesomeIcon
icon={faTag}
className="text-xs w-4 text-violet-400"
/>
<span className="truncate">{tag.tag_value}</span>
<span className="ml-auto text-[10px] text-base-fg/40">
{tag.use_count}
</span>
</button>
))}
<button
type="button"
onClick={handleOpenTagBrowser}
className={twMerge(
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
tagBrowserOpen && !activeTagToken
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/50 hover:bg-ui-controls/30 hover:text-base-fg",
)}
>
<FontAwesomeIcon
icon={faEllipsis}
className="text-xs w-4"
/>
<span>All tags</span>
<span className="ml-auto text-[10px] text-base-fg/40">
{sortedTags.length}
</span>
</button>
</>
)}
</div>
</div>
)}
@@ -2738,7 +3069,125 @@ export const GalleryModal = React.memo(
>
{/* Subfolder tiles for the current location (root or open folder) */}
{folderChipsSection}
{galleryTab === "folders" && !activeFolderId ? (
{activeTagToken ? (
/* ── Tag view ── */
tagContentLoading && activeTagItems.length === 0 ? (
<div className="flex h-full items-center justify-center">
<LoadingSpinner className="h-8 w-8" />
</div>
) : activeTagItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-400/10">
<FontAwesomeIcon
icon={faTag}
className="text-violet-400 text-2xl"
/>
</div>
<div className="text-base-fg font-semibold">
No files carry this tag
</div>
<div className="text-base-fg/40 text-sm text-center max-w-[16rem]">
Add tags to media from the item view's Tags section.
</div>
</div>
) : (
<div className="space-y-6 p-4">
{Object.entries(tagGroupedItems).map(
([date, dateItems], groupIndex) => {
const filteredItems = dateItems.filter(filterItem);
if (filteredItems.length === 0) return null;
return (
<LazyDateGroup
key={date}
eager={groupIndex < 2}
itemCount={filteredItems.length}
gridColumns={gridColumns}
scrollRoot={scrollContainerRef.current}
>
<h3 className="text-md mb-2 font-medium text-base-fg/60">
{date}
</h3>
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
{filteredItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
audioPromptText={audioPromptTextFor(item)}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
e.currentTarget.src =
PLACEHOLDER_IMAGES.DEFAULT;
e.currentTarget.style.opacity = "0.3";
(
e.currentTarget as HTMLImageElement
).dataset.brokenurl =
item.thumbnail || "";
handleImageError(item.thumbnail!);
}}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
onDelete={onDeleteMedia}
onEditClicked={onEditClicked}
maxSelections={maxSelections}
bulkSelected={bulkSelectedIds.has(item.id)}
onBulkSelectToggle={() =>
toggleBulkSelect(item.id)
}
bulkSelectionMode={bulkSelectionMode}
getBulkDragItems={getBulkDragItems}
folders={folders}
onAddToFolder={requestFolderDrop}
onCreateFolderFromMenu={
handleOpenNewFolderModal
}
/>
))}
</div>
</LazyDateGroup>
);
},
)}
{tagLoadingMore && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8" />
</div>
)}
</div>
)
) : tagBrowserOpen ? (
/* ── All-tags word cloud ── */
userTags.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-400/10">
<FontAwesomeIcon
icon={faTag}
className="text-violet-400 text-2xl"
/>
</div>
<div className="text-base-fg font-semibold">
No tags yet
</div>
<div className="text-base-fg/40 text-sm text-center max-w-[16rem]">
Open any item and add tags in its details panel —
they'll show up here.
</div>
</div>
) : (
<TagCloudView
tags={sortedTags}
onOpen={handleOpenTagFromSidebar}
/>
)
) : galleryTab === "folders" && !activeFolderId ? (
/* ── Folders tab, root: folder cards only (above) ── */
currentSubfolders.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
@@ -1,2 +1,4 @@
export * from './lib/lightbox-modal';
export * from './lib/tags/TagsSection';
export * from './lib/tags/TagChipInput';
@@ -61,6 +61,7 @@ import {
formatDuration,
formatResolution,
} from "@storyteller/common";
import { TagsSection } from "./tags/TagsSection";
interface LightboxModalProps {
isOpen: boolean;
@@ -1055,6 +1056,8 @@ export function LightboxModal({
/>
)}
<TagsSection mediaToken={mediaId} creator={creator} />
{additionalInfo}
</div>
@@ -131,7 +131,7 @@ export function TagChipInput({
};
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 ${
`flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-all ${
flashValue === value ? "ring-2 ring-primary" : ""
}`;
@@ -146,7 +146,7 @@ export function TagChipInput({
{chips.map((value) =>
disabled ? (
<span key={value.toLowerCase()} className={chipClass(value)}>
<FontAwesomeIcon icon={faTag} className="h-2.5 w-2.5 text-white/40" />
<FontAwesomeIcon icon={faTag} className="h-2.5 w-2.5 text-base-fg/40" />
{value}
</span>
) : (
@@ -181,7 +181,7 @@ export function TagChipInput({
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"
className="min-w-[7rem] flex-1 bg-transparent py-0.5 text-sm text-base-fg placeholder:text-base-fg/40 focus:outline-none"
/>
)}
</div>
@@ -196,13 +196,13 @@ export function TagChipInput({
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 ${
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-base-fg transition-colors ${
highlight === index ? "bg-ui-controls/60" : "hover:bg-ui-controls/40"
}`}
>
<FontAwesomeIcon icon={faTag} className="text-[10px] text-white/40" />
<FontAwesomeIcon icon={faTag} className="text-[10px] text-base-fg/40" />
<span className="truncate">{suggestion.value}</span>
<span className="ml-auto text-[11px] text-white/40">
<span className="ml-auto text-[11px] text-base-fg/40">
{suggestion.useCount}
</span>
</button>
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTag } from "@fortawesome/pro-solid-svg-icons";
import {
TagsApi,
UsersApi,
type TagDetails,
type UserInfo,
} from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { TagChipInput, type TagSuggestion } from "./TagChipInput";
export interface TagsSectionProps {
mediaToken?: string | null;
creator?: UserInfo | null;
/**
* The logged-in username when the host app already knows it (string =
* user, null = logged out). Left undefined, the section resolves the
* session itself (cached module-wide).
*/
currentUsername?: string | null;
/**
* Autocomplete suggestions supplied by the host app (e.g. the webapp's
* tags store). Left undefined, the section fetches the user's tags itself.
*/
suggestions?: TagSuggestion[];
/** Canonical tags after every successful save (fresh use counts). */
onSaved?: (tags: TagDetails[]) => void;
}
/** A queued save: the full desired tag set for one media file. */
interface PendingSave {
token: string;
values: string[];
}
// Session + own-tags lookups are module-cached so reopening the lightbox
// doesn't refetch (only used when the host doesn't supply the data).
let cachedUsername: string | null | undefined;
let usernameInFlight: Promise<string | null> | null = null;
const resolveSessionUsername = (): Promise<string | null> => {
if (cachedUsername !== undefined) return Promise.resolve(cachedUsername);
if (!usernameInFlight) {
usernameInFlight = new UsersApi()
.GetSession()
.then((res) => {
cachedUsername =
res.success && res.data?.loggedIn && res.data.user
? res.data.user.username
: null;
return cachedUsername;
})
.catch(() => {
usernameInFlight = null;
return null;
});
}
return usernameInFlight;
};
/**
* 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,
currentUsername,
suggestions,
onSaved,
}: TagsSectionProps) {
const tagsApi = useMemo(() => new TagsApi(), []);
// Session: prop wins; otherwise resolve (and cache) it ourselves.
const [resolvedUsername, setResolvedUsername] = useState<string | null>(null);
useEffect(() => {
if (currentUsername !== undefined) return;
let cancelled = false;
resolveSessionUsername().then((username) => {
if (!cancelled) setResolvedUsername(username);
});
return () => {
cancelled = true;
};
}, [currentUsername]);
const effectiveUsername = currentUsername ?? resolvedUsername;
const isOwner =
!!effectiveUsername &&
!!creator?.username &&
creator.username === effectiveUsername;
// 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);
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]);
// Autocomplete fallback: when the host doesn't supply suggestions, fetch
// the user's tags once per mount (merged with canonical tags after saves).
const [ownTags, setOwnTags] = useState<TagSuggestion[]>([]);
const ownTagsLoadedRef = useRef(false);
useEffect(() => {
if (suggestions !== undefined || !isOwner || ownTagsLoadedRef.current) {
return;
}
ownTagsLoadedRef.current = true;
let cancelled = false;
(async () => {
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;
}
if (cancelled) return;
setOwnTags(
all
.sort(
(a, b) =>
b.use_count - a.use_count ||
a.tag_value_lowercase.localeCompare(b.tag_value_lowercase),
)
.map((t) => ({ value: t.tag_value, useCount: t.use_count })),
);
})();
return () => {
cancelled = true;
};
}, [suggestions, isOwner, tagsApi]);
const effectiveSuggestions = suggestions ?? ownTags;
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) {
onSaved?.(res.data.tags);
if (suggestions === undefined && res.data.tags.length > 0) {
// Keep the self-fetched suggestion list fresh with new tags.
setOwnTags((prev) => {
const known = new Set(prev.map((s) => s.value.toLowerCase()));
const added = res.data!.tags
.filter((t) => !known.has(t.tag_value_lowercase))
.map((t) => ({ value: t.tag_value, useCount: t.use_count }));
return added.length > 0 ? [...prev, ...added] : prev;
});
}
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-base-fg/60">
<FontAwesomeIcon icon={faTag} />
<span>Tags</span>
</div>
{isOwner && tags.length >= 2 && (
<button
onClick={handleClear}
className="text-xs text-base-fg/60 hover:text-base-fg transition-colors"
>
Clear
</button>
)}
</div>
<TagChipInput
chips={tags.map((t) => t.tag_value)}
suggestions={effectiveSuggestions}
disabled={!isOwner}
onAdd={handleAdd}
onRemove={handleRemove}
/>
</div>
);
}
+168 -88
View File
File diff suppressed because it is too large. Load diff