Webapp/desktop: batch download video results, refresh credits after failed generations (#1801)

## Changes

**Batch download**
- Add multi-select mode to the create-video results feed (webapp +
desktop) with a "Select" toggle in the topbar, checkboxes on completed
items, and a floating download bar
- Webapp: "Download" bundles selected videos into a single .zip
client-side (new jszip dependency, concurrent fetches, progress and
partial-failure toasts)
- Desktop: "Download" prompts for a folder and saves each selected video
as its own file via native Tauri save, with progress toasts
- Library: add a Download (zip) button to the existing bulk selection
bar
- Shared `ui-generation-list` lib gains a selection store, select
toggle, action bar, and optional selection props on the grid/list views
(existing hosts unaffected)

**Credits refresh on failed generations**
- Webapp: dispatch a credits refresh when the task queue or generation
feed observes a newly failed job (immediately and again after 2.5s so
server-side refunds settle)
- Desktop: also refresh credits when the 5s task poll discovers a
failure that the push event missed, and listen for `credits-change` in
the TopBar

**UI fixes**
- Selection download bar always sits above the promptbox regardless of
its size
- Mobile Generate/History header no longer overlaps the gallery toggles
(flex layout instead of absolute positioning)
- Reference deck top-aligns in the expanded prompt box instead of
centering
- "Play videos with sound" setting now defaults to on
This commit is contained in:
BFlat authored and GitHub committed 2026-07-23 19:54:12 +07:00
1 parent d4239a3516
commit 9f28c525ec
35 files changed
+916 -53

No files matched your search

@@ -3,6 +3,7 @@
"version": "0.0.1",
"private": true,
"dependencies": {
"jszip": "^3.10.1",
"react-aria": "^3.48.0",
"react-masonry-css": "^1.0.16"
}
@@ -7,7 +7,11 @@ import { TabSelector } from "@storyteller/ui-tab-selector";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useIsMobile } from "../ui/use-mobile";
import { GalleryAutoplayToggle, GalleryViewToggle } from "./GalleryViewToggle";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "./GalleryViewToggle";
import {
MobileCreateTabsProvider,
type MobileCreateTab,
@@ -26,6 +30,9 @@ interface CreateMediaPageShellProps {
// Video page only: playing/still toggle for video preview thumbnails,
// rendered next to the grid/list toggle on the mobile History tab.
showAutoplayToggle?: boolean;
// Multi-select + batch download toggle, rendered in the same mobile
// History-tab cluster (the desktop toggle lives in the TopBar).
showSelectToggle?: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
// Optional CTA rendered under the empty-state subtitle (e.g. a signup button
@@ -58,6 +65,7 @@ export function CreateMediaPageShell({
authChecked,
hasContent,
showAutoplayToggle = false,
showSelectToggle = false,
emptyStateTitle,
emptyStateSubtitle,
emptyStateCta,
@@ -96,19 +104,26 @@ export function CreateMediaPageShell({
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
<Seo title={title} description={description} />
<div className="relative flex items-center justify-center border-b border-ui-panel-border px-3 py-2">
{/* Flex (not absolute) keeps the toggle cluster from overlapping
the tabs — the tabs stay centered via the equal flex-1 sides and
just shift left if the cluster outgrows its half. */}
<div className="flex items-center border-b border-ui-panel-border px-3 py-2">
<div className="flex-1" />
<TabSelector
tabs={MOBILE_TABS}
activeTab={mobileTab}
onTabChange={(id) => setMobileTab(id as MobileCreateTab)}
className="w-auto"
className="w-auto shrink-0"
/>
{mobileTab === "history" && hasContent && (
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1.5">
{showAutoplayToggle && <GalleryAutoplayToggle />}
<GalleryViewToggle />
</div>
)}
<div className="flex flex-1 items-center justify-end gap-1.5">
{mobileTab === "history" && hasContent && (
<>
{showSelectToggle && <GallerySelectToggle />}
{showAutoplayToggle && <GalleryAutoplayToggle />}
<GalleryViewToggle />
</>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import {
SelectionActionBar,
useGallerySelectionStore,
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { downloadItemsAsZip } from "../../lib/download-media-zip";
import { useIsMobile } from "../ui/use-mobile";
// Floating bar shown while the generation feed is in select mode:
// "Download selected" zips the chosen items client-side. Centered within the
// content area next to the sidebar; on desktop it sits above the floating
// promptbox via the measured `bottomOffset`, on mobile (History tab — no
// promptbox) it uses a fixed offset like the library's bulk bar.
export function GallerySelectionDownloadBar({
allItems,
bottomOffset,
}: {
allItems: GalleryItem[];
bottomOffset?: number;
}) {
const isMobile = useIsMobile();
const ids = useGallerySelectionStore((s) => s.ids);
const setActive = useGallerySelectionStore((s) => s.setActive);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const isDownloading = progress !== null;
const selectedItems = useMemo(() => {
const byId = new Map(allItems.map((it) => [it.id, it] as const));
return Array.from(ids)
.map((id) => byId.get(id))
.filter(
(it): it is GalleryItem & { fullImage: string } => !!it?.fullImage,
);
}, [allItems, ids]);
const handleDownload = async () => {
if (isDownloading || selectedItems.length === 0) return;
setProgress({ done: 0, total: selectedItems.length });
try {
const { succeeded, failed } = await downloadItemsAsZip(
selectedItems.map((it) => ({
id: it.id,
url: it.fullImage,
mediaClass: it.mediaClass,
})),
{ onProgress: (done, total) => setProgress({ done, total }) },
);
if (failed === 0) {
toast.success(
`Downloaded ${succeeded} ${succeeded === 1 ? "file" : "files"}`,
);
} else if (succeeded > 0) {
toast.error(
`${failed} of ${succeeded + failed} files failed to download`,
);
} else {
toast.error("Could not download the selected files.");
}
if (succeeded > 0) setActive(false);
} finally {
setProgress(null);
}
};
return (
<SelectionActionBar
className={isMobile ? "bottom-20" : ""}
// Center within the content area (viewport minus the app sidebar); on
// desktop, sit above the fixed promptbox whatever its measured height.
style={{
left: "var(--ac-sidebar-offset, 0px)",
...(isMobile ? {} : { bottom: bottomOffset ?? 16 }),
}}
>
<button
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
{isDownloading && progress
? `Downloading ${progress.done}/${progress.total}…`
: "Download"}
</button>
</SelectionActionBar>
);
}
@@ -2,4 +2,5 @@
export {
GalleryViewToggle,
GalleryAutoplayToggle,
GallerySelectToggle,
} from "@storyteller/ui-generation-list";
@@ -1,6 +1,9 @@
import { useEffect, useMemo } from "react";
import { useGallerySelectionStore } from "@storyteller/ui-generation-list";
import { useGalleryViewStore } from "../../lib/gallery-view-store";
import { GenerationGalleryGrid } from "./GenerationGalleryGrid";
import { GenerationGalleryList } from "./GenerationGalleryList";
import { GallerySelectionDownloadBar } from "./GallerySelectionDownloadBar";
import type { GenerationGalleryProps } from "./types";
// Renders the generation feed in whichever layout the user picked via the
@@ -8,9 +11,34 @@ import type { GenerationGalleryProps } from "./types";
// rendering the grid directly on the create pages.
export function GenerationGallery(props: GenerationGalleryProps) {
const viewMode = useGalleryViewStore((s) => s.viewMode);
return viewMode === "list" ? (
<GenerationGalleryList {...props} />
) : (
<GenerationGalleryGrid {...props} />
const { selectable, selectionBarBottomOffset, newlyCompletedItems, galleryItems } =
props;
// The selection store is global (like the view-mode store) — drop select
// mode when the feed unmounts so it can't leak onto other pages.
useEffect(() => {
if (!selectable) return;
return () => useGallerySelectionStore.getState().setActive(false);
}, [selectable]);
const allItems = useMemo(
() => [...newlyCompletedItems, ...galleryItems],
[newlyCompletedItems, galleryItems],
);
return (
<>
{viewMode === "list" ? (
<GenerationGalleryList {...props} />
) : (
<GenerationGalleryGrid {...props} />
)}
{selectable && (
<GallerySelectionDownloadBar
allItems={allItems}
bottomOffset={selectionBarBottomOffset}
/>
)}
</>
);
}
@@ -13,6 +13,7 @@ import {
type GalleryItem,
is3DMediaClass,
type RecreateSlotContext,
useGallerySelectionStore,
} from "@storyteller/ui-generation-list";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { useGalleryItemActions } from "./useGalleryItemActions";
@@ -36,7 +37,10 @@ export function GenerationGalleryGrid({
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
selectable,
}: GenerationGalleryProps) {
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
return (
<GenerationGridView
inProgressJobs={inProgressJobs}
@@ -54,10 +58,17 @@ export function GenerationGalleryGrid({
renderGalleryActions={(item) => (
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
)}
selectionMode={!!selectable && selectionActive}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
// Recreate affordance for pending + failed cards. The pending card shows a
// hover-revealed icon button by the prompt; the failed card a labeled button
// next to Dismiss.
@@ -13,6 +13,7 @@ import {
type GalleryItem,
is3DMediaClass,
type RecreateSlotContext,
useGallerySelectionStore,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { useRecreateFromPromptToken } from "../../lib/recreate";
@@ -40,7 +41,10 @@ export function GenerationGalleryList({
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
selectable,
}: GenerationGalleryProps) {
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
return (
<GenerationListView
inProgressJobs={inProgressJobs}
@@ -63,10 +67,17 @@ export function GenerationGalleryList({
/>
)}
onCopyPromptResult={handleCopyResult}
selectionMode={!!selectable && selectionActive}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
function handleCopyResult(success: boolean) {
if (success) {
toast.success("Prompt copied");
@@ -16,4 +16,10 @@ export interface GenerationGalleryProps {
onLoadMore: () => void;
onGalleryItemClick: (item: GalleryItem) => void;
enableMakeVideo?: boolean;
/** Enables the multi-select + batch download flow (select toggle in the
* TopBar, checkboxes on completed items, floating download bar). */
selectable?: boolean;
/** Pixel offset that keeps the floating download bar above the page's
* fixed prompt box (pass the measured promptbox height + gap). */
selectionBarBottomOffset?: number;
}
@@ -130,8 +130,8 @@ function GeneralPanel() {
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Play videos with sound</p>
<p className="text-xs opacity-70">
When on, videos in the media viewer start unmuted so you don't have
to click unmute every time. When off (default), they start muted.
When on (default), videos in the media viewer start unmuted so you
don't have to click unmute every time. When off, they start muted.
</p>
</div>
<Switch
@@ -905,6 +905,15 @@ export const TaskQueue = () => {
);
prevFailedIdsRef.current = newFailedIdSet;
if (newlyFailedJobs.length > 0) {
// A failure may have triggered a server-side refund — nudge the
// credits chip now, and again once the refund has settled.
window.dispatchEvent(new Event("credits-change"));
setTimeout(() => {
window.dispatchEvent(new Event("credits-change"));
}, 2500);
}
for (const job of newlyFailedJobs) {
const parts = formatTitleParts(job);
const failureCategory =
@@ -2,6 +2,7 @@ import { Link, useLocation } from "react-router-dom";
import { SidebarTrigger, useSidebar } from "../ui/sidebar";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "../generation-gallery/GalleryViewToggle";
import { Breadcrumbs } from "./breadcrumbs";
@@ -19,6 +20,8 @@ export function TopBar() {
const showViewToggle = !isMobile && GALLERY_VIEW_ROUTES.has(pathname);
// Playing/still video previews only matter where the feed has videos.
const showAutoplayToggle = showViewToggle && pathname === "/create-video";
// Multi-select + batch download is wired up on the video feed only for now.
const showSelectToggle = showViewToggle && pathname === "/create-video";
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
@@ -46,6 +49,7 @@ export function TopBar() {
{/* Right: gallery layout toggle (create pages) + credits / upgrade / task queue / avatar */}
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
{showSelectToggle && <GallerySelectToggle />}
{showAutoplayToggle && <GalleryAutoplayToggle />}
{showViewToggle && <GalleryViewToggle />}
<TopBarActions />
@@ -0,0 +1,97 @@
import JSZip from "jszip";
import { addCorsParam } from "@storyteller/common";
import { extensionForUrl } from "./download-media";
// How many media fetches run at once while filling the zip.
const FETCH_CONCURRENCY = 3;
export interface ZipDownloadItem {
id: string;
url: string;
mediaClass?: string | null;
}
export interface ZipDownloadResult {
succeeded: number;
failed: number;
}
// Fetches each item (same CORS/dl params as downloadMediaFile) and saves them
// all as a single zip. A single-item selection skips the zip and saves the
// file directly. Items that fail to fetch are skipped — the zip is still
// produced as long as at least one item succeeded. Callers surface the counts.
export async function downloadItemsAsZip(
items: ZipDownloadItem[],
opts?: { onProgress?: (done: number, total: number) => void },
): Promise<ZipDownloadResult> {
if (items.length === 1) {
const item = items[0];
try {
const blob = await fetchItemBlob(item);
const ext = extensionForUrl(item.url, item.mediaClass);
saveBlob(blob, `artcraft-${item.id}.${ext}`);
opts?.onProgress?.(1, 1);
return { succeeded: 1, failed: 0 };
} catch {
opts?.onProgress?.(1, 1);
return { succeeded: 0, failed: 1 };
}
}
const zip = new JSZip();
let succeeded = 0;
let failed = 0;
let done = 0;
let next = 0;
const worker = async () => {
while (next < items.length) {
const item = items[next];
next += 1;
try {
const blob = await fetchItemBlob(item);
const ext = extensionForUrl(item.url, item.mediaClass);
zip.file(`artcraft-${item.id}.${ext}`, blob);
succeeded += 1;
} catch {
failed += 1;
}
done += 1;
opts?.onProgress?.(done, items.length);
}
};
await Promise.all(
Array.from({ length: Math.min(FETCH_CONCURRENCY, items.length) }, worker),
);
if (succeeded > 0) {
// Media files are already compressed — STORE skips pointless deflate work.
const blob = await zip.generateAsync({ type: "blob", compression: "STORE" });
saveBlob(blob, `artcraft-videos-${Date.now()}.zip`);
}
return { succeeded, failed };
}
async function fetchItemBlob(item: ZipDownloadItem): Promise<Blob> {
const corsUrl = addCorsParam(item.url) || item.url;
const response = await fetch(`${corsUrl}&dl=1`, { credentials: "omit" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob();
}
function saveBlob(blob: Blob, filename: string) {
const blobUrl = window.URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.style.display = "none";
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
window.URL.revokeObjectURL(blobUrl);
}
}
@@ -12,7 +12,7 @@ const EXT_BY_MEDIA_CLASS: Record<string, string> = {
splat: "spz",
};
function extensionForUrl(url: string, mediaClass?: string | null): string {
export function extensionForUrl(url: string, mediaClass?: string | null): string {
try {
const pathname = new URL(url, window.location.href).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Audio behavior for lightbox videos: whether they open with sound (off by
// Audio behavior for lightbox videos: whether they open with sound (on by
// default, toggled in the settings modal's General tab) and the last volume
// the user picked, so each video doesn't reset to 100%.
@@ -16,7 +16,7 @@ interface LightboxSoundStore {
export const useLightboxSoundStore = create<LightboxSoundStore>()(
persist(
(set) => ({
soundEnabled: false,
soundEnabled: true,
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
volume: 1,
setVolume: (volume) => set({ volume }),
@@ -1485,6 +1485,7 @@ export default function CreateVideo() {
authChecked={authChecked}
hasContent={hasContent}
showAutoplayToggle
showSelectToggle
emptyStateTitle="Create Video"
emptyStateSubtitle="Describe a scene. See it in motion."
emptyStateCta={
@@ -1517,6 +1518,8 @@ export default function CreateVideo() {
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={lightbox.handleGalleryItemClick}
selectable
selectionBarBottomOffset={promptHeight + 24}
/>
}
promptBox={
@@ -33,6 +33,7 @@ import {
} from "@storyteller/ui-action-reminder-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faArrowsRotate,
faBorderAll,
faCube,
@@ -53,8 +54,11 @@ import {
faEllipsis,
faCloud,
faList,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { Lightbox } from "../../components/lightbox/lightbox";
import { toast } from "../../components/toast/toast";
import { downloadItemsAsZip } from "../../lib/download-media-zip";
import {
useLibraryFoldersStore,
useLibrarySelectionStore,
@@ -1100,7 +1104,10 @@ export default function Library() {
{/* Header — sticky below navbar */}
<div
data-no-marquee
className="sticky top-0 z-50 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
// z-10 keeps the sticky header above the (static) grid tiles while
// staying below the app topbar (z-20), so topbar popovers (credits,
// avatar) aren't painted under this bar.
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
>
<div className="flex flex-col gap-6">
{/* Tabs + actions */}
@@ -2075,6 +2082,11 @@ function BulkSelectionBar({
const folderMediaItems = useLibraryFoldersStore((s) => s.folderMediaItems);
const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems);
const [popoverOpen, setPopoverOpen] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<{
done: number;
total: number;
} | null>(null);
const isDownloading = downloadProgress !== null;
// Same ordering as the sidebar / folder cards: starred first, then
// alphabetical (the store list arrives in API order).
@@ -2109,6 +2121,38 @@ function BulkSelectionBar({
if (ids.size === 0) return null;
const clear = () => useLibrarySelectionStore.getState().clear();
const handleDownloadSelected = async () => {
const downloadable = selectedItems.filter(
(it): it is GalleryItem & { fullImage: string } => !!it.fullImage,
);
if (isDownloading || downloadable.length === 0) return;
setDownloadProgress({ done: 0, total: downloadable.length });
try {
const { succeeded, failed } = await downloadItemsAsZip(
downloadable.map((it) => ({
id: it.id,
url: it.fullImage,
mediaClass: it.mediaClass,
})),
{ onProgress: (done, total) => setDownloadProgress({ done, total }) },
);
if (failed === 0) {
toast.success(
`Downloaded ${succeeded} ${succeeded === 1 ? "file" : "files"}`,
);
} else if (succeeded > 0) {
toast.error(
`${failed} of ${succeeded + failed} files failed to download`,
);
} else {
toast.error("Could not download the selected files.");
}
if (succeeded > 0) clear();
} finally {
setDownloadProgress(null);
}
};
return (
<div
data-no-marquee
@@ -2202,6 +2246,21 @@ function BulkSelectionBar({
)}
</div>
<button
type="button"
onClick={handleDownloadSelected}
disabled={isDownloading}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
{isDownloading && downloadProgress
? `Downloading ${downloadProgress.done}/${downloadProgress.total}…`
: "Download"}
</button>
<button
type="button"
onClick={onDeleteSelected}
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
@@ -13,6 +13,8 @@ import { toast } from "@storyteller/ui-toaster";
import {
GenerationListView,
GenerationGridView,
SelectionActionBar,
useGallerySelectionStore,
useGalleryViewStore,
type FailedJob,
type GalleryItem,
@@ -23,6 +25,7 @@ import {
applyRecreateFromPromptToken,
copyShareLink,
downloadMediaFileToDisk,
downloadMediaFilesToFolder,
} from "./desktopMediaActions";
// Desktop twin of the webapp's GenerationGallery: renders the shared feed in
@@ -44,13 +47,30 @@ export interface DesktopGenerationGalleryProps {
onGalleryItemClick: (item: GalleryItem) => void;
/** Image page only: show a "Make Video" quick action on image items. */
enableMakeVideo?: boolean;
/** Enables the multi-select + batch download flow (select toggle in the
* TopBar, checkboxes on completed items, floating download bar). */
selectable?: boolean;
/** Pixel offset that keeps the floating download bar above the page's
* fixed prompt box (pass the measured promptbox height + gap). */
selectionBarBottomOffset?: number;
}
export function DesktopGenerationGallery({
enableMakeVideo,
selectable,
selectionBarBottomOffset,
...feedProps
}: DesktopGenerationGalleryProps) {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const selectionActive = useGallerySelectionStore((s) => s.active);
const selectedIds = useGallerySelectionStore((s) => s.ids);
// The selection store is global (like the view-mode store) — drop select
// mode when the page unmounts (tab switch) so it can't leak elsewhere.
useEffect(() => {
if (!selectable) return;
return () => useGallerySelectionStore.getState().setActive(false);
}, [selectable]);
const handleCopyResult = useCallback((success: boolean) => {
if (success) {
@@ -60,20 +80,37 @@ export function DesktopGenerationGallery({
}
}, []);
const selectionProps = {
selectionMode: !!selectable && selectionActive,
selectedIds,
onToggleSelect: handleToggleSelect,
};
const selectionBar = selectable && (
<SelectionDownloadBar
newlyCompletedItems={feedProps.newlyCompletedItems}
galleryItems={feedProps.galleryItems}
bottomOffset={selectionBarBottomOffset}
/>
);
if (viewMode === "list") {
return (
<GenerationListView
{...feedProps}
renderGalleryActions={(item, ctx) => (
<ItemActions
item={item}
promptToken={ctx.promptToken ?? item.promptToken}
enableMakeVideo={enableMakeVideo}
variant="row"
/>
)}
onCopyPromptResult={handleCopyResult}
/>
<>
<GenerationListView
{...feedProps}
{...selectionProps}
renderGalleryActions={(item, ctx) => (
<ItemActions
item={item}
promptToken={ctx.promptToken ?? item.promptToken}
enableMakeVideo={enableMakeVideo}
variant="row"
/>
)}
onCopyPromptResult={handleCopyResult}
/>
{selectionBar}
</>
);
}
@@ -81,6 +118,7 @@ export function DesktopGenerationGallery({
<div className="px-3">
<GenerationGridView
{...feedProps}
{...selectionProps}
renderGalleryActions={(item, ctx) => (
<ItemActions
item={item}
@@ -90,10 +128,74 @@ export function DesktopGenerationGallery({
/>
)}
/>
{selectionBar}
</div>
);
}
function handleToggleSelect(item: GalleryItem) {
useGallerySelectionStore.getState().toggle(item.id);
}
// Floating bar shown in select mode: "Download" prompts for a folder and
// saves each selected item as its own file (native flow — no zip). Sits
// above the floating promptbox.
function SelectionDownloadBar({
newlyCompletedItems,
galleryItems,
bottomOffset,
}: {
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
bottomOffset?: number;
}) {
const ids = useGallerySelectionStore((s) => s.ids);
const [isDownloading, setIsDownloading] = useState(false);
const selectedItems = useMemo(() => {
const byId = new Map(
[...newlyCompletedItems, ...galleryItems].map(
(it) => [it.id, it] as const,
),
);
return Array.from(ids)
.map((id) => byId.get(id))
.filter((it): it is GalleryItem => !!it?.fullImage);
}, [newlyCompletedItems, galleryItems, ids]);
const handleDownload = useCallback(async () => {
if (isDownloading || selectedItems.length === 0) return;
setIsDownloading(true);
try {
const anySaved = await downloadMediaFilesToFolder(selectedItems);
if (anySaved) useGallerySelectionStore.getState().setActive(false);
} finally {
setIsDownloading(false);
}
}, [isDownloading, selectedItems]);
return (
// Sit above the fixed promptbox whatever its measured height is.
<SelectionActionBar
className={bottomOffset != null ? "" : "bottom-24"}
style={bottomOffset != null ? { bottom: bottomOffset } : undefined}
>
<button
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-xs ${isDownloading ? "animate-spin" : ""}`}
/>
Download
</button>
</SelectionActionBar>
);
}
// Quick actions for a completed item — desktop equivalents of the webapp's
// useGalleryItemActions: recreate re-seeds the create page from the prompt
// record, make-video seeds the video page, share copies the public media
@@ -3,6 +3,7 @@ import {
FilterMediaClasses,
PromptsApi,
downloadUrlToPath,
pickDownloadDirectory,
promptDownloadLocationIfNeeded,
} from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
@@ -29,7 +30,10 @@ import {
galleryModalVisibleDuringDrag,
galleryModalVisibleViewMode,
} from "@storyteller/ui-gallery-modal";
import { getCachedPrompt } from "@storyteller/ui-generation-list";
import {
getCachedPrompt,
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { useTabStore } from "~/pages/Stores/TabState";
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
@@ -38,6 +42,30 @@ import { useTabStore } from "~/pages/Stores/TabState";
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
audio: "mp3",
// "dimensional" is the deprecated pre-split 3D class; mesh/splat replace it.
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
function extensionForUrl(url: string, mediaClass?: string): string {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
if (match) return match[1].toLowerCase();
} catch {
// ignore — fall through to mediaClass default
}
if (mediaClass && EXT_BY_MEDIA_CLASS[mediaClass]) {
return EXT_BY_MEDIA_CLASS[mediaClass];
}
return "bin";
}
/** Download a media file, prompting for a location when configured to. */
export async function downloadMediaFileToDisk(url: string, mediaClass?: string) {
try {
@@ -63,6 +91,61 @@ export async function downloadMediaFileToDisk(url: string, mediaClass?: string)
}
}
/**
* Batch download: prompt for a directory, then save each item as its own
* file (`artcraft-{token}.{ext}`). Progress and the outcome are surfaced via
* a single updating toast. Returns true when at least one file was saved
* (false on dismiss / nothing downloadable).
*/
export async function downloadMediaFilesToFolder(
items: GalleryItem[],
): Promise<boolean> {
const downloadable = items.filter(
(it): it is GalleryItem & { fullImage: string } => !!it.fullImage,
);
if (downloadable.length === 0) return false;
const dir = await pickDownloadDirectory();
if (!dir) {
// User dismissed the picker.
return false;
}
const toastId = toast.loading(`Saving 1 of ${downloadable.length}…`);
let failedCount = 0;
for (let i = 0; i < downloadable.length; i++) {
const item = downloadable[i];
toast.loading(`Saving ${i + 1} of ${downloadable.length}…`, {
id: toastId,
});
try {
const ext = extensionForUrl(item.fullImage, item.mediaClass);
await downloadUrlToPath(
item.fullImage,
`${dir}/artcraft-${item.id}.${ext}`,
);
} catch (error) {
console.error(">>> Failed to save file:", error);
failedCount++;
}
}
const saved = downloadable.length - failedCount;
if (failedCount === 0) {
toast.success(`Saved ${saved} ${saved === 1 ? "file" : "files"}`, {
id: toastId,
});
} else if (saved > 0) {
toast.error(
`${failedCount} of ${downloadable.length} files failed to save`,
{ id: toastId },
);
} else {
toast.error("Could not save the selected files.", { id: toastId });
}
return saved > 0;
}
/** Seed the video page with `url` as the starting image and switch to it. */
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
try {
@@ -530,6 +530,10 @@ export const TaskQueue = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [unreadCompletedIds, setUnreadCompletedIds] = useState<string[]>([]);
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
// Failed-task ids already seen by the poll — seeded on first load so
// failures from previous sessions don't trigger a credits refresh.
const prevFailedIdsRef = useRef<Set<string>>(new Set());
const failedSeenInitializedRef = useRef(false);
// Confirmation state
const [confirmationConfig, setConfirmationConfig] = useState<{
isOpen: boolean;
@@ -838,6 +842,21 @@ export const TaskQueue = () => {
setCompleted(done);
setFailed(failedTasks);
// Refresh credits when a task newly enters a failed status via the
// poll — the "generation-failed-event" push below can be missed.
const newFailedIdSet = new Set(failedTasks.map((t) => t.id));
if (!failedSeenInitializedRef.current) {
failedSeenInitializedRef.current = true;
} else if (
failedTasks.some((t) => !prevFailedIdsRef.current.has(t.id))
) {
// Refunded credits may take a moment to settle in the database
setTimeout(() => {
useCreditsState.getState().fetchFromServer();
}, 2000);
}
prevFailedIdsRef.current = newFailedIdSet;
// Track newly completed IDs when popover is closed
const newCompletedIdSet = new Set(done.map((d) => d.id));
const newlyCompletedIds: string[] = [];
@@ -52,6 +52,7 @@ import {
} from "@storyteller/ui-pricing-modal";
import {
GalleryAutoplayToggle,
GallerySelectToggle,
GalleryViewToggle,
} from "@storyteller/ui-generation-list";
import { SettingsModal } from "@storyteller/ui-settings-modal";
@@ -250,7 +251,19 @@ export const TopBar = ({ pageName }: Props) => {
void useCreditsState.getState().fetchFromServer();
console.log("TopBar: Polled credits");
}, CREDITS_POLL_INTERVAL);
return () => clearInterval(interval);
// Imperative refresh requests, e.g. dispatched by the shared
// useGenerationJobs hook when it observes a newly-failed job (the server
// may have refunded the charge).
const handleCreditsChange = () => {
void useCreditsState.getState().fetchFromServer();
};
window.addEventListener("credits-change", handleCreditsChange);
return () => {
clearInterval(interval);
window.removeEventListener("credits-change", handleCreditsChange);
};
}, [authStatus]);
useCreditsBalanceChangedEvent(async () => {
@@ -502,6 +515,7 @@ export const TopBar = ({ pageName }: Props) => {
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5">
{tabStore.activeTabId === "VIDEO" && <GallerySelectToggle />}
{tabStore.activeTabId === "VIDEO" && <GalleryAutoplayToggle />}
{(tabStore.activeTabId === "IMAGE" ||
tabStore.activeTabId === "VIDEO" ||
@@ -197,6 +197,8 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={openInLightbox}
selectable
selectionBarBottomOffset={promptHeight + 32}
/>
}
promptBox={
+10 -1
View File
@@ -1,7 +1,7 @@
import { download } from "@tauri-apps/plugin-upload";
import { downloadDir } from "@tauri-apps/api/path";
import { save } from "@tauri-apps/plugin-dialog";
import { open, save } from "@tauri-apps/plugin-dialog";
const ASK_LOCATION_BEFORE_DOWNLOAD_KEY = "artcraft_ask_location_before_download";
@@ -56,6 +56,15 @@ export const promptDownloadLocationIfNeeded = async (
return chosen ?? null;
};
/**
* Prompts the user to pick a directory (e.g. for batch downloads).
* Returns the chosen absolute path, or `null` when dismissed.
*/
export const pickDownloadDirectory = async (): Promise<string | null> => {
const chosen = await open({ directory: true, multiple: false });
return typeof chosen === "string" ? chosen : null;
};
/** Downloads `url` to an explicit absolute filesystem path. */
export const downloadUrlToPath = async (url: string, path: string) => {
await download(url, path);
@@ -336,7 +336,10 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* dropdown menu */}
{mode !== "select" && (
<div
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100 transition-opacity duration-75"
// Tile chrome stays under sticky page headers (e.g. the library
// filter bar at z-10): z-[2]/z-[1] keep the in-tile ordering
// (menu/checkbox above badges) without escaping the page layers.
className="absolute right-2 top-2 z-[2] opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100 transition-opacity duration-75"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
@@ -408,7 +411,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{mode !== "select" && onBulkSelectToggle && (
<div
className={twMerge(
"absolute left-2 top-2 z-30 flex h-5 w-5 items-center justify-center rounded border-2 cursor-pointer transition-all duration-100",
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded border-2 cursor-pointer transition-all duration-100",
bulkSelected
? "bg-primary border-primary"
: "border-white/60 bg-black/40 hover:border-white",
@@ -432,7 +435,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
)}
{/* Media class badge on hover — bottom-left */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute left-2 bottom-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<div className="pointer-events-none absolute left-2 bottom-2 z-[1] rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass === "dimensional" ||
item.mediaClass === "mesh" ||
item.mediaClass === "splat"
@@ -442,7 +445,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
)}
{/* Upload badge — bottom-right (always visible, even in select mode) */}
{item.isUpload && (
<div className="pointer-events-none absolute right-2 bottom-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-black/50 backdrop-blur-lg text-white">
<div className="pointer-events-none absolute right-2 bottom-2 z-[1] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 backdrop-blur-lg text-white">
<FontAwesomeIcon icon={faUpload} className="text-[10px]" />
</div>
)}
@@ -4,8 +4,11 @@ export * from "./lib/format-time-ago";
export * from "./lib/prompts-cache";
export * from "./lib/media-prompt-token-cache";
export * from "./lib/gallery-view-store";
export * from "./lib/gallery-selection-store";
export * from "./lib/GalleryViewToggle";
export * from "./lib/GalleryAutoplayToggle";
export * from "./lib/GallerySelectToggle";
export * from "./lib/SelectionActionBar";
export * from "./lib/useGalleryData";
export * from "./lib/useSessionMediaByClass";
export * from "./lib/useGalleryEntries";
@@ -1,6 +1,7 @@
import { memo, useCallback, useState, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faCube,
faImage,
faMusic,
@@ -73,6 +74,11 @@ export interface GalleryCardProps {
title?: string;
/** Hover-revealed quick-action cluster (recreate / share / download …). */
actionsSlot?: ReactNode;
/** Multi-select mode: clicking toggles selection instead of opening the
* item, a checkbox chip is shown, and the actions pill is hidden. */
selectMode?: boolean;
selected?: boolean;
onToggleSelect?: (item: GalleryItem) => void;
}
export const GalleryCard = memo(function GalleryCard({
@@ -81,6 +87,9 @@ export const GalleryCard = memo(function GalleryCard({
shape = "auto",
title,
actionsSlot,
selectMode = false,
selected = false,
onToggleSelect,
}: GalleryCardProps) {
const isSquare = shape === "square";
const cached = aspectRatioCache.get(item.id);
@@ -131,24 +140,29 @@ export const GalleryCard = memo(function GalleryCard({
);
const handleCardClick = useCallback(() => {
if (selectMode) {
// Only downloadable (completed, URL-bearing) items are selectable.
if (item.fullImage) onToggleSelect?.(item);
return;
}
onClick(item);
}, [item, onClick]);
}, [item, onClick, selectMode, onToggleSelect]);
const handleCardKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(item);
handleCardClick();
}
},
[item, onClick],
[handleCardClick],
);
return (
<div
role="button"
tabIndex={0}
className={`group relative block w-full rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""}`}
className={`group relative block w-full rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""} ${selected ? "ring-2 ring-primary-400" : ""}`}
style={outerStyle}
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
@@ -194,6 +208,19 @@ export const GalleryCard = memo(function GalleryCard({
)}
</div>
{/* Selection checkbox chip (select mode only) */}
{selectMode && item.fullImage && (
<div
className={`pointer-events-none absolute left-2 top-2 z-10 flex h-5 w-5 items-center justify-center rounded-md border transition-colors ${
selected
? "border-primary-400 bg-primary-400 text-white"
: "border-white/60 bg-black/40 text-transparent"
}`}
>
<FontAwesomeIcon icon={faCheck} className="text-[10px]" />
</div>
)}
{/* Hover overlay with media type + model badges and quick actions */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
@@ -213,7 +240,7 @@ export const GalleryCard = memo(function GalleryCard({
)}
</div>
{actionsSlot && (
{actionsSlot && !selectMode && (
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
{actionsSlot}
</div>
@@ -1,6 +1,7 @@
import { memo, useCallback, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faCube,
faImage,
faMusic,
@@ -29,6 +30,11 @@ export interface GalleryRowProps {
/** Hover-revealed quick-action cluster (recreate / share / download …). */
actionsSlot?: ReactNode;
onCopyPromptResult?: (success: boolean) => void;
/** Multi-select mode: clicking toggles selection instead of opening the
* item, a leading checkbox is shown, and the actions cluster is hidden. */
selectMode?: boolean;
selected?: boolean;
onToggleSelect?: (item: GalleryItem) => void;
}
export const GalleryRow = memo(function GalleryRow({
@@ -39,6 +45,9 @@ export const GalleryRow = memo(function GalleryRow({
loading = false,
actionsSlot,
onCopyPromptResult,
selectMode = false,
selected = false,
onToggleSelect,
}: GalleryRowProps) {
const isVideo = item.mediaClass === "video";
const is3D = is3DMediaClass(item.mediaClass);
@@ -60,15 +69,22 @@ export const GalleryRow = memo(function GalleryRow({
? getCreatorIconPathForModelId(effectiveModelId)
: null;
const handleRowClick = useCallback(() => onClick(item), [item, onClick]);
const handleRowClick = useCallback(() => {
if (selectMode) {
// Only downloadable (completed, URL-bearing) items are selectable.
if (item.fullImage) onToggleSelect?.(item);
return;
}
onClick(item);
}, [item, onClick, selectMode, onToggleSelect]);
const handleRowKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(item);
handleRowClick();
}
},
[item, onClick],
[handleRowClick],
);
const timeAgo = formatTimeAgo(item.createdAt);
@@ -79,8 +95,21 @@ export const GalleryRow = memo(function GalleryRow({
tabIndex={0}
onClick={handleRowClick}
onKeyDown={handleRowKeyDown}
className="group flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition-colors hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400/60"
className={`group flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition-colors hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400/60 ${selected ? "bg-primary-400/10" : ""}`}
>
{/* Selection checkbox (select mode only) */}
{selectMode && (
<div
className={`pointer-events-none flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors ${
selected
? "border-primary-400 bg-primary-400 text-white"
: "border-white/60 bg-black/40 text-transparent"
} ${item.fullImage ? "" : "invisible"}`}
>
<FontAwesomeIcon icon={faCheck} className="text-[10px]" />
</div>
)}
{/* Thumbnail */}
<div className="relative size-[100px] shrink-0 overflow-hidden rounded-md bg-ui-controls/40 leading-none">
<GalleryThumbnail
@@ -153,7 +182,7 @@ export const GalleryRow = memo(function GalleryRow({
{/* Quick actions, right after the media type. Always visible on
mobile (no hover); hover-revealed on desktop. The prompt above
spans the full row width either way. */}
{actionsSlot && (
{actionsSlot && !selectMode && (
<div className="flex shrink-0 items-center gap-0.5 sm:ms-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
{actionsSlot}
</div>
@@ -0,0 +1,33 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSquareCheck } from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useGallerySelectionStore } from "./gallery-selection-store";
// TopBar toggle that puts the create-page gallery into multi-select mode.
// Sits next to GalleryViewToggle; the mode is shared with the gallery via
// useGallerySelectionStore.
export function GallerySelectToggle() {
const active = useGallerySelectionStore((s) => s.active);
const setActive = useGallerySelectionStore((s) => s.setActive);
const label = active ? "Exit selection" : "Select items";
return (
<div className="flex items-center rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5">
<Tooltip content={label} position="bottom" delay={300}>
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={() => setActive(!active)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
active
? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80"
}`}
>
<FontAwesomeIcon icon={faSquareCheck} />
</button>
</Tooltip>
</div>
);
}
@@ -48,6 +48,11 @@ export interface GenerationGridViewProps {
item: GalleryItem,
ctx: { modelId?: string; promptToken?: string },
) => ReactNode;
/** Multi-select mode for completed cards (pending/failed cards are never
* selectable). See useGallerySelectionStore for the shared state. */
selectionMode?: boolean;
selectedIds?: ReadonlySet<string>;
onToggleSelect?: (item: GalleryItem) => void;
}
export function GenerationGridView({
@@ -63,6 +68,9 @@ export function GenerationGridView({
onGalleryItemClick,
renderRecreate,
renderGalleryActions,
selectionMode = false,
selectedIds,
onToggleSelect,
}: GenerationGridViewProps) {
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
@@ -187,6 +195,9 @@ export function GenerationGridView({
modelId: entry.item.modelId,
promptToken: entry.item.promptToken,
})}
selectMode={selectionMode}
selected={selectionMode && selectedIds?.has(entry.item.id)}
onToggleSelect={onToggleSelect}
/>
)}
</div>
@@ -51,6 +51,11 @@ export interface GenerationListViewProps {
) => ReactNode;
/** Copy-prompt feedback (e.g. host toast). */
onCopyPromptResult?: (success: boolean) => void;
/** Multi-select mode for completed rows (pending/failed rows are never
* selectable). See useGallerySelectionStore for the shared state. */
selectionMode?: boolean;
selectedIds?: ReadonlySet<string>;
onToggleSelect?: (item: GalleryItem) => void;
}
export function GenerationListView({
@@ -67,6 +72,9 @@ export function GenerationListView({
renderRecreate,
renderGalleryActions,
onCopyPromptResult,
selectionMode = false,
selectedIds,
onToggleSelect,
}: GenerationListViewProps) {
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
@@ -200,6 +208,9 @@ export function GenerationListView({
promptToken: promptToken || undefined,
})}
onCopyPromptResult={onCopyPromptResult}
selectMode={selectionMode}
selected={selectionMode && selectedIds?.has(entry.item.id)}
onToggleSelect={onToggleSelect}
/>
);
})}
@@ -0,0 +1,54 @@
import type { CSSProperties, ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-solid-svg-icons";
import { useGallerySelectionStore } from "./gallery-selection-store";
export interface SelectionActionBarProps {
/** Host-provided action buttons (e.g. "Download selected"). */
children?: ReactNode;
/** Replaces the default `bottom-6` offset on the fixed, centered wrapper
* (e.g. "bottom-20 sm:bottom-4") so hosts can adjust placement without
* fighting conflicting Tailwind utilities. */
className?: string;
/** Merged onto the wrapper — e.g. a `left` override to center within the
* content area next to a sidebar. */
style?: CSSProperties;
}
// Floating pill shown while the gallery is in select mode. Styled to match
// the library/moodboard multiselect bars. Selection state comes from
// useGallerySelectionStore; the actions themselves are host-injected since
// download/share flows differ between the webapp and the desktop app.
export function SelectionActionBar({
children,
className,
style,
}: SelectionActionBarProps) {
const active = useGallerySelectionStore((s) => s.active);
const count = useGallerySelectionStore((s) => s.ids.size);
const setActive = useGallerySelectionStore((s) => s.setActive);
if (!active) return null;
return (
<div
className={`pointer-events-none fixed inset-x-0 z-40 flex justify-center ${className ?? "bottom-6"}`}
style={style}
>
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur">
<span className="px-1 text-sm font-medium text-white/80">
{count} selected
</span>
{children}
<button
type="button"
aria-label="Exit selection"
onClick={() => setActive(false)}
className="flex h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { create } from "zustand";
// Multi-select state for the create-page generation feeds. Shared between the
// TopBar "Select" toggle, the gallery views (checkbox overlays), and the
// floating SelectionActionBar. Not persisted — selection is ephemeral, and
// hosts clear it on unmount so it can't leak across pages.
interface GallerySelectionState {
active: boolean;
ids: Set<string>;
// Leaving select mode always drops the current selection.
setActive: (active: boolean) => void;
toggle: (id: string) => void;
clear: () => void;
}
export const useGallerySelectionStore = create<GallerySelectionState>(
(set) => ({
active: false,
ids: new Set<string>(),
setActive: (active) =>
set(active ? { active } : { active, ids: new Set<string>() }),
toggle: (id) =>
set((state) => {
const ids = new Set(state.ids);
if (ids.has(id)) {
ids.delete(id);
} else {
ids.add(id);
}
return { ids };
}),
clear: () => set({ ids: new Set<string>() }),
}),
);
@@ -339,6 +339,7 @@ export function useGenerationJobs(options: {
const [newlyCompleted, setNewlyCompleted] = useState<GalleryItem[]>([]);
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
const prevFailedIdsRef = useRef<Set<string>>(new Set());
const initialLoadDoneRef = useRef(false);
const promptTokens = useMemo(() => {
@@ -396,6 +397,22 @@ export function useGenerationJobs(options: {
FAILED_STATUSES.has(j.status.status),
);
// A newly-observed failure may have triggered a server-side refund, so
// nudge the credits display — once now, and again after the refund has
// had a moment to settle in the database. (Skip on first load: those
// failures happened in a previous session.)
const failedIdSet = new Set(newFailed.map((j) => j.job_token));
if (
initialLoadDoneRef.current &&
newFailed.some((j) => !prevFailedIdsRef.current.has(j.job_token))
) {
window.dispatchEvent(new Event("credits-change"));
setTimeout(() => {
window.dispatchEvent(new Event("credits-change"));
}, 2500);
}
prevFailedIdsRef.current = failedIdSet;
// Completed
const completedJobs = filtered.filter((j) =>
COMPLETED_STATUSES.has(j.status.status),
@@ -49,7 +49,7 @@ export const KeyframeCards = ({
return (
<div
className={twMerge(
"relative flex shrink-0 items-center self-center",
"relative flex shrink-0 items-center self-start",
className,
)}
>
@@ -240,7 +240,7 @@ export const ReferenceDeck = ({
return (
<div
className={twMerge(
"relative flex shrink-0 items-center self-center",
"relative flex shrink-0 items-center self-start",
className,
)}
>
+67 -2
View File
@@ -224,6 +224,7 @@
"name": "@frontend/artcraft-webapp",
"version": "0.0.1",
"dependencies": {
"jszip": "^3.10.1",
"react-aria": "^3.48.0",
"react-masonry-css": "^1.0.16"
}
@@ -16472,7 +16473,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cors": {
@@ -20880,6 +20880,11 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -22372,6 +22377,54 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -22609,6 +22662,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
@@ -27311,7 +27372,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT"
},
"node_modules/promise": {
@@ -29515,6 +29575,11 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",