diff --git a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx index 89c75d55a5..15dc6926ef 100644 --- a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx +++ b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx @@ -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(); +let cachedUsername: string | null = null; +const usersApiSingleton = new UsersApi(); + +/** Get cached username or fetch it once. */ +async function getCachedUsername(): Promise { + 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 ( +
+ {/* Fake date heading */} +
+
+
+ {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( +
+
+
+ ))} +
+
+ +
+ ); +}; + +// ─── 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(null); + const [isNearViewport, setIsNearViewport] = useState(eager); + // Store the last measured height so placeholders match exactly + const measuredHeightRef = useRef(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 ( +
+ ); + } + + return
{children}
; + }, +); +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>(new Set()); + const scrollContainerRef = useRef(null); const [username, setUsername] = useState(""); 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>( 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( > @@ -843,66 +1100,75 @@ export const GalleryModal = React.memo(
- {loading && allItems.length === 0 ? ( + {initialLoading && allItems.length === 0 ? ( + + ) : allItems.length === 0 && !loading ? (
-
- -
+
No items yet
) : (
- {Object.entries(groupedItems).map(([date, dateItems]) => { - const filteredItems = dateItems.filter(filterItem); - if (filteredItems.length === 0) return null; - return ( -
-

- {date} -

-
{ + const filteredItems = dateItems.filter(filterItem); + if (filteredItems.length === 0) return null; + return ( + - {filteredItems.map((item) => ( - 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} - /> - ))} -
-
- ); - })} - {loading && allItems.length > 0 && ( +

+ {date} +

+
+ {filteredItems.map((item) => ( + 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} + /> + ))} +
+ + ); + }, + )} + {paginationLoading && allItems.length > 0 && (