mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 01:38:44 +00:00
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:
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>
|
||||
|
||||
|
||||
+6
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+168
-88
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user