update main gallery

This commit is contained in:
Brandon Thomas committed 2026-07-11 20:25:14 -04:00
1 parent 9a8ffc96bb
commit ed8a7e4022
3 files changed
+85 -41

No files matched your search

@@ -12,6 +12,7 @@ import {
mergeCollageUrls,
} from "@storyteller/ui-gallery-modal";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import { is3DMediaClass } from "@storyteller/ui-generation-list";
import { toast } from "../../components/toast/toast";
// ── Bulk selection store ────────────────────────────────────────────────────
@@ -141,7 +142,10 @@ const getLabel = (item: any): string => {
case "video":
return "Video Generation";
case "dimensional":
case "mesh":
return "3D Mesh";
case "splat":
return "3D World";
default:
return "Generation";
}
@@ -149,12 +153,11 @@ const getLabel = (item: any): string => {
/** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */
export function mapRawToGalleryItem(item: any): GalleryItem {
const thumbnail =
item.media_class === "dimensional"
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
const thumbnail = is3DMediaClass(item.media_class)
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
@@ -172,12 +175,11 @@ export function mapRawToGalleryItem(item: any): GalleryItem {
function mapFolderListItemToGalleryItem(
item: FolderMediaFileListItem,
): GalleryItem {
const thumbnail =
item.media_class === "dimensional"
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
const thumbnail = is3DMediaClass(item.media_class)
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
@@ -8,6 +8,7 @@ import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import {
UsersApi,
GalleryModalApi,
MediaFilesApi,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/api";
@@ -24,6 +25,7 @@ import {
type GalleryItem,
} from "@storyteller/ui-gallery-modal";
import { PLACEHOLDER_IMAGES, is3DModelUrl } from "@storyteller/common";
import { is3DMediaClass } from "@storyteller/ui-generation-list";
import {
showActionReminder,
isActionReminderOpen,
@@ -41,6 +43,7 @@ import {
faFolderPlus,
faFolder,
faFolderOpen,
faGlobe,
faPlus,
faXmark,
faStar,
@@ -62,6 +65,7 @@ const FILTERS = [
{ id: "video", label: "Videos", icon: faVideo, route: "/library/videos" },
{ id: "audio", label: "Audio", icon: faMusic, route: "/library/audio" },
{ id: "meshes", label: "Meshes", icon: faCube, route: "/library/meshes" },
{ id: "splats", label: "Splats", icon: faGlobe, route: "/library/splats" },
];
const ROUTE_TO_FILTER: Record<string, string> = {
@@ -69,8 +73,11 @@ const ROUTE_TO_FILTER: Record<string, string> = {
videos: "video",
audio: "audio",
meshes: "meshes",
splats: "splats",
};
// Media classes for the user-list endpoint. The Meshes and Splats tabs don't
// use it — they call the session mesh/splat list endpoints instead.
const getFilterMediaClass = (
filter: string,
): FilterMediaClasses[] | undefined => {
@@ -81,14 +88,14 @@ const getFilterMediaClass = (
return [FilterMediaClasses.VIDEO];
case "audio":
return [FilterMediaClasses.AUDIO];
case "meshes":
return [FilterMediaClasses.DIMENSIONAL];
default:
return [
FilterMediaClasses.IMAGE,
FilterMediaClasses.VIDEO,
FilterMediaClasses.AUDIO,
FilterMediaClasses.DIMENSIONAL,
FilterMediaClasses.MESH,
FilterMediaClasses.SPLAT,
];
}
};
@@ -168,6 +175,10 @@ export default function Library() {
useEffect(() => () => useLibrarySelectionStore.getState().clear(), []);
const api = useMemo(() => new GalleryModalApi(), []);
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
// Keyset cursor for the Meshes / Splats tabs (their endpoints paginate by
// cursor, not page index).
const meshSplatCursorRef = useRef<string | undefined>(undefined);
// ── Folder store ──────────────────────────────────────────────────────────
const folders = useLibraryFoldersStore((s) => s.folders);
@@ -275,31 +286,57 @@ export default function Library() {
isLoadingRef.current = true;
setLoading(true);
try {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: getFilterMediaClass(activeFilter),
include_user_uploads: true,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
// Drop 3D-model cover screenshots the backend surfaces as
// "dimensional" items whose asset is actually a .png.
!(
item.media_class === "dimensional" &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
.map(mapRawToGalleryItem);
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
if (activeFilter === "meshes" || activeFilter === "splats") {
// The Meshes / Splats tabs use the session by-class list endpoints
// (server-scoped, cursor-paginated) instead of the user list.
const cursor = reset ? undefined : meshSplatCursorRef.current;
const response =
activeFilter === "splats"
? await mediaFilesApi.ListSessionSplatMediaFiles({
cursor,
page_size: PAGE_SIZE,
})
: await mediaFilesApi.ListSessionMeshMediaFiles({
cursor,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newItems = response.data.map(mapRawToGalleryItem);
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
meshSplatCursorRef.current =
response.pagination?.maybe_next ?? undefined;
setHasMore(
newItems.length >= PAGE_SIZE &&
!!response.pagination?.maybe_next,
);
}
} else {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: getFilterMediaClass(activeFilter),
include_user_uploads: true,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
// Drop 3D-model cover screenshots the backend surfaces as
// "dimensional" items whose asset is actually a .png.
!(
item.media_class === "dimensional" &&
!is3DModelUrl(item.media_links?.cdn_url)
),
)
.map(mapRawToGalleryItem);
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
}
}
} catch {
// ignore
@@ -308,7 +345,7 @@ export default function Library() {
setInitialLoading(false);
isLoadingRef.current = false;
},
[username, activeFilter, pageIndex, api],
[username, activeFilter, pageIndex, api, mediaFilesApi],
);
// Initial load + filter change
@@ -316,6 +353,7 @@ export default function Library() {
if (!username) return;
setAllItems([]);
setPageIndex(0);
meshSplatCursorRef.current = undefined;
setHasMore(true);
setInitialLoading(true);
isLoadingRef.current = false;
@@ -706,6 +744,7 @@ export default function Library() {
const refreshRoot = useCallback(() => {
setAllItems([]);
setPageIndex(0);
meshSplatCursorRef.current = undefined;
setHasMore(true);
setInitialLoading(true);
isLoadingRef.current = false;
@@ -1558,7 +1597,7 @@ function BulkThumb({ item }: { item: GalleryItem }) {
const placeholderIcon =
item.mediaClass === "video"
? faVideo
: item.mediaClass === "dimensional"
: is3DMediaClass(item.mediaClass)
? faCube
: item.mediaClass === "audio"
? faMusic
@@ -2,7 +2,10 @@ export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
/** Deprecated coarse 3D class; legacy records only. New 3D media is MESH or SPLAT. */
DIMENSIONAL = "dimensional",
MESH = "mesh",
SPLAT = "splat",
}
export enum FilterMediaType {