From 884729bd30ec99a1212e4865cc7bfc03877e5f57 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 12 Jul 2026 15:47:03 -0400 Subject: [PATCH] unfoldered navigation (frontend) --- .../sidebar/library-folders-nav.tsx | 17 +- .../src/components/topbar/breadcrumbs.tsx | 9 +- frontend/apps/artcraft-webapp/src/main.tsx | 2 +- .../pages/library/library-folders-store.ts | 4 +- .../src/pages/library/library.tsx | 151 ++++++++++++++++-- frontend/libs/api/src/lib/FoldersApi.ts | 34 ++++ 6 files changed, 203 insertions(+), 14 deletions(-) diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx index eb7184c1cb..7a9a90aae8 100644 --- a/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx +++ b/frontend/apps/artcraft-webapp/src/components/sidebar/library-folders-nav.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBorderAll, faFolder, + faFolderOpen, faPlus, faStar, } from "@fortawesome/pro-solid-svg-icons"; @@ -34,10 +35,12 @@ export function LibraryFoldersNav({ const onFolders = pathname === "/library/folders" || pathname.startsWith("/library/folder_"); + const onFolderless = pathname === "/library/folderless"; const onUnsorted = !onFolders && + !onFolderless && (pathname === "/library" || pathname.startsWith("/library/")); - const inLibraryArea = onUnsorted || onFolders; + const inLibraryArea = onUnsorted || onFolders || onFolderless; const folders = useLibraryFoldersStore((s) => s.folders); const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId); @@ -104,6 +107,18 @@ export function LibraryFoldersNav({ + + + + + Unfoldered + + + {inLibraryArea && rootFolders.map((folder) => ( diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx index 02a6f5847d..0d1b87f01f 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/breadcrumbs.tsx @@ -38,9 +38,16 @@ function resolveCrumbs(pathname: string): Crumb[] { const onFolders = pathname === "/library/folders" || pathname.startsWith("/library/folder_"); + const onFolderless = pathname === "/library/folderless"; return [ { label: "Library", href: "/library" }, - { label: onFolders ? "Folders" : "All Assets" }, + { + label: onFolders + ? "Folders" + : onFolderless + ? "Unfoldered" + : "All Assets", + }, ]; } if (ROUTE_CRUMBS[pathname]) return ROUTE_CRUMBS[pathname]; diff --git a/frontend/apps/artcraft-webapp/src/main.tsx b/frontend/apps/artcraft-webapp/src/main.tsx index 73e4aabbbd..f5b9c0e19e 100644 --- a/frontend/apps/artcraft-webapp/src/main.tsx +++ b/frontend/apps/artcraft-webapp/src/main.tsx @@ -24,7 +24,7 @@ if (import.meta.env.DEV) { // (disabled — it overrides the origin above and points at localhost:12345, // which breaks dev unless a local backend is running. Re-enable only when // testing against a local storyteller-web.) - // StorytellerApiHostStore.getInstance().setDevelopment(); + StorytellerApiHostStore.getInstance().setDevelopment(); } catch (e) { console.warn("Failed to set dev API host override", e); } diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts b/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts index 758092366a..738a8c4ba4 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts +++ b/frontend/apps/artcraft-webapp/src/pages/library/library-folders-store.ts @@ -166,7 +166,9 @@ export function mapRawToGalleryItem(item: any): GalleryItem { fullImage: item.media_links?.cdn_url ?? null, createdAt: item.created_at, mediaClass: item.media_class || "image", - isUpload: item.origin_category === "upload", + // The user-media list carries `origin_category`; the folderless list + // carries `is_user_upload` instead. + isUpload: item.origin_category === "upload" || item.is_user_upload === true, batchImageToken: item.maybe_batch_token, }; } diff --git a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx index 1c6223157a..58d84b7391 100644 --- a/frontend/apps/artcraft-webapp/src/pages/library/library.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/library/library.tsx @@ -9,6 +9,7 @@ import { UsersApi, GalleryModalApi, MediaFilesApi, + FoldersApi, FilterMediaClasses, FilterMediaType, } from "@storyteller/api"; @@ -68,6 +69,17 @@ const FILTERS = [ { id: "splats", label: "Splats", icon: faGlobe, route: "/library/splats" }, ]; +// Class scopes for the Unfoldered tab. Local state (not routes): the tab is +// one page and the filter maps to the endpoint's `filter_media_class` param. +const FOLDERLESS_CLASS_FILTERS = [ + { id: "all", label: "All", icon: faBorderAll }, + { id: "image", label: "Images", icon: faImage }, + { id: "video", label: "Videos", icon: faVideo }, + { id: "audio", label: "Audio", icon: faMusic }, + { id: "mesh", label: "Meshes", icon: faCube }, + { id: "splat", label: "Splats", icon: faGlobe }, +]; + const ROUTE_TO_FILTER: Record = { images: "image", videos: "video", @@ -150,9 +162,15 @@ export default function Library() { const activeFilter = filterParam ? (ROUTE_TO_FILTER[filterParam] ?? "all") : "all"; - // Top-level tab derived from the route: All Assets (flat library) vs Folders. + // Top-level tab derived from the route: All Assets (flat library), + // Folders, or Unfoldered (files in no folder at all). const onFoldersRoute = pathname === "/library/folders" || !!folderToken; - const tab: "unsorted" | "folders" = onFoldersRoute ? "folders" : "unsorted"; + const onFolderlessRoute = slug === "folderless"; + const tab: "unsorted" | "folders" | "folderless" = onFoldersRoute + ? "folders" + : onFolderlessRoute + ? "folderless" + : "unsorted"; const [username, setUsername] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(null); @@ -176,10 +194,20 @@ export default function Library() { const api = useMemo(() => new GalleryModalApi(), []); const mediaFilesApi = useMemo(() => new MediaFilesApi(), []); + const foldersApi = useMemo(() => new FoldersApi(), []); // Keyset cursor for the Meshes / Splats tabs (their endpoints paginate by // cursor, not page index). const meshSplatCursorRef = useRef(undefined); + // ── Unfoldered tab ── + // Optional media-class scope for the folderless list ("all" = unfiltered). + const [folderlessClass, setFolderlessClass] = useState("all"); + // Keyset cursor for the folderless endpoint. + const folderlessCursorRef = useRef(undefined); + // Current tab, readable from stable callbacks without re-binding them. + const tabRef = useRef(tab); + tabRef.current = tab; + // ── Folder store ────────────────────────────────────────────────────────── const folders = useLibraryFoldersStore((s) => s.folders); const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId); @@ -286,6 +314,31 @@ export default function Library() { isLoadingRef.current = true; setLoading(true); try { + if (tabRef.current === "folderless") { + // The Unfoldered tab lists files in no folder at all via the + // dedicated endpoint (server-scoped, cursor-paginated). The server + // only returns a cursor when the page was full, so its presence + // doubles as the has-more signal. + const cursor = reset ? undefined : folderlessCursorRef.current; + const response = await foldersApi.ListMediaFilesWithoutFolder({ + cursor, + limit: PAGE_SIZE, + filterMediaClass: + folderlessClass === "all" ? undefined : folderlessClass, + }); + if (response.success && response.data) { + const newItems = response.data.map(mapRawToGalleryItem); + setAllItems((prev) => (reset ? newItems : [...prev, ...newItems])); + const nextCursor = response.pagination?.maybe_cursor ?? undefined; + folderlessCursorRef.current = nextCursor; + setHasMore(!!nextCursor); + } + setLoading(false); + setInitialLoading(false); + isLoadingRef.current = false; + return; + } + 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. @@ -345,20 +398,22 @@ export default function Library() { setInitialLoading(false); isLoadingRef.current = false; }, - [username, activeFilter, pageIndex, api, mediaFilesApi], + [username, activeFilter, pageIndex, api, mediaFilesApi, foldersApi, folderlessClass], ); - // Initial load + filter change + // Initial load + filter / tab change useEffect(() => { if (!username) return; + if (tab === "folders") return; // Folder views load via the folders store. setAllItems([]); setPageIndex(0); meshSplatCursorRef.current = undefined; + folderlessCursorRef.current = undefined; setHasMore(true); setInitialLoading(true); isLoadingRef.current = false; loadItems(true); - }, [username, activeFilter]); // eslint-disable-line react-hooks/exhaustive-deps + }, [username, activeFilter, tab, folderlessClass]); // eslint-disable-line react-hooks/exhaustive-deps // Infinite scroll — listens on the real scroll container. Paginates the open // folder's media when inside a folder, otherwise the root library list. @@ -374,7 +429,11 @@ export default function Library() { if (scrollBottom >= 500) return; if (activeFolderId) { loadFolderMedia(activeFolderId, false); - } else if (tab === "unsorted" && hasMore && !isLoadingRef.current) { + } else if ( + (tab === "unsorted" || tab === "folderless") && + hasMore && + !isLoadingRef.current + ) { loadItems(); } }; @@ -396,16 +455,30 @@ export default function Library() { if (itemIds.length === 0) return; const source = useLibraryFoldersStore.getState().activeFolderId; const known = displayItemsRef.current; + // On the Unfoldered tab, items that just landed in a folder no longer + // belong in the list — drop them (and their selection) once the add + // settles. + const pruneIfFolderless = () => { + if (tabRef.current !== "folderless") return; + const removed = new Set(itemIds); + setAllItems((prev) => prev.filter((it) => !removed.has(it.id))); + useLibrarySelectionStore.getState().removeIds(itemIds); + }; if (source && source !== targetFolderId) { promptFolderDrop({ count: itemIds.length, targetFolderName: folders.find((f) => f.id === targetFolderId)?.name, onMove: () => moveMediaToFolder(itemIds, source, targetFolderId, known), - onAdd: () => addMediaToFolder(itemIds, targetFolderId, known), + onAdd: () => + addMediaToFolder(itemIds, targetFolderId, known).then( + pruneIfFolderless, + ), }); } else { - addMediaToFolder(itemIds, targetFolderId, known); + void addMediaToFolder(itemIds, targetFolderId, known).then( + pruneIfFolderless, + ); } }, [folders, addMediaToFolder, moveMediaToFolder], @@ -745,6 +818,7 @@ export default function Library() { setAllItems([]); setPageIndex(0); meshSplatCursorRef.current = undefined; + folderlessCursorRef.current = undefined; setHasMore(true); setInitialLoading(true); isLoadingRef.current = false; @@ -953,8 +1027,29 @@ export default function Library() { /> Folders + + {tab === "folderless" && ( + + )} + + Unfoldered + - {tab === "unsorted" && ( + {(tab === "unsorted" || tab === "folderless") && ( + ))} + + )} {tab === "unsorted" && (
{FILTERS.map((filter) => ( @@ -1177,7 +1302,13 @@ export default function Library() {
) : rootEmpty ? (
-

No items yet.

+

+ {tab === "folderless" + ? folderlessClass === "all" + ? "Everything is in a folder — nothing to organize." + : "No unfoldered files of this type." + : "No items yet."} +