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:
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"
|
||||
}
|
||||
|
||||
+24
-9
@@ -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">
|
||||
|
||||
+101
@@ -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";
|
||||
+32
-4
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+11
@@ -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.
|
||||
|
||||
+11
@@ -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}
|
||||
|
||||
+115
-13
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
Generated
+67
-2
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user