library performance fixes (#1080)

## Library/Gallery Modal Improvements

### Performance
- Library items are cached so reopening the modal is instant instead of
loading from scratch every time
- Only visible sections are rendered - off-screen groups are swapped out
as you scroll, keeping things smooth even with lots of media
- Username session is cached to skip a redundant API call on every open

### UX
- Replaced loading spinner with a skeleton grid placeholder
- New items from completed generations appear at the top without wiping
the library
- Library auto-refreshes when a generation finishes (if open)
- Refresh icon spins while refreshing
This commit is contained in:
BFlat authored and GitHub committed 2026-03-03 11:34:43 +07:00
1 parent 7ee421cfbe
commit 0337e64f9d
1 file changed
+378 -112
@@ -55,6 +55,147 @@ import {
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { MediaFileDelete } from "@storyteller/tauri-api";
import { listen } from "@tauri-apps/api/event";
// ─── Module-level gallery cache ───────────────────────────────────────────────
// Persists gallery items across modal open/close so users see content instantly.
interface GalleryCacheEntry {
items: GalleryItem[];
pageIndex: number;
hasMore: boolean;
timestamp: number;
}
const galleryCacheMap = new Map<string, GalleryCacheEntry>();
let cachedUsername: string | null = null;
const usersApiSingleton = new UsersApi();
/** Get cached username or fetch it once. */
async function getCachedUsername(): Promise<string | null> {
if (cachedUsername) return cachedUsername;
try {
const session = await usersApiSingleton.GetSession();
if (session.success && session.data?.user) {
cachedUsername = session.data.user.username;
return cachedUsername;
}
} catch {
// ignore
}
return null;
}
// ─── Skeleton loader component ────────────────────────────────────────────────
const SKELETON_COUNT = 15;
const SkeletonGrid = ({ columns }: { columns: number }) => {
const gapClass =
columns <= 4 ? "gap-1.5" : columns <= 7 ? "gap-1" : "gap-0.5";
return (
<div className="space-y-6 p-4 animate-in fade-in duration-200">
{/* Fake date heading */}
<div>
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<div key={i} className="aspect-square rounded-md overflow-hidden">
<div
className="h-full w-full bg-white/[0.06]"
style={{
animation: `pulse 1.8s ease-in-out ${i * 0.07}s infinite`,
}}
/>
</div>
))}
</div>
</div>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
);
};
// ─── Lazy date-group virtualization ───────────────────────────────────────────
// Bidirectional: mounts groups when they scroll within 800px of the viewport,
// and unmounts them when they scroll beyond that range. Measured heights are
// used for placeholders so scroll position stays rock-solid.
interface LazyDateGroupProps {
/** If true the group renders on mount (use for first N visible groups). */
eager: boolean;
/** Estimated item count for placeholder height calculation. */
itemCount: number;
/** Current grid column count, used to estimate placeholder height. */
gridColumns: number;
/** The scroll container used as IntersectionObserver root. */
scrollRoot: HTMLDivElement | null;
children: React.ReactNode;
}
const LazyDateGroup = React.memo(
({
eager,
itemCount,
gridColumns,
scrollRoot,
children,
}: LazyDateGroupProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isNearViewport, setIsNearViewport] = useState(eager);
// Store the last measured height so placeholders match exactly
const measuredHeightRef = useRef<number | null>(null);
// Measure rendered height on every commit while visible
useEffect(() => {
if (isNearViewport && containerRef.current) {
measuredHeightRef.current = containerRef.current.offsetHeight;
}
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsNearViewport(entry.isIntersecting);
},
{
root: scrollRoot,
rootMargin: "800px 0px", // mount/unmount buffer
},
);
observer.observe(el);
return () => observer.disconnect();
}, [scrollRoot]);
if (!isNearViewport) {
// Use measured height if available, otherwise estimate
const height =
measuredHeightRef.current ??
Math.ceil(itemCount / gridColumns) * 120 + 36;
return (
<div
ref={containerRef}
style={{ height, minHeight: height }}
aria-hidden
/>
);
}
return <div ref={containerRef}>{children}</div>;
},
);
LazyDateGroup.displayName = "LazyDateGroup";
export interface GalleryItem {
id: string;
@@ -219,10 +360,15 @@ export const GalleryModal = React.memo(
onMake3DWorldClicked,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
// Separate state for pagination spinner (bottom of list) — not shared with background refresh
const [paginationLoading, setPaginationLoading] = useState(false);
// Ref-based lock to prevent re-entrant loadItems calls
const isLoadingRef = useRef(false);
// Lightbox state is now handled via signals
const lightboxImageSignal = galleryModalLightboxImage;
const lightboxVisibleSignal = galleryModalLightboxVisible;
const failedImageUrls = useRef<Set<string>>(new Set());
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [username, setUsername] = useState<string>("");
const forceFilterRef = useRef(forceFilter);
const [activeFilter, setActiveFilter] = useState(forceFilter || "all");
@@ -246,6 +392,9 @@ export const GalleryModal = React.memo(
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
// Track whether this is the very first load (no cache, no items yet)
const [initialLoading, setInitialLoading] = useState(false);
// Bulk selection state (view mode only)
const [bulkSelectedIds, setBulkSelectedIds] = useState<Set<string>>(
new Set(),
@@ -270,7 +419,6 @@ export const GalleryModal = React.memo(
?.imageUrls;
const api = useMemo(() => new GalleryModalApi(), []);
const usersApi = useMemo(() => new UsersApi(), []);
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
const groupItemsByDate = useCallback((items: GalleryItem[]) => {
@@ -304,17 +452,53 @@ export const GalleryModal = React.memo(
failedImageUrls.current.add(url);
}, []);
// Fetch & cache username — uses module-level cache so subsequent opens are instant
useEffect(() => {
const getUsername = async () => {
const session = await usersApi.GetSession();
if (session.success && session.data?.user) {
setUsername(session.data.user.username);
}
const name = await getCachedUsername();
if (name) setUsername(name);
};
if (isOpen || (mode === "view" && galleryModalVisibleViewMode.value)) {
getUsername();
}
}, [usersApi, mode, galleryModalVisibleViewMode.value, isOpen]);
}, [mode, galleryModalVisibleViewMode.value, isOpen]);
// Helper to build the cache key for the current filter
const getCacheKey = useCallback(
() => `gallery_${activeFilterRef.current}`,
[],
);
// Map raw API item to GalleryItem
const mapApiItem = useCallback(
(item: any): GalleryItem => ({
id: item.token,
label: getLabel(item),
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews?.animated
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: getThumbnailUrl(item.media_links.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.MEDIUM,
}),
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
item.media_class ||
(item.filter_media_classes ? item.filter_media_classes[0] : "image"),
assetType:
item.media_class === "dimensional"
? item.maybe_animation_type ||
item.origin_product_category === "character" ||
(item.origin && item.origin.product_category === "character")
? "character"
: "object"
: undefined,
}),
[],
);
// Stable loadItems that reads from refs — never causes cascading re-renders
const loadItems = useCallback(
@@ -322,7 +506,11 @@ export const GalleryModal = React.memo(
const currentUsername = usernameRef.current;
const currentFilter = activeFilterRef.current;
if (!currentUsername) return;
// Ref-based lock prevents concurrent/re-entrant calls
if (isLoadingRef.current) return;
isLoadingRef.current = true;
setLoading(true);
if (!reset) setPaginationLoading(true);
try {
const filterMediaClasses = getFilterMediaClass(currentFilter);
const query = {
@@ -345,59 +533,79 @@ export const GalleryModal = React.memo(
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
)
.map((item: any) => ({
id: item.token,
label: getLabel(item),
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews?.animated
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: getThumbnailUrl(
item.media_links.maybe_thumbnail_template,
{
width: THUMBNAIL_SIZES.MEDIUM,
},
),
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
item.media_class ||
(item.filter_media_classes
? item.filter_media_classes[0]
: "image"),
assetType:
item.media_class === "dimensional"
? item.maybe_animation_type ||
item.origin_product_category === "character" ||
(item.origin &&
item.origin.product_category === "character")
? "character"
: "object"
: undefined,
}));
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
.map(mapApiItem);
if (reset) {
// Smart merge: update existing items in-place (preserves
// scroll position), then prepend any genuinely new items.
setAllItems((prev) => {
if (prev.length === 0) return newItems;
const existingIds = new Set(prev.map((it) => it.id));
const brandNew = newItems.filter(
(it: GalleryItem) => !existingIds.has(it.id),
);
// Build lookup for fresh data so we can update in-place
const freshMap = new Map(
newItems.map((it: GalleryItem) => [it.id, it]),
);
// Update existing items in their current positions
const updated = prev.map((it) => freshMap.get(it.id) || it);
return [...brandNew, ...updated];
});
} else {
setAllItems((prev) => [...prev, ...newItems]);
}
// Pagination logic
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
// Update cache
const cacheKey = getCacheKey();
setAllItems((latest) => {
galleryCacheMap.set(cacheKey, {
items: latest,
pageIndex: current + 1,
hasMore: current + 1 < total,
timestamp: Date.now(),
});
return latest;
});
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
setPaginationLoading(false);
setInitialLoading(false);
isLoadingRef.current = false;
},
[api],
[api, mapApiItem, getCacheKey],
);
// refresh logic — stable because loadItems is stable
// refresh logic — shows cached items immediately, then background-refreshes
const refreshGallery = useCallback(() => {
setAllItems([]);
setPageIndex(0);
setHasMore(true);
loadItems(true);
const cacheKey = `gallery_${activeFilterRef.current}`;
const cached = galleryCacheMap.get(cacheKey);
if (cached && cached.items.length > 0) {
// Instantly show cached items — no loading indicator
setAllItems(cached.items);
setPageIndex(cached.pageIndex);
setHasMore(cached.hasMore);
setInitialLoading(false);
// Background refresh — silently fetch page 0 and merge new items
loadItems(true);
} else {
// No cache — show skeleton, then load
setAllItems([]);
setPageIndex(0);
setHasMore(true);
setInitialLoading(true);
loadItems(true);
}
}, [loadItems]);
useEffect(() => {
@@ -420,6 +628,28 @@ export const GalleryModal = React.memo(
activeFilter,
]);
// Auto-refresh when a generation completes while the gallery is open
const modalIsOpenRef = useRef(false);
useEffect(() => {
modalIsOpenRef.current =
mode === "view"
? !!galleryModalVisibleViewMode.value
: typeof isOpen === "boolean"
? isOpen
: true;
}, [mode, isOpen, galleryModalVisibleViewMode.value]);
useEffect(() => {
const unlistenPromise = listen("generation-complete-event", () => {
if (modalIsOpenRef.current && username) {
refreshGallery();
}
});
return () => {
unlistenPromise.then((fn) => fn());
};
}, [refreshGallery, username]);
const toggleBulkSelect = useCallback((id: string) => {
setBulkSelectedIds((prev) => {
const next = new Set(prev);
@@ -468,14 +698,29 @@ export const GalleryModal = React.memo(
onUseSelected?.(selectedItems);
}, [groupedItems, selectedItemIds, onUseSelected]);
const handleItemDeleted = useCallback((id: string) => {
setAllItems((prev) => prev.filter((it) => it.id !== id));
setBulkSelectedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
const handleItemDeleted = useCallback(
(id: string) => {
setAllItems((prev) => {
const updated = prev.filter((it) => it.id !== id);
// Remove from cache too
const cacheKey = getCacheKey();
const cached = galleryCacheMap.get(cacheKey);
if (cached) {
galleryCacheMap.set(cacheKey, {
...cached,
items: cached.items.filter((it) => it.id !== id),
});
}
return updated;
});
setBulkSelectedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
},
[getCacheKey],
);
const clearBulkSelection = useCallback(() => {
setBulkSelectedIds(new Set());
@@ -504,16 +749,28 @@ export const GalleryModal = React.memo(
try {
const ids = Array.from(bulkSelectedIds);
await Promise.allSettled(ids.map((id) => MediaFileDelete(id)));
setAllItems((prev) =>
prev.filter((it) => !bulkSelectedIds.has(it.id)),
);
setAllItems((prev) => {
const updated = prev.filter((it) => !bulkSelectedIds.has(it.id));
// Evict from cache too
const cacheKey = getCacheKey();
const cached = galleryCacheMap.get(cacheKey);
if (cached) {
galleryCacheMap.set(cacheKey, {
...cached,
items: cached.items.filter(
(it) => !bulkSelectedIds.has(it.id),
),
});
}
return updated;
});
clearBulkSelection();
} finally {
isActionReminderOpen.value = false;
}
},
});
}, [bulkSelectedIds, clearBulkSelection]);
}, [bulkSelectedIds, clearBulkSelection, getCacheKey]);
useSignals();
@@ -633,12 +890,12 @@ export const GalleryModal = React.memo(
if (
scrollHeight - scrollTop - clientHeight < 100 &&
hasMore &&
!loading
!isLoadingRef.current
) {
loadItems();
}
},
[hasMore, loading, loadItems],
[hasMore, loadItems],
);
return (
@@ -739,7 +996,7 @@ export const GalleryModal = React.memo(
>
<FontAwesomeIcon
icon={faArrowsRotate}
className="text-lg text-base-fg"
className={`text-lg text-base-fg ${loading ? "animate-spin" : ""}`}
/>
</Button>
</Tooltip>
@@ -843,66 +1100,75 @@ export const GalleryModal = React.memo(
</div>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto bg-ui-panel"
onScroll={handleScroll}
>
{loading && allItems.length === 0 ? (
{initialLoading && allItems.length === 0 ? (
<SkeletonGrid columns={gridColumns} />
) : allItems.length === 0 && !loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-base-fg/60">
<LoadingSpinner className="h-12 w-12" />
</div>
<div className="text-base-fg/40 text-sm">No items yet</div>
</div>
) : (
<div className="space-y-6 p-4">
{Object.entries(groupedItems).map(([date, dateItems]) => {
const filteredItems = dateItems.filter(filterItem);
if (filteredItems.length === 0) return null;
return (
<div key={date}>
<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))`,
}}
{Object.entries(groupedItems).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}
>
{filteredItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
e.currentTarget.src =
PLACEHOLDER_IMAGES.DEFAULT;
e.currentTarget.style.opacity = "0.3";
// Set the `data-brokenurl` property for debugging the broken images:
(
e.currentTarget as HTMLImageElement
).dataset.brokenurl = item.thumbnail || "";
handleImageError(item.thumbnail!);
}}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
onEditClicked={onEditClicked}
maxSelections={maxSelections}
bulkSelected={bulkSelectedIds.has(item.id)}
onBulkSelectToggle={() =>
toggleBulkSelect(item.id)
}
bulkSelectionMode={bulkSelectionMode}
/>
))}
</div>
</div>
);
})}
{loading && allItems.length > 0 && (
<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}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
e.currentTarget.src =
PLACEHOLDER_IMAGES.DEFAULT;
e.currentTarget.style.opacity = "0.3";
// Set the `data-brokenurl` property for debugging the broken images:
(
e.currentTarget as HTMLImageElement
).dataset.brokenurl = item.thumbnail || "";
handleImageError(item.thumbnail!);
}}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
onEditClicked={onEditClicked}
maxSelections={maxSelections}
bulkSelected={bulkSelectedIds.has(item.id)}
onBulkSelectToggle={() =>
toggleBulkSelect(item.id)
}
bulkSelectionMode={bulkSelectionMode}
/>
))}
</div>
</LazyDateGroup>
);
},
)}
{paginationLoading && allItems.length > 0 && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8" />
</div>