mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
unfoldered navigation (frontend) (#1781)
This commit is contained in:
@@ -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({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={onFolderless}
|
||||
tooltip="Unfoldered"
|
||||
>
|
||||
<Link to="/library/folderless" onClick={onNavClick}>
|
||||
<FontAwesomeIcon icon={faFolderOpen} />
|
||||
<span>Unfoldered</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{inLibraryArea &&
|
||||
rootFolders.map((folder) => (
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(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<string | undefined>(undefined);
|
||||
|
||||
// ── Unfoldered tab ──
|
||||
// Optional media-class scope for the folderless list ("all" = unfiltered).
|
||||
const [folderlessClass, setFolderlessClass] = useState<string>("all");
|
||||
// Keyset cursor for the folderless endpoint.
|
||||
const folderlessCursorRef = useRef<string | undefined>(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() {
|
||||
/>
|
||||
<span className="relative z-10">Folders</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/library/folderless"
|
||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
tab === "folderless"
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab === "folderless" && (
|
||||
<motion.span
|
||||
layoutId="library-tab-indicator"
|
||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||
/>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={faFolderOpen}
|
||||
className="relative z-10 text-xs"
|
||||
/>
|
||||
<span className="relative z-10">Unfoldered</span>
|
||||
</Link>
|
||||
</div>
|
||||
{tab === "unsorted" && (
|
||||
{(tab === "unsorted" || tab === "folderless") && (
|
||||
<button
|
||||
onClick={refreshRoot}
|
||||
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||
@@ -969,6 +1064,36 @@ export default function Library() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{tab === "folderless" && (
|
||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
|
||||
{FOLDERLESS_CLASS_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => setFolderlessClass(filter.id)}
|
||||
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
folderlessClass === filter.id
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{folderlessClass === filter.id && (
|
||||
<motion.span
|
||||
layoutId="library-folderless-filter-indicator"
|
||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||
/>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={filter.icon}
|
||||
className="relative z-10 text-xs"
|
||||
/>
|
||||
<span className="relative z-10 hidden sm:inline">
|
||||
{filter.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tab === "unsorted" && (
|
||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
|
||||
{FILTERS.map((filter) => (
|
||||
@@ -1177,7 +1302,13 @@ export default function Library() {
|
||||
</div>
|
||||
) : rootEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-white/40 text-sm mb-4">No items yet.</p>
|
||||
<p className="text-white/40 text-sm mb-4">
|
||||
{tab === "folderless"
|
||||
? folderlessClass === "all"
|
||||
? "Everything is in a folder — nothing to organize."
|
||||
: "No unfoldered files of this type."
|
||||
: "No items yet."}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/create-image">
|
||||
<Button
|
||||
|
||||
@@ -296,6 +296,40 @@ export class FoldersApi extends ApiManager {
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
/**
|
||||
* List the session user's media files that sit in no folder at all
|
||||
* (memberships pointing at soft-deleted folders count as unfoldered).
|
||||
* Newest first. Cursor-based — pass the previous response's `maybe_cursor`
|
||||
* back as `cursor`; an absent cursor means the list is exhausted.
|
||||
*/
|
||||
public ListMediaFilesWithoutFolder({
|
||||
cursor,
|
||||
limit,
|
||||
filterMediaClass,
|
||||
}: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
/** Optional media class scope (image / video / audio / mesh / splat / ...). */
|
||||
filterMediaClass?: string;
|
||||
} = {}): Promise<ApiResponse<FolderMediaFileListItem[], FolderCursor>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/media_files_without_folder`;
|
||||
const query: Record<string, string | number> = {};
|
||||
if (cursor) query.cursor = cursor;
|
||||
if (limit) query.limit = limit;
|
||||
if (filterMediaClass) query.filter_media_class = filterMediaClass;
|
||||
return this.get<{
|
||||
success: boolean;
|
||||
media_files: FolderMediaFileListItem[];
|
||||
maybe_cursor?: string | null;
|
||||
}>({ endpoint, query })
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.media_files ?? [],
|
||||
pagination: { maybe_cursor: response.maybe_cursor },
|
||||
}))
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add media files to a folder. Idempotent (INSERT IGNORE); tokens that don't
|
||||
* exist or are soft-deleted are silently skipped — returns the accepted tokens.
|
||||
|
||||
Reference in New Issue
Block a user