mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:59:28 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2029524c77 | ||
|
|
0354b68f88 | ||
|
|
5b3327f919 | ||
|
|
34300089c8 | ||
|
|
9950dfebf0 |
+10
@@ -196,6 +196,16 @@ export function CreateMediaPageShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom fade behind the floating prompt box so it stays legible
|
||||
over the feed. Sits under the prompt box (z-30). */}
|
||||
{hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
|
||||
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptBox}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-182
@@ -1,182 +1,2 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
|
||||
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
|
||||
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
|
||||
// video thumbnails 404 until the render job finishes (so we retry on a timer
|
||||
// and on tab refocus), and broken image URLs fall back to a placeholder.
|
||||
|
||||
// ── Shared visibility listener for video thumbnail retries ────────────────
|
||||
|
||||
const visibilityCallbacks = new Set<() => void>();
|
||||
|
||||
function onTabVisible(cb: () => void) {
|
||||
visibilityCallbacks.add(cb);
|
||||
if (visibilityCallbacks.size === 1) {
|
||||
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
return () => {
|
||||
visibilityCallbacks.delete(cb);
|
||||
if (visibilityCallbacks.size === 0) {
|
||||
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fireVisibilityCallbacks() {
|
||||
if (document.hidden) return;
|
||||
visibilityCallbacks.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ── Retry constants ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 20;
|
||||
const RETRY_INTERVAL = 5000;
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface GalleryThumbnailProps {
|
||||
thumbnail: string | null;
|
||||
alt: string;
|
||||
isVideo: boolean;
|
||||
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
|
||||
fallbackIcon: IconDefinition;
|
||||
// Classes for the <img> element (sizing / object-fit).
|
||||
imgClassName?: string;
|
||||
fallbackIconClassName?: string;
|
||||
// The "Loading thumbnail…" caption is too large for small list rows.
|
||||
showRetryLabel?: boolean;
|
||||
// Called on successful load — the card uses it to measure aspect ratio.
|
||||
onLoad?: (e: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
}
|
||||
|
||||
export function GalleryThumbnail({
|
||||
thumbnail,
|
||||
alt,
|
||||
isVideo,
|
||||
fallbackIcon,
|
||||
imgClassName = "block h-full w-full object-cover",
|
||||
fallbackIconClassName = "text-xl text-white/20",
|
||||
showRetryLabel = true,
|
||||
onLoad,
|
||||
}: GalleryThumbnailProps) {
|
||||
// "retrying" flips to true only after the first error, so videos with ready
|
||||
// thumbnails render the normal <img> path with zero overhead. While retrying,
|
||||
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const retryImgRef = useRef<HTMLImageElement>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const retryCountRef = useRef(0);
|
||||
|
||||
const kickRetry = useCallback(() => {
|
||||
if (!retryImgRef.current || !thumbnail) return;
|
||||
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
|
||||
}, [thumbnail]);
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
|
||||
if (document.hidden) return;
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryCountRef.current++;
|
||||
kickRetry();
|
||||
}, RETRY_INTERVAL);
|
||||
}, [thumbnail, kickRetry]);
|
||||
|
||||
// Subscribe to the shared visibility listener while a video card is retrying.
|
||||
useEffect(() => {
|
||||
if (!isVideo || !thumbnail || !retrying) return;
|
||||
const unsubscribe = onTabVisible(() => {
|
||||
retryCountRef.current = 0;
|
||||
kickRetry();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [isVideo, thumbnail, retrying, kickRetry]);
|
||||
|
||||
// Reset retry state when the thumbnail URL changes.
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}, [isVideo, thumbnail]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo && retrying) {
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
onLoad?.(e);
|
||||
},
|
||||
[isVideo, retrying, onLoad],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo) {
|
||||
setRetrying(true);
|
||||
scheduleRetry();
|
||||
} else {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallback) return;
|
||||
target.dataset.fallback = "1";
|
||||
target.src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
target.style.opacity = "0.3";
|
||||
}
|
||||
},
|
||||
[isVideo, scheduleRetry],
|
||||
);
|
||||
|
||||
if (retrying) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg text-white/30"
|
||||
/>
|
||||
{showRetryLabel && (
|
||||
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Hidden img retries in the background via ref — zero re-renders */}
|
||||
<img
|
||||
ref={retryImgRef}
|
||||
src={thumbnail!}
|
||||
alt=""
|
||||
className="absolute h-0 w-0 opacity-0"
|
||||
aria-hidden
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (thumbnail) {
|
||||
return (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={imgClassName}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon icon={fallbackIcon} className={fallbackIconClassName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryThumbnail } from "@storyteller/ui-generation-list";
|
||||
|
||||
+2
-53
@@ -1,53 +1,2 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGrid2, faListUl } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
useGalleryViewStore,
|
||||
type GalleryViewMode,
|
||||
} from "../../lib/gallery-view-store";
|
||||
|
||||
const OPTIONS: {
|
||||
mode: GalleryViewMode;
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ mode: "grid", icon: faGrid2, label: "Grid view" },
|
||||
{ mode: "list", icon: faListUl, label: "List view" },
|
||||
];
|
||||
|
||||
// Segmented grid/list switch for the create-page galleries. Lives in the
|
||||
// TopBar; the selection is shared with the gallery via useGalleryViewStore.
|
||||
export function GalleryViewToggle() {
|
||||
const viewMode = useGalleryViewStore((s) => s.viewMode);
|
||||
const setViewMode = useGalleryViewStore((s) => s.setViewMode);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Gallery layout"
|
||||
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
|
||||
>
|
||||
{OPTIONS.map(({ mode, icon, label }) => {
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Tooltip key={mode} content={label} position="bottom" delay={300}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={() => setViewMode(mode)}
|
||||
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={icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
|
||||
+163
-83
@@ -1,29 +1,26 @@
|
||||
import Masonry from "react-masonry-css";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { PendingCard } from "./PendingCard";
|
||||
import { FailedCard } from "./FailedCard";
|
||||
import { GalleryCard } from "./GalleryCard";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
GenerationGridView,
|
||||
type GalleryItem,
|
||||
type RecreateSlotContext,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { useGalleryItemActions } from "./useGalleryItemActions";
|
||||
import type { GenerationGalleryProps } from "./types";
|
||||
|
||||
// ── Grid layout constants ─────────────────────────────────────────────────
|
||||
|
||||
const BREAKPOINT_COLS = {
|
||||
default: 4,
|
||||
1280: 4,
|
||||
900: 3,
|
||||
640: 2,
|
||||
};
|
||||
|
||||
// 12px gap on both axes (≈ Tailwind gap-3).
|
||||
// ml-[-12px] on container offsets the first column's pl-[8px].
|
||||
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
|
||||
const COLUMN_CLASS = "pl-[8px]";
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
// Thin wrapper over the shared masonry grid: injects the webapp-only
|
||||
// affordances (recreate navigation, share/download actions) through the
|
||||
// view's render-prop seams. Layout and card markup live in
|
||||
// @storyteller/ui-generation-list and are shared with the desktop app.
|
||||
|
||||
export function GenerationGalleryGrid({
|
||||
inProgressJobs,
|
||||
@@ -33,80 +30,163 @@ export function GenerationGalleryGrid({
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
enableMakeVideo,
|
||||
}: GenerationGalleryProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
return (
|
||||
<GenerationGridView
|
||||
inProgressJobs={inProgressJobs}
|
||||
failedJobs={failedJobs}
|
||||
onDismissFailed={onDismissFailed}
|
||||
newlyCompletedItems={newlyCompletedItems}
|
||||
galleryItems={galleryItems}
|
||||
newlyCompletedTokens={newlyCompletedTokens}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
onLoadMore={onLoadMore}
|
||||
onGalleryItemClick={onGalleryItemClick}
|
||||
renderRecreate={(ctx) => <CardRecreateButton {...ctx} />}
|
||||
renderGalleryActions={(item) => (
|
||||
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
// 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.
|
||||
function CardRecreateButton({
|
||||
promptToken,
|
||||
mediaClass,
|
||||
kind,
|
||||
}: RecreateSlotContext) {
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
mediaClass,
|
||||
);
|
||||
|
||||
if (isInitialLoading) {
|
||||
if (kind === "failed") {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={isRecreating ? "animate-spin" : ""}
|
||||
/>
|
||||
Recreate
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Masonry
|
||||
breakpointCols={BREAKPOINT_COLS}
|
||||
className={MASONRY_CLASS}
|
||||
columnClassName={COLUMN_CLASS}
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
|
||||
>
|
||||
{mergedEntries.map((entry) => (
|
||||
<div key={entry.key} className="mb-[8px]">
|
||||
{entry.kind === "pending" && (
|
||||
<PendingCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "failed" && (
|
||||
<FailedCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
refImageUrl={entry.job.refImageUrl}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
onDismiss={onDismissFailed}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "gallery" && (
|
||||
<GalleryCard
|
||||
item={entry.item}
|
||||
onClick={onGalleryItemClick}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Masonry>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
// Hover quick actions for completed cards (recreate / make-video / share /
|
||||
// download), styled for the card's dark action pill.
|
||||
function CardActions({
|
||||
item,
|
||||
enableMakeVideo,
|
||||
}: {
|
||||
item: GalleryItem;
|
||||
enableMakeVideo?: boolean;
|
||||
}) {
|
||||
const {
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||
|
||||
const buttonClass =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<>
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className={buttonClass}
|
||||
aria-label="Download"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+188
-132
@@ -1,19 +1,30 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { PendingRow } from "./PendingRow";
|
||||
import { FailedRow } from "./FailedRow";
|
||||
import { GalleryRow } from "./GalleryRow";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
import { usePrompts } from "../../lib/prompts-cache";
|
||||
import { useMediaPromptTokens } from "../../lib/media-prompt-token-cache";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
GenerationListView,
|
||||
type GalleryItem,
|
||||
type RecreateSlotContext,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { toast } from "../toast/toast";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import {
|
||||
useGalleryItemActions,
|
||||
type GalleryItemActions,
|
||||
} from "./useGalleryItemActions";
|
||||
import type { GenerationGalleryProps } from "./types";
|
||||
|
||||
// Constrains the feed to the promptbox width (max-w-5xl) and stacks one row
|
||||
// per generation, mirroring the masonry grid's merged in-progress / failed /
|
||||
// completed feed.
|
||||
// Thin wrapper over the shared list view: injects the webapp-only affordances
|
||||
// (recreate navigation, share/download actions, toasts) through the view's
|
||||
// render-prop seams. Layout and row markup live in
|
||||
// @storyteller/ui-generation-list and are shared with the desktop app.
|
||||
|
||||
export function GenerationGalleryList({
|
||||
inProgressJobs,
|
||||
@@ -23,131 +34,176 @@ export function GenerationGalleryList({
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
enableMakeVideo,
|
||||
}: GenerationGalleryProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
|
||||
// Show the real prompt text + model on completed rows instead of the
|
||||
// "Image Generation" placeholder. The profile media-list endpoint doesn't
|
||||
// return prompt tokens yet, so for items missing one we resolve it through
|
||||
// the batch media-files endpoint (media token → prompt token), then feed the
|
||||
// prompt token into the prompts cache (prompt token → text + model). Both
|
||||
// steps are batched + cached. Pending/failed entries already carry a prompt.
|
||||
const mediaTokensNeedingPrompt = useMemo(
|
||||
() =>
|
||||
mergedEntries.flatMap((entry) =>
|
||||
entry.kind === "gallery" && !entry.item.promptToken
|
||||
? [entry.item.id]
|
||||
: [],
|
||||
),
|
||||
[mergedEntries],
|
||||
);
|
||||
const resolvedPromptTokens = useMediaPromptTokens(mediaTokensNeedingPrompt);
|
||||
|
||||
// For an item: its (non-empty) prompt token, and whether the media→prompt
|
||||
// lookup has finished. `resolved` lets rows show a skeleton while loading and
|
||||
// fall back to the label once we know there's no prompt.
|
||||
const promptStateFor = useCallback(
|
||||
(item: { id: string; promptToken?: string }) => {
|
||||
const direct = item.promptToken;
|
||||
const resolved = direct != null || resolvedPromptTokens.has(item.id);
|
||||
const promptToken = direct || resolvedPromptTokens.get(item.id) || "";
|
||||
return { promptToken, resolved };
|
||||
},
|
||||
[resolvedPromptTokens],
|
||||
);
|
||||
|
||||
const promptTokens = useMemo(() => {
|
||||
const tokens: string[] = [];
|
||||
for (const entry of mergedEntries) {
|
||||
if (entry.kind !== "gallery") continue;
|
||||
const { promptToken } = promptStateFor(entry.item);
|
||||
if (promptToken) tokens.push(promptToken);
|
||||
}
|
||||
return tokens;
|
||||
}, [mergedEntries, promptStateFor]);
|
||||
const promptsMap = usePrompts(promptTokens);
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl sm:px-4">
|
||||
<div className="flex flex-col divide-y divide-white/[0.04]">
|
||||
{mergedEntries.map((entry) => {
|
||||
if (entry.kind === "pending") {
|
||||
return (
|
||||
<PendingRow
|
||||
key={entry.key}
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "failed") {
|
||||
return (
|
||||
<FailedRow
|
||||
key={entry.key}
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
refImageUrl={entry.job.refImageUrl}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
onDismiss={onDismissFailed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { promptToken, resolved } = promptStateFor(entry.item);
|
||||
const prompt = promptToken ? promptsMap.get(promptToken) : undefined;
|
||||
// Loading while the media→prompt lookup is pending, or it found a
|
||||
// prompt token whose text hasn't arrived yet.
|
||||
const loading = !resolved || (!!promptToken && !prompt);
|
||||
return (
|
||||
<GalleryRow
|
||||
key={entry.key}
|
||||
item={entry.item}
|
||||
onClick={onGalleryItemClick}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
title={prompt?.maybe_positive_prompt?.trim() || undefined}
|
||||
modelId={prompt?.maybe_model_type || undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
<GenerationListView
|
||||
inProgressJobs={inProgressJobs}
|
||||
failedJobs={failedJobs}
|
||||
onDismissFailed={onDismissFailed}
|
||||
newlyCompletedItems={newlyCompletedItems}
|
||||
galleryItems={galleryItems}
|
||||
newlyCompletedTokens={newlyCompletedTokens}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
onLoadMore={onLoadMore}
|
||||
onGalleryItemClick={onGalleryItemClick}
|
||||
renderRecreate={(ctx) => <RowRecreateButton {...ctx} />}
|
||||
renderGalleryActions={(item, { modelId }) => (
|
||||
<GalleryRowHoverActions
|
||||
item={item}
|
||||
modelId={modelId}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
onCopyPromptResult={handleCopyResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function handleCopyResult(success: boolean) {
|
||||
if (success) {
|
||||
toast.success("Prompt copied");
|
||||
} else {
|
||||
toast.error("Unable to copy prompt");
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate button for pending + failed rows. Pending rows render it inside the
|
||||
// lib's hover-revealed cluster; failed rows show it inline (dimmer tint).
|
||||
function RowRecreateButton({
|
||||
promptToken,
|
||||
mediaClass,
|
||||
kind,
|
||||
}: RecreateSlotContext) {
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
mediaClass,
|
||||
);
|
||||
return (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={
|
||||
kind === "pending"
|
||||
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryRowHoverActions({
|
||||
item,
|
||||
modelId,
|
||||
enableMakeVideo,
|
||||
}: {
|
||||
item: GalleryItem;
|
||||
modelId?: string;
|
||||
enableMakeVideo?: boolean;
|
||||
}) {
|
||||
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
|
||||
return <GalleryRowActions actions={actions} hasDownload={!!item.fullImage} />;
|
||||
}
|
||||
|
||||
// Recreate / make-video / share / download buttons, shared by the desktop
|
||||
// (hover) and mobile (inline) clusters. Each handler stops propagation so taps
|
||||
// don't also open the lightbox.
|
||||
function GalleryRowActions({
|
||||
actions,
|
||||
hasDownload,
|
||||
}: {
|
||||
actions: GalleryItemActions;
|
||||
hasDownload: boolean;
|
||||
}) {
|
||||
const {
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = actions;
|
||||
|
||||
const buttonClass =
|
||||
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<>
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{hasDownload && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,6 @@ export { GenerationGallery } from "./GenerationGallery";
|
||||
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
|
||||
export { GenerationGalleryList } from "./GenerationGalleryList";
|
||||
export { GalleryViewToggle } from "./GalleryViewToggle";
|
||||
export { GalleryCard } from "./GalleryCard";
|
||||
export { GalleryRow } from "./GalleryRow";
|
||||
export { PendingCard, type PendingCardProps } from "./PendingCard";
|
||||
export { PendingRow } from "./PendingRow";
|
||||
export { FailedCard, type FailedCardProps } from "./FailedCard";
|
||||
export { FailedRow } from "./FailedRow";
|
||||
export { useGalleryData, type GalleryItem } from "./useGalleryData";
|
||||
export {
|
||||
useGenerationJobs,
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
// Progress + time-left derivation shared by the pending card and pending row.
|
||||
|
||||
export interface PendingStatus {
|
||||
progressPercent: number | null;
|
||||
timeLabel: string | null;
|
||||
}
|
||||
|
||||
export function derivePendingStatus(
|
||||
progress?: number,
|
||||
estimatedTimeLeftMs?: number,
|
||||
): PendingStatus {
|
||||
const progressPercent =
|
||||
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
|
||||
const isAlmostDone = progress != null && progress >= 95;
|
||||
const timeLabel = isAlmostDone
|
||||
? "Almost done..."
|
||||
: estimatedTimeLeftMs != null && estimatedTimeLeftMs > 0
|
||||
? formatTimeLeft(estimatedTimeLeftMs)
|
||||
: null;
|
||||
return { progressPercent, timeLabel };
|
||||
}
|
||||
|
||||
function formatTimeLeft(ms: number): string {
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0 && minutes > 0) return `~${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `~${hours}h`;
|
||||
if (minutes > 0) return `~${minutes}m`;
|
||||
return `~${seconds}s`;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export {
|
||||
derivePendingStatus,
|
||||
type PendingStatus,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,170 +1,2 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
GalleryModalApi,
|
||||
FilterMediaClasses,
|
||||
FilterMediaType,
|
||||
} from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
fullImage: string | null;
|
||||
createdAt: string;
|
||||
mediaClass: string;
|
||||
modelId?: string;
|
||||
batchImageToken?: string;
|
||||
// Token for the generation's prompt record. The list view resolves it
|
||||
// (via the shared prompts cache) to show the real prompt + model.
|
||||
promptToken?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
const getLabel = (item: any) => {
|
||||
if (item.maybe_title) return item.maybe_title;
|
||||
switch (item.media_class) {
|
||||
case "image":
|
||||
return "Image Generation";
|
||||
case "video":
|
||||
return "Video Generation";
|
||||
case "dimensional":
|
||||
return "3D Mesh";
|
||||
default:
|
||||
return "Generation";
|
||||
}
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useGalleryData(options: {
|
||||
username: string | null;
|
||||
filterMediaClasses: FilterMediaClasses[];
|
||||
excludeUploads?: boolean;
|
||||
}) {
|
||||
const { username, filterMediaClasses, excludeUploads } = options;
|
||||
|
||||
const [items, setItems] = useState<GalleryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
const api = useMemo(() => new GalleryModalApi(), []);
|
||||
|
||||
const mapApiItem = useCallback((item: any): GalleryItem => {
|
||||
const isDimensional = item.media_class === "dimensional";
|
||||
const thumbnail = isDimensional
|
||||
? null
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.token,
|
||||
label: getLabel(item),
|
||||
thumbnail,
|
||||
fullImage: item.media_links?.cdn_url || null,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: item.media_class || "image",
|
||||
modelId: item.maybe_model_type || undefined,
|
||||
batchImageToken: item.maybe_batch_token,
|
||||
promptToken: item.maybe_prompt_token || undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadItems = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!username) return;
|
||||
if (isLoadingRef.current) return;
|
||||
isLoadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.listUserMediaFiles({
|
||||
username,
|
||||
filter_media_classes: filterMediaClasses,
|
||||
include_user_uploads: !excludeUploads,
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) =>
|
||||
item.media_type !== FilterMediaType.SCENE_JSON &&
|
||||
!(excludeUploads && item.origin_category === "upload"),
|
||||
)
|
||||
.map(mapApiItem);
|
||||
|
||||
if (reset) {
|
||||
setItems(newItems);
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
}
|
||||
|
||||
const current = response.pagination?.current ?? 0;
|
||||
const total = response.pagination?.total_page_count ?? 1;
|
||||
setPageIndex(current + 1);
|
||||
setHasMore(current + 1 < total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
},
|
||||
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
|
||||
);
|
||||
|
||||
// Initial load + filter change. When logged out (no username), clear the
|
||||
// loading flag so the shell renders the empty state instead of a spinner.
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
isLoadingRef.current = false;
|
||||
if (!username) {
|
||||
setHasMore(false);
|
||||
setIsInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
setHasMore(true);
|
||||
setIsInitialLoading(true);
|
||||
loadItems(true);
|
||||
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasMore && !isLoadingRef.current) {
|
||||
loadItems();
|
||||
}
|
||||
}, [hasMore, loadItems]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
isLoadingRef.current = false;
|
||||
loadItems(true);
|
||||
}, [loadItems]);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh,
|
||||
removeItem,
|
||||
};
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { useGalleryData, type GalleryItem } from "@storyteller/ui-generation-list";
|
||||
|
||||
+7
-98
@@ -1,98 +1,7 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { FailedJob, InProgressJob } from "./useGenerationJobs";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
// Shared between the grid and list views: both merge the in-progress / failed /
|
||||
// completed streams into a single time-sorted feed and lazy-load more on scroll.
|
||||
|
||||
export type GalleryEntry =
|
||||
| { kind: "pending"; key: string; createdAt: number; job: InProgressJob }
|
||||
| { kind: "failed"; key: string; createdAt: number; job: FailedJob }
|
||||
| { kind: "gallery"; key: string; createdAt: number; item: GalleryItem };
|
||||
|
||||
export interface MergedEntriesInput {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
}
|
||||
|
||||
export function useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
}: MergedEntriesInput): GalleryEntry[] {
|
||||
return useMemo(() => {
|
||||
const entries: GalleryEntry[] = [];
|
||||
|
||||
for (const job of inProgressJobs) {
|
||||
entries.push({
|
||||
kind: "pending",
|
||||
key: job.id,
|
||||
createdAt: new Date(job.createdAt).getTime(),
|
||||
job,
|
||||
});
|
||||
}
|
||||
for (const job of failedJobs) {
|
||||
entries.push({
|
||||
kind: "failed",
|
||||
key: job.id,
|
||||
createdAt: new Date(job.createdAt).getTime(),
|
||||
job,
|
||||
});
|
||||
}
|
||||
for (const item of newlyCompletedItems) {
|
||||
entries.push({
|
||||
kind: "gallery",
|
||||
key: `new-${item.id}`,
|
||||
createdAt: new Date(item.createdAt).getTime(),
|
||||
item,
|
||||
});
|
||||
}
|
||||
for (const item of galleryItems) {
|
||||
if (newlyCompletedTokens.has(item.id)) continue;
|
||||
entries.push({
|
||||
kind: "gallery",
|
||||
key: item.id,
|
||||
createdAt: new Date(item.createdAt).getTime(),
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return entries;
|
||||
}, [
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
]);
|
||||
}
|
||||
|
||||
// Returns a ref to attach to a sentinel element near the end of the feed;
|
||||
// `onLoadMore` fires when it scrolls into view (with a 400px lookahead).
|
||||
export function useInfiniteScrollSentinel(
|
||||
hasMore: boolean,
|
||||
onLoadMore: () => void,
|
||||
) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || !hasMore) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, onLoadMore]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
type GalleryEntry,
|
||||
type MergedEntriesInput,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
+4
-28
@@ -11,35 +11,11 @@ import type { GalleryItem } from "./useGalleryData";
|
||||
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
// The job shapes moved to the shared generation-list lib; re-exported here so
|
||||
// existing import sites keep working.
|
||||
|
||||
export interface InProgressJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
progress: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
createdAt: string;
|
||||
batchCount?: number;
|
||||
// Prompt token + media class enable the "Recreate" action while the job is
|
||||
// still running, mirroring the failed/completed cards.
|
||||
promptToken?: string;
|
||||
mediaClass: "image" | "video";
|
||||
}
|
||||
|
||||
export interface FailedJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
promptToken?: string;
|
||||
refImageUrl?: string;
|
||||
mediaClass: "image" | "video";
|
||||
}
|
||||
export type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
|
||||
import type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,27 +1,2 @@
|
||||
// Compact relative-time formatter ("just now", "5 minutes ago", "2 months
|
||||
// ago") for gallery list rows. Kept dependency-free — the app has no date
|
||||
// library, and this avoids pulling one in for a single label.
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = MINUTE * 60;
|
||||
const DAY = HOUR * 24;
|
||||
const WEEK = DAY * 7;
|
||||
const MONTH = DAY * 30;
|
||||
const YEAR = DAY * 365;
|
||||
|
||||
const pluralize = (value: number, unit: string): string =>
|
||||
`${value} ${unit}${value === 1 ? "" : "s"} ago`;
|
||||
|
||||
export function formatTimeAgo(input: string | number | Date): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "just now";
|
||||
if (seconds < 45) return "just now";
|
||||
if (seconds < HOUR) return pluralize(Math.round(seconds / MINUTE), "minute");
|
||||
if (seconds < DAY) return pluralize(Math.round(seconds / HOUR), "hour");
|
||||
if (seconds < WEEK) return pluralize(Math.round(seconds / DAY), "day");
|
||||
if (seconds < MONTH) return pluralize(Math.round(seconds / WEEK), "week");
|
||||
if (seconds < YEAR) return pluralize(Math.round(seconds / MONTH), "month");
|
||||
return pluralize(Math.round(seconds / YEAR), "year");
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { formatTimeAgo } from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
// View mode for the create-page generation galleries. Shared between the
|
||||
// create-image / create-video pages and the TopBar toggle, and persisted so
|
||||
// the user's preference survives reloads and carries across both pages.
|
||||
|
||||
export type GalleryViewMode = "grid" | "list";
|
||||
|
||||
interface GalleryViewState {
|
||||
viewMode: GalleryViewMode;
|
||||
setViewMode: (mode: GalleryViewMode) => void;
|
||||
}
|
||||
|
||||
export const useGalleryViewStore = create<GalleryViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "grid",
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
}),
|
||||
{ name: "artcraft-gallery-view" },
|
||||
),
|
||||
);
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
// Re-exported so every webapp consumer shares the lib's store instance.
|
||||
export {
|
||||
useGalleryViewStore,
|
||||
type GalleryViewMode,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,104 +1,3 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
|
||||
// Workaround until the profile media-list endpoint returns prompt tokens:
|
||||
// resolve media token → prompt token via the batch media-files endpoint
|
||||
// (GET /v1/media_files/batch), which does include `maybe_prompt_token`. The
|
||||
// resolved prompt token is then fed into the prompts cache (usePrompts) to get
|
||||
// the prompt text + model. Mirrors prompts-cache.ts: module-level cache,
|
||||
// in-flight dedupe, and a subscribe-on-load hook.
|
||||
|
||||
// mediaToken → promptToken ("" = resolved, but the media has no prompt token).
|
||||
const tokenCache = new Map<string, string>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: MediaFilesApi | null = null;
|
||||
|
||||
// Keep request URLs (one `tokens=` param per token) comfortably under length limits.
|
||||
const CHUNK_SIZE = 50;
|
||||
|
||||
function getApi(): MediaFilesApi {
|
||||
if (!api) api = new MediaFilesApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(mediaTokens: readonly string[]): Promise<void> {
|
||||
const missing = mediaTokens.filter(
|
||||
(t) => t && !tokenCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
|
||||
try {
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
|
||||
chunks.push(missing.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
const responses = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
getApi().ListMediaFilesByTokens({ mediaTokens: chunk }),
|
||||
),
|
||||
);
|
||||
for (const response of responses) {
|
||||
if (!response.success || !response.data) continue;
|
||||
for (const file of response.data) {
|
||||
tokenCache.set(file.token, file.maybe_prompt_token ?? "");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Swallow — the finally block marks the tokens resolved (empty) so the
|
||||
// loading skeleton clears and we don't refetch in a loop.
|
||||
} finally {
|
||||
// Mark every requested token resolved. Tokens that errored or weren't
|
||||
// returned get "" (no prompt) so the row falls back to its label instead
|
||||
// of showing a skeleton forever. Always notify so skeletons clear.
|
||||
for (const t of missing) {
|
||||
if (!tokenCache.has(t)) tokenCache.set(t, "");
|
||||
inFlight.delete(t);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns mediaToken → promptToken for every token whose media→prompt lookup
|
||||
// has completed; the value is "" when the media has no prompt. A token that is
|
||||
// absent from the map is still resolving (use `.has()` to distinguish that
|
||||
// from a resolved-but-promptless item). Triggers a batched fetch for any not
|
||||
// yet resolved.
|
||||
export function useMediaPromptTokens(
|
||||
mediaTokens: readonly (string | null | undefined)[],
|
||||
): Map<string, string> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of mediaTokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list, order-independent.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, string>();
|
||||
for (const t of cleaned) {
|
||||
const promptToken = tokenCache.get(t);
|
||||
// Include "" entries so callers can tell "resolved, no prompt" (present)
|
||||
// apart from "still loading" (absent).
|
||||
if (promptToken !== undefined) result.set(t, promptToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
// Re-exported so every webapp consumer shares the lib's module-level cache.
|
||||
export { useMediaPromptTokens } from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,73 +1,3 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
|
||||
const promptCache = new Map<string, Prompts>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: PromptsApi | null = null;
|
||||
|
||||
function getApi(): PromptsApi {
|
||||
if (!api) api = new PromptsApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(tokens: readonly string[]): Promise<void> {
|
||||
const missing = tokens.filter(
|
||||
(t) => t && !promptCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
try {
|
||||
const response = await getApi().BatchGetPrompts({ tokens: missing });
|
||||
if (response.success && response.data) {
|
||||
for (const p of response.data) {
|
||||
promptCache.set(p.token, p);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
} catch {
|
||||
// Failures will be retried when usePrompts re-runs with the same tokens.
|
||||
} finally {
|
||||
for (const t of missing) inFlight.delete(t);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedPrompt(token: string): Prompts | undefined {
|
||||
return promptCache.get(token);
|
||||
}
|
||||
|
||||
export function usePrompts(tokens: readonly (string | null | undefined)[]): Map<string, Prompts> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list. Sorting keeps it stable when
|
||||
// a poll returns the same set in a different order.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
for (const t of cleaned) {
|
||||
const p = promptCache.get(t);
|
||||
if (p) result.set(t, p);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
// Re-exported so every webapp consumer shares the lib's module-level cache.
|
||||
export { getCachedPrompt, usePrompts } from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -208,6 +208,28 @@ export default function CreateImage() {
|
||||
excludeUploads: true,
|
||||
});
|
||||
|
||||
// Map job token → batch count so the pending card/row can show
|
||||
// "Generating N images" — the batch runs as a single job chip.
|
||||
const batches = useCreateImageStore((s) => s.batches);
|
||||
const jobTokenToBatchCount = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const batch of batches) {
|
||||
if (batch.jobToken && batch.requestedCount > 1) {
|
||||
map.set(batch.jobToken, batch.requestedCount);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [batches]);
|
||||
|
||||
const enrichedInProgress = useMemo(
|
||||
() =>
|
||||
jobs.inProgress.map((job) => {
|
||||
const batchCount = jobTokenToBatchCount.get(job.id);
|
||||
return batchCount ? { ...job, batchCount } : job;
|
||||
}),
|
||||
[jobs.inProgress, jobTokenToBatchCount],
|
||||
);
|
||||
|
||||
const newlyCompletedTokens = useMemo(
|
||||
() => new Set(jobs.newlyCompleted.map((i) => i.id)),
|
||||
[jobs.newlyCompleted],
|
||||
@@ -565,7 +587,7 @@ export default function CreateImage() {
|
||||
promptForm={mobileForm}
|
||||
gridContent={
|
||||
<GenerationGallery
|
||||
inProgressJobs={jobs.inProgress}
|
||||
inProgressJobs={enrichedInProgress}
|
||||
failedJobs={jobs.failed}
|
||||
onDismissFailed={jobs.dismissFailed}
|
||||
newlyCompletedItems={jobs.newlyCompleted}
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
|
||||
@apply bg-[#303034]/90 !important;
|
||||
@apply bg-[#303032]/90 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,6 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
|
||||
},
|
||||
@@ -67,6 +64,9 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch/tsconfig.lib.json"
|
||||
},
|
||||
@@ -74,16 +74,22 @@
|
||||
"path": "../../libs/components/viewer-3d/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
|
||||
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gravatar/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
|
||||
"path": "../../libs/components/tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/generation-list/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
|
||||
@@ -91,9 +97,6 @@
|
||||
{
|
||||
"path": "../../libs/components/popover/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
@@ -32,6 +29,9 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch"
|
||||
},
|
||||
@@ -39,16 +39,22 @@
|
||||
"path": "../../libs/components/viewer-3d"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gravatar"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
"path": "../../libs/components/tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/generation-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
@@ -56,9 +62,6 @@
|
||||
{
|
||||
"path": "../../libs/components/popover"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1119,10 +1119,21 @@ export const ImageTo3DExperience = ({
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex justify-center gap-2.5",
|
||||
"flex items-center justify-between gap-2.5",
|
||||
hasResults ? "mt-3" : "mt-3",
|
||||
)}
|
||||
>
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={
|
||||
variant === "world"
|
||||
? IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST
|
||||
: IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST
|
||||
}
|
||||
page={
|
||||
variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE
|
||||
}
|
||||
/>
|
||||
<GenerateButton
|
||||
variant="primary"
|
||||
icon={undefined}
|
||||
@@ -1150,23 +1161,7 @@ export const ImageTo3DExperience = ({
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={
|
||||
variant === "world"
|
||||
? IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST
|
||||
: IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST
|
||||
}
|
||||
page={variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton
|
||||
modelPage={
|
||||
variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// Shared page chrome for the desktop Generate Image / Generate Video pages,
|
||||
// mirroring the webapp's CreateMediaPageShell: a centered hero over the
|
||||
// decorative background, and a scrollable generation feed (padded past the
|
||||
// fixed promptbox) once there is anything to show.
|
||||
//
|
||||
// The hero + background behave like a splash screen: they stay up while the
|
||||
// feed is still loading (pages remount on tab switches, so this covers the
|
||||
// reload) and fade out once content arrives. With a genuinely empty library
|
||||
// they simply stay — they ARE the empty state.
|
||||
|
||||
const SPLASH_FADE_MS = 700;
|
||||
|
||||
interface DesktopCreatePageShellProps {
|
||||
hasContent: boolean;
|
||||
emptyStateTitle: string;
|
||||
emptyStateSubtitle: string;
|
||||
/** Decorative page background (fixed-position); fades out with the hero. */
|
||||
background?: ReactNode;
|
||||
/** Height of the fixed promptbox area, used to pad the feed's bottom. */
|
||||
bottomOffset: number;
|
||||
listContent: ReactNode;
|
||||
/** The fixed-position promptbox stack (caller controls its positioning). */
|
||||
promptBox: ReactNode;
|
||||
bottomRight?: ReactNode;
|
||||
}
|
||||
|
||||
export function DesktopCreatePageShell({
|
||||
hasContent,
|
||||
emptyStateTitle,
|
||||
emptyStateSubtitle,
|
||||
background,
|
||||
bottomOffset,
|
||||
listContent,
|
||||
promptBox,
|
||||
bottomRight,
|
||||
}: DesktopCreatePageShellProps) {
|
||||
const [splashState, setSplashState] = useState<
|
||||
"visible" | "fading" | "hidden"
|
||||
>("visible");
|
||||
|
||||
useEffect(() => {
|
||||
if (hasContent && splashState === "visible") {
|
||||
setSplashState("fading");
|
||||
const t = setTimeout(() => setSplashState("hidden"), SPLASH_FADE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (!hasContent && splashState === "hidden") {
|
||||
// Feed emptied out again (e.g. everything deleted) — bring it back.
|
||||
setSplashState("visible");
|
||||
}
|
||||
return undefined;
|
||||
}, [hasContent, splashState]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background">
|
||||
<div className="relative h-full w-full">
|
||||
{hasContent && (
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto pt-4"
|
||||
style={{ paddingBottom: bottomOffset + 24 }}
|
||||
>
|
||||
{listContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splashState !== "hidden" && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute inset-0 z-10 transition-opacity duration-700",
|
||||
splashState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center pb-52">
|
||||
<div className="relative z-20 flex flex-col items-center justify-center text-center drop-shadow-xl">
|
||||
<h1 className="text-7xl font-bold text-base-fg">
|
||||
{emptyStateTitle}
|
||||
</h1>
|
||||
<span className="pt-2 text-xl text-base-fg opacity-80">
|
||||
{emptyStateSubtitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{background}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom fade behind the floating prompt box so it stays legible
|
||||
over the feed. Sits under the prompt box (z-20). */}
|
||||
{hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-64 bg-gradient-to-t from-ui-background to-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptBox}
|
||||
|
||||
{bottomRight && (
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
{bottomRight}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import {
|
||||
GenerationListView,
|
||||
GenerationGridView,
|
||||
useGalleryViewStore,
|
||||
type FailedJob,
|
||||
type GalleryItem,
|
||||
type InProgressJob,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import {
|
||||
applyMakeVideoFromImage,
|
||||
applyRecreateFromPromptToken,
|
||||
copyShareLink,
|
||||
downloadMediaFileToDisk,
|
||||
} from "./desktopMediaActions";
|
||||
|
||||
// Desktop twin of the webapp's GenerationGallery: renders the shared feed in
|
||||
// whichever layout the TopBar toggle picked (grid by default), injecting the
|
||||
// desktop flavors of the quick actions (recreate / make-video / share /
|
||||
// download) through the views' render-prop seams.
|
||||
|
||||
export interface DesktopGenerationGalleryProps {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
onDismissFailed: (jobToken: string) => void;
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
hasMore: boolean;
|
||||
isLoading?: boolean;
|
||||
isInitialLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
onGalleryItemClick: (item: GalleryItem) => void;
|
||||
/** Image page only: show a "Make Video" quick action on image items. */
|
||||
enableMakeVideo?: boolean;
|
||||
}
|
||||
|
||||
export function DesktopGenerationGallery({
|
||||
enableMakeVideo,
|
||||
...feedProps
|
||||
}: DesktopGenerationGalleryProps) {
|
||||
const viewMode = useGalleryViewStore((s) => s.viewMode);
|
||||
|
||||
const handleCopyResult = useCallback((success: boolean) => {
|
||||
if (success) {
|
||||
toast.success("Prompt copied");
|
||||
} else {
|
||||
toast.error("Unable to copy prompt");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (viewMode === "list") {
|
||||
return (
|
||||
<GenerationListView
|
||||
{...feedProps}
|
||||
renderGalleryActions={(item, ctx) => (
|
||||
<ItemActions
|
||||
item={item}
|
||||
promptToken={ctx.promptToken ?? item.promptToken}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
variant="row"
|
||||
/>
|
||||
)}
|
||||
onCopyPromptResult={handleCopyResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<GenerationGridView
|
||||
{...feedProps}
|
||||
renderGalleryActions={(item, ctx) => (
|
||||
<ItemActions
|
||||
item={item}
|
||||
promptToken={ctx.promptToken ?? item.promptToken}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
variant="card"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
// link, download saves via Tauri. stopPropagation keeps taps from also
|
||||
// opening the lightbox.
|
||||
function ItemActions({
|
||||
item,
|
||||
promptToken,
|
||||
enableMakeVideo,
|
||||
variant,
|
||||
}: {
|
||||
item: GalleryItem;
|
||||
promptToken?: string;
|
||||
enableMakeVideo?: boolean;
|
||||
variant: "row" | "card";
|
||||
}) {
|
||||
const [isRecreating, setIsRecreating] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const recreateMediaClass: "image" | "video" | null = isVideo
|
||||
? "video"
|
||||
: is3D
|
||||
? null
|
||||
: "image";
|
||||
const canRecreate = !!promptToken && !!recreateMediaClass;
|
||||
const canMakeVideo = !!enableMakeVideo && !isVideo && !is3D;
|
||||
|
||||
const buttonClass =
|
||||
variant === "card"
|
||||
? "flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
: "flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
|
||||
const handleRecreate = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!promptToken || !recreateMediaClass || isRecreating) return;
|
||||
setIsRecreating(true);
|
||||
try {
|
||||
await applyRecreateFromPromptToken(promptToken, recreateMediaClass);
|
||||
} finally {
|
||||
setIsRecreating(false);
|
||||
}
|
||||
},
|
||||
[promptToken, recreateMediaClass, isRecreating],
|
||||
);
|
||||
|
||||
const handleMakeVideo = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const url = item.fullImage || item.thumbnail;
|
||||
if (!url) return;
|
||||
applyMakeVideoFromImage(url, item.id);
|
||||
},
|
||||
[item.fullImage, item.thumbnail, item.id],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const ok = await copyShareLink(item.id);
|
||||
if (ok) {
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 1500);
|
||||
}
|
||||
},
|
||||
[item.id],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!item.fullImage || isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadMediaFileToDisk(item.fullImage, item.mediaClass);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
[item.fullImage, item.mediaClass, isDownloading],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{canRecreate && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
FilterMediaClasses,
|
||||
PromptsApi,
|
||||
downloadUrlToPath,
|
||||
promptDownloadLocationIfNeeded,
|
||||
} from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import {
|
||||
RefImage,
|
||||
RefVideo,
|
||||
RefAudio,
|
||||
usePromptImageStore,
|
||||
usePromptVideoStore,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import {
|
||||
useClassyModelSelectorStore,
|
||||
ModelPage,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import {
|
||||
IMAGE_MODELS_BY_ID,
|
||||
VIDEO_MODELS_BY_ID,
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
} from "@storyteller/model-list";
|
||||
import {
|
||||
galleryModalLightboxVisible,
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalVisibleViewMode,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { getCachedPrompt } from "@storyteller/ui-generation-list";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
|
||||
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
|
||||
// the create pages' generation feed (rows + cards). All of them operate on
|
||||
// global stores via getState(), so they're safe to call from anywhere.
|
||||
|
||||
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
|
||||
|
||||
/** Download a media file, prompting for a location when configured to. */
|
||||
export async function downloadMediaFileToDisk(url: string, mediaClass?: string) {
|
||||
try {
|
||||
const chosenPath = await promptDownloadLocationIfNeeded(url);
|
||||
if (chosenPath === null) {
|
||||
// User dismissed the picker.
|
||||
return;
|
||||
}
|
||||
if (typeof chosenPath === "string") {
|
||||
await downloadUrlToPath(url, chosenPath);
|
||||
} else {
|
||||
await DownloadUrl(url);
|
||||
}
|
||||
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
|
||||
toast.success(`Downloaded 3D model`);
|
||||
} else {
|
||||
toast.success(`Downloaded ${mediaClass}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(">>> Failed to download file:", error);
|
||||
// NB: Rust/Tauri should now flash a toast instead.
|
||||
//toast.error("Failed to download file");
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the video page with `url` as the starting image and switch to it. */
|
||||
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
|
||||
try {
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: mediaToken || "",
|
||||
};
|
||||
// Update zustand store for Video directly
|
||||
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy a public share link for the media token. Returns success. */
|
||||
export async function copyShareLink(mediaToken: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${SHARE_URL_BASE}${mediaToken}`);
|
||||
toast.success("Share link copied");
|
||||
return true;
|
||||
} catch {
|
||||
toast.error("Unable to copy link");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the image or video create page from a generation's prompt record
|
||||
* (prompt text, reference media, settings, model) and switch to that page.
|
||||
*/
|
||||
export function applyRecreateFromPromptData(data: {
|
||||
promptData: Prompts;
|
||||
mediaClass: string | undefined;
|
||||
}) {
|
||||
try {
|
||||
const { promptData, mediaClass: recreateMediaClass } = data;
|
||||
const contextImages = promptData.maybe_context_images || [];
|
||||
|
||||
// Partition context images by semantic type
|
||||
const imgRefs: RefImage[] = [];
|
||||
let endFrameImage: RefImage | undefined;
|
||||
const vidRefs: RefVideo[] = [];
|
||||
const audioRefs: RefAudio[] = [];
|
||||
|
||||
for (const ci of contextImages) {
|
||||
const base = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: ci.media_links.cdn_url,
|
||||
mediaToken: ci.media_token,
|
||||
};
|
||||
|
||||
switch (ci.semantic) {
|
||||
case "vid_end_frame":
|
||||
endFrameImage = { ...base, file: new File([], "recreate-ref") };
|
||||
break;
|
||||
case "vid_ref":
|
||||
vidRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
case "audioref":
|
||||
audioRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
|
||||
imgRefs.push({ ...base, file: new File([], "recreate-ref") });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine input mode from generation_mode or context image semantics
|
||||
const hasKeyframeSemantics = contextImages.some(
|
||||
(ci) =>
|
||||
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
|
||||
);
|
||||
const inputMode: "keyframe" | "reference" =
|
||||
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
|
||||
? "keyframe"
|
||||
: promptData.maybe_generation_mode === "reference"
|
||||
? "reference"
|
||||
: imgRefs.length > 0
|
||||
? "reference"
|
||||
: "keyframe";
|
||||
|
||||
const modelStore = useClassyModelSelectorStore.getState();
|
||||
|
||||
if (recreateMediaClass === "video") {
|
||||
const videoStore = usePromptVideoStore.getState();
|
||||
|
||||
// Set model first so the UI syncs sizeOptions / durationOptions
|
||||
const videoModel = promptData.maybe_model_type
|
||||
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
|
||||
: undefined;
|
||||
if (videoModel) {
|
||||
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
|
||||
}
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
videoStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) videoStore.setReferenceImages(imgRefs);
|
||||
if (endFrameImage) videoStore.setEndFrameImage(endFrameImage);
|
||||
if (vidRefs.length > 0) videoStore.setReferenceVideos(vidRefs);
|
||||
if (audioRefs.length > 0) videoStore.setReferenceAudios(audioRefs);
|
||||
if (promptData.maybe_generate_audio !== null) {
|
||||
videoStore.setGenerateWithSound(promptData.maybe_generate_audio);
|
||||
}
|
||||
if (promptData.maybe_duration_seconds !== null) {
|
||||
videoStore.setDuration(promptData.maybe_duration_seconds);
|
||||
}
|
||||
|
||||
// Map API aspect ratio (tauriValue like "wide_sixteen_by_nine") → textLabel (like "16:9")
|
||||
if (promptData.maybe_aspect_ratio && videoModel?.sizeOptions) {
|
||||
const match = videoModel.sizeOptions.find(
|
||||
(opt) => opt.tauriValue === promptData.maybe_aspect_ratio,
|
||||
);
|
||||
if (match) {
|
||||
videoStore.setAspectRatio(match.textLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// Map API resolution (like "one_k") → video store format (like "1080p")
|
||||
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
|
||||
const resolutionMap: Record<string, string> = {
|
||||
one_k: "1080p",
|
||||
two_k: "2k",
|
||||
three_k: "3k",
|
||||
four_k: "4k",
|
||||
};
|
||||
const mapped = resolutionMap[promptData.maybe_resolution];
|
||||
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
|
||||
videoStore.setResolution(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
videoStore.setInputMode(inputMode);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
} else {
|
||||
// Default to image
|
||||
const imageStore = usePromptImageStore.getState();
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
imageStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
|
||||
if (promptData.maybe_aspect_ratio) {
|
||||
imageStore.setCommonAspectRatio(
|
||||
promptData.maybe_aspect_ratio as CommonAspectRatio,
|
||||
);
|
||||
}
|
||||
if (promptData.maybe_resolution) {
|
||||
imageStore.setCommonResolution(
|
||||
promptData.maybe_resolution as CommonResolution,
|
||||
);
|
||||
}
|
||||
|
||||
if (promptData.maybe_model_type) {
|
||||
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
|
||||
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
|
||||
}
|
||||
useTabStore.getState().setActiveTab("IMAGE");
|
||||
}
|
||||
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate from a prompt token: resolve the prompt record (shared cache
|
||||
* first, then the API), then seed the create page from it.
|
||||
*/
|
||||
export async function applyRecreateFromPromptToken(
|
||||
promptToken: string,
|
||||
mediaClass: "image" | "video",
|
||||
) {
|
||||
let promptData: Prompts | undefined = getCachedPrompt(promptToken);
|
||||
if (!promptData) {
|
||||
try {
|
||||
const res = await new PromptsApi().BatchGetPrompts({
|
||||
tokens: [promptToken],
|
||||
});
|
||||
promptData = res.success && res.data?.[0] ? res.data[0] : undefined;
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
}
|
||||
if (!promptData) {
|
||||
toast.error("Could not load the original prompt.");
|
||||
return;
|
||||
}
|
||||
applyRecreateFromPromptData({ promptData, mediaClass });
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { GetTaskQueue, MarkTaskAsDismissed } from "@storyteller/tauri-api";
|
||||
import type { TaskQueueItem } from "@storyteller/tauri-api";
|
||||
import {
|
||||
useTextToImageGenerationCompleteEvent,
|
||||
useVideoGenerationCompleteEvent,
|
||||
} from "@storyteller/tauri-events";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import {
|
||||
getMediaThumbnail,
|
||||
getThumbnailUrl,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import {
|
||||
ALL_MODELS_LIST,
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
import type {
|
||||
FailedJob,
|
||||
GalleryItem,
|
||||
InProgressJob,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import {
|
||||
getMetaForTask,
|
||||
cleanupOldEntries,
|
||||
} from "../signaled/TopBar/taskEnqueueMeta";
|
||||
|
||||
// Desktop twin of the webapp's useGenerationJobs: feeds the shared
|
||||
// GenerationListView from the Tauri task queue instead of JobsApi. Polls every
|
||||
// 5s and reloads instantly on Tauri generation events / local enqueues.
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const FAILED_STATUSES = new Set([
|
||||
"complete_failure",
|
||||
"attempt_failed",
|
||||
"dead",
|
||||
"cancelled_by_user",
|
||||
"cancelled_by_provider",
|
||||
"cancelled_by_us",
|
||||
]);
|
||||
|
||||
const FAILURE_REASON_LABEL: Record<string, string> = {
|
||||
rule_bans_user_image: "Image violates content policy",
|
||||
rule_bans_user_image_with_faces: "Images with faces are not allowed",
|
||||
rule_bans_user_text_prompt: "Text prompt violates content policy",
|
||||
rule_bans_user_content: "Content violates content policy",
|
||||
rule_bans_generated_video: "Generated video flagged by content policy",
|
||||
rule_bans_generated_audio: "Generated audio flagged by content policy",
|
||||
rule_bans_generated_content: "Generated content flagged by content policy",
|
||||
generation_failed: "Generation failed",
|
||||
unknown: "An unknown error occurred",
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDesktopGenerationFeed(options: {
|
||||
mediaType: "image" | "video";
|
||||
}) {
|
||||
const { mediaType } = options;
|
||||
const mediaApiRef = useRef(new MediaFilesApi());
|
||||
|
||||
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
|
||||
const [failed, setFailed] = useState<FailedJob[]>([]);
|
||||
const [newlyCompleted, setNewlyCompleted] = useState<GalleryItem[]>([]);
|
||||
|
||||
// Snapshot per-task durations so the fake progress stays stable.
|
||||
const durationsRef = useRef<Map<string, number>>(new Map());
|
||||
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
// Locally dismissed failed tasks — hides them until the server-side
|
||||
// dismissal lands in the next poll.
|
||||
const dismissedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { tasks } = await GetTaskQueue();
|
||||
const filtered = tasks.filter(
|
||||
(t) => getTaskMediaType(t) === mediaType && !dismissedRef.current.has(t.id),
|
||||
);
|
||||
|
||||
const newInProgress = filtered
|
||||
.filter(
|
||||
(t) => t.task_status === "pending" || t.task_status === "started",
|
||||
)
|
||||
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
|
||||
.map((t) => taskToInProgress(t, mediaType, durationsRef.current));
|
||||
|
||||
const newFailed = filtered
|
||||
.filter((t) => FAILED_STATUSES.has(String(t.task_status)))
|
||||
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
|
||||
.map((t) => taskToFailed(t, mediaType));
|
||||
|
||||
const completedTasks = filtered.filter(
|
||||
(t) => String(t.task_status) === "complete_success",
|
||||
);
|
||||
const completedIdSet = new Set(completedTasks.map((t) => t.id));
|
||||
|
||||
// Detect newly completed (skip on first load to avoid flooding —
|
||||
// history comes from the gallery API instead).
|
||||
let expandedNewItems: GalleryItem[] = [];
|
||||
if (initialLoadDoneRef.current) {
|
||||
const newOnes = completedTasks.filter(
|
||||
(t) => !prevCompletedIdsRef.current.has(t.id),
|
||||
);
|
||||
if (newOnes.length > 0) {
|
||||
// Await expansion so the pending row and its completed replacement
|
||||
// commit in the same React render — no "remove then add" gap.
|
||||
const expanded = await Promise.all(
|
||||
newOnes.map((t) =>
|
||||
taskToGalleryItems(t, mediaType, mediaApiRef.current),
|
||||
),
|
||||
);
|
||||
expandedNewItems = expanded.flat();
|
||||
}
|
||||
}
|
||||
initialLoadDoneRef.current = true;
|
||||
prevCompletedIdsRef.current = completedIdSet;
|
||||
|
||||
// Prune duration snapshots for tasks no longer in progress.
|
||||
const activeIds = new Set(newInProgress.map((j) => j.id));
|
||||
for (const id of Array.from(durationsRef.current.keys())) {
|
||||
if (!activeIds.has(id)) durationsRef.current.delete(id);
|
||||
}
|
||||
|
||||
if (expandedNewItems.length > 0) {
|
||||
setNewlyCompleted((prev) => {
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const fresh = expandedNewItems.filter((i) => !existingIds.has(i.id));
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
}
|
||||
setInProgress(newInProgress);
|
||||
setFailed(newFailed);
|
||||
} catch {
|
||||
// ignore — next poll retries
|
||||
}
|
||||
}, [mediaType]);
|
||||
|
||||
/** Prepend completed items (deduped) ahead of the next poll. */
|
||||
const addCompletedItems = useCallback((items: GalleryItem[]) => {
|
||||
if (items.length === 0) return;
|
||||
setNewlyCompleted((prev) => {
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const fresh = items.filter((i) => !existingIds.has(i.id));
|
||||
if (fresh.length === 0) return prev;
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Push-style completion: the Rust backend emits typed completion events
|
||||
// carrying the generated media inline, so results appear instantly instead
|
||||
// of waiting on the next task-queue poll. The follow-up load() reconciles
|
||||
// the pending chip; the poll's own newly-completed detection dedupes by
|
||||
// media token against these. NB: the tauri-events hooks subscribe once on
|
||||
// mount, so the callbacks must only capture stable values (mediaType is a
|
||||
// constant per page; addCompletedItems/load are stable).
|
||||
useTextToImageGenerationCompleteEvent(async (event) => {
|
||||
if (mediaType !== "image" || !event.generated_images?.length) return;
|
||||
const createdAt = new Date().toISOString();
|
||||
addCompletedItems(
|
||||
event.generated_images.map((img) => ({
|
||||
id: img.media_token,
|
||||
label: "Image Generation",
|
||||
thumbnail:
|
||||
getThumbnailUrl(img.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.LARGE,
|
||||
}) ?? img.cdn_url,
|
||||
fullImage: img.cdn_url,
|
||||
createdAt,
|
||||
mediaClass: "image",
|
||||
})),
|
||||
);
|
||||
load();
|
||||
});
|
||||
|
||||
useVideoGenerationCompleteEvent(async (event) => {
|
||||
if (mediaType !== "video" || !event.generated_video) return;
|
||||
const video = event.generated_video;
|
||||
addCompletedItems([
|
||||
{
|
||||
id: video.media_token,
|
||||
label: "Video Generation",
|
||||
thumbnail:
|
||||
getThumbnailUrl(video.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.LARGE,
|
||||
}) ?? null,
|
||||
fullImage: video.cdn_url,
|
||||
createdAt: new Date().toISOString(),
|
||||
mediaClass: "video",
|
||||
},
|
||||
]);
|
||||
load();
|
||||
});
|
||||
|
||||
// Poll + reload on Tauri generation events and local "task-queue-update"
|
||||
// dispatches (fired by the pages right after enqueueing).
|
||||
useEffect(() => {
|
||||
cleanupOldEntries();
|
||||
load();
|
||||
const intervalId = setInterval(load, POLL_INTERVAL_MS);
|
||||
|
||||
const handleTaskUpdate = () => load();
|
||||
window.addEventListener("task-queue-update", handleTaskUpdate);
|
||||
|
||||
let cancelled = false;
|
||||
const unlistenComplete: Promise<UnlistenFn> = listen(
|
||||
"generation-complete-event",
|
||||
() => {
|
||||
if (!cancelled) load();
|
||||
},
|
||||
);
|
||||
const unlistenFailed: Promise<UnlistenFn> = listen(
|
||||
"generation-failed-event",
|
||||
() => {
|
||||
if (!cancelled) load();
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("task-queue-update", handleTaskUpdate);
|
||||
unlistenComplete.then((f) => f());
|
||||
unlistenFailed.then((f) => f());
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const dismissFailed = useCallback(async (taskId: string) => {
|
||||
dismissedRef.current.add(taskId);
|
||||
setFailed((prev) => prev.filter((f) => f.id !== taskId));
|
||||
try {
|
||||
await MarkTaskAsDismissed(taskId);
|
||||
} catch {
|
||||
// ignore — the local filter already hides it
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
inProgress,
|
||||
failed,
|
||||
newlyCompleted,
|
||||
dismissFailed,
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Task mapping helpers ────────────────────────────────────────────────────
|
||||
|
||||
function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
|
||||
const taskTypeStr = t.task_type ? String(t.task_type).toLowerCase() : "";
|
||||
const modelTypeStr = t.model_type ? String(t.model_type).toLowerCase() : "";
|
||||
const isSplat =
|
||||
taskTypeStr.includes("gaussian") ||
|
||||
modelTypeStr.includes("marble") ||
|
||||
modelTypeStr.includes("worldlabs");
|
||||
const is3D =
|
||||
taskTypeStr.includes("3d") ||
|
||||
taskTypeStr.includes("object") ||
|
||||
taskTypeStr.includes("dimensional") ||
|
||||
isSplat;
|
||||
if (is3D) return "other";
|
||||
if (taskTypeStr.includes("video")) return "video";
|
||||
if (taskTypeStr.includes("image")) return "image";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getTaskModelLabel(t: TaskQueueItem): string {
|
||||
const modelType = t.model_type ? String(t.model_type) : "";
|
||||
const modelDisplay = modelType ? getModelDisplayName(modelType) : undefined;
|
||||
const provider = t.provider
|
||||
? getProviderDisplayName(String(t.provider).toLowerCase())
|
||||
: undefined;
|
||||
if (modelDisplay && provider) return `${modelDisplay} · ${provider}`;
|
||||
return modelDisplay || provider || "Unknown model";
|
||||
}
|
||||
|
||||
function taskToInProgress(
|
||||
t: TaskQueueItem,
|
||||
mediaType: "image" | "video",
|
||||
durations: Map<string, number>,
|
||||
): InProgressJob {
|
||||
const now = Date.now();
|
||||
const createdMs = t.created_at.getTime();
|
||||
|
||||
let duration = durations.get(t.id);
|
||||
if (!duration) {
|
||||
const model = t.model_type
|
||||
? ALL_MODELS_LIST.find(
|
||||
(m) =>
|
||||
m.tauriId === String(t.model_type) || m.id === String(t.model_type),
|
||||
)
|
||||
: undefined;
|
||||
duration =
|
||||
model?.progressBarTime ?? (mediaType === "video" ? 900000 : 30000);
|
||||
durations.set(t.id, duration);
|
||||
}
|
||||
|
||||
const elapsed = now - createdMs;
|
||||
const progress = Math.min(95, Math.max(0, (elapsed / duration) * 100));
|
||||
const meta = getMetaForTask(
|
||||
t.id,
|
||||
t.model_type ? String(t.model_type) : undefined,
|
||||
createdMs,
|
||||
);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
prompt: meta?.prompt ?? "",
|
||||
modelId: t.model_type ? String(t.model_type) : "",
|
||||
modelLabel: getTaskModelLabel(t),
|
||||
progress,
|
||||
estimatedTimeLeftMs: Math.max(0, duration - elapsed),
|
||||
createdAt: t.created_at.toISOString(),
|
||||
// One image task can produce a whole batch — surface the requested count
|
||||
// so the pending chip reads "Generating N images".
|
||||
batchCount: meta?.batchCount,
|
||||
mediaClass: mediaType,
|
||||
};
|
||||
}
|
||||
|
||||
function taskToFailed(
|
||||
t: TaskQueueItem,
|
||||
mediaType: "image" | "video",
|
||||
): FailedJob {
|
||||
const fr = t.failure_reason;
|
||||
const failureReason = fr
|
||||
? FAILURE_REASON_LABEL[fr.failure_type] || fr.failure_message || undefined
|
||||
: undefined;
|
||||
const failureMessage =
|
||||
fr?.failure_message && fr.failure_type !== "unknown"
|
||||
? fr.failure_message
|
||||
: undefined;
|
||||
const meta = getMetaForTask(
|
||||
t.id,
|
||||
t.model_type ? String(t.model_type) : undefined,
|
||||
t.created_at.getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
prompt: meta?.prompt ?? "",
|
||||
modelId: t.model_type ? String(t.model_type) : "",
|
||||
modelLabel: getTaskModelLabel(t),
|
||||
failureReason,
|
||||
failureMessage,
|
||||
status: String(t.task_status),
|
||||
createdAt: t.created_at.toISOString(),
|
||||
refImageUrl: meta?.refImageUrls?.[0],
|
||||
mediaClass: mediaType,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a completed task to its GalleryItem(s), expanding batch siblings. */
|
||||
async function taskToGalleryItems(
|
||||
t: TaskQueueItem,
|
||||
mediaType: "image" | "video",
|
||||
mediaFilesApi: MediaFilesApi,
|
||||
): Promise<GalleryItem[]> {
|
||||
const file = t.completed_item?.primary_media_file;
|
||||
if (!file) return [];
|
||||
|
||||
const meta = getMetaForTask(
|
||||
t.id,
|
||||
t.model_type ? String(t.model_type) : undefined,
|
||||
t.created_at.getTime(),
|
||||
);
|
||||
const label =
|
||||
meta?.prompt ||
|
||||
(mediaType === "video" ? "Video Generation" : "Image Generation");
|
||||
const modelId = t.model_type ? String(t.model_type) : undefined;
|
||||
// Sort by task creation time (not completion time) so the completed row
|
||||
// occupies the same slot the pending row held — no layout shift.
|
||||
const createdAt = t.created_at.toISOString();
|
||||
|
||||
const primary: GalleryItem = {
|
||||
id: file.token,
|
||||
label,
|
||||
thumbnail:
|
||||
getThumbnailUrl(file.maybe_thumbnail_url_template, {
|
||||
width: THUMBNAIL_SIZES.LARGE,
|
||||
}) ?? null,
|
||||
fullImage: file.cdn_url || null,
|
||||
createdAt,
|
||||
mediaClass: mediaType,
|
||||
modelId,
|
||||
batchImageToken: t.completed_item?.maybe_batch_token,
|
||||
};
|
||||
|
||||
const batchToken = t.completed_item?.maybe_batch_token;
|
||||
if (!batchToken) return [primary];
|
||||
|
||||
try {
|
||||
const batchResponse = await mediaFilesApi.GetMediaFilesByBatchToken({
|
||||
batchToken,
|
||||
});
|
||||
if (!batchResponse.success || !batchResponse.data?.length) {
|
||||
return [primary];
|
||||
}
|
||||
return batchResponse.data
|
||||
.map((batchFile: any): GalleryItem | null => {
|
||||
const cdnUrl = batchFile.media_links?.cdn_url;
|
||||
if (!cdnUrl) return null;
|
||||
const thumbnail = getMediaThumbnail(batchFile.media_links, mediaType, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
return {
|
||||
id: batchFile.token,
|
||||
label,
|
||||
thumbnail: thumbnail || cdnUrl,
|
||||
fullImage: cdnUrl,
|
||||
createdAt,
|
||||
mediaClass: mediaType,
|
||||
modelId,
|
||||
batchImageToken: batchToken,
|
||||
};
|
||||
})
|
||||
.filter((i): i is GalleryItem => i !== null);
|
||||
} catch {
|
||||
return [primary];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
// Resolve the current session's username (needed by the gallery list API).
|
||||
// Mirrors the gallery modal's session fetch; null while loading / logged out.
|
||||
export function useDesktopUsername(): string | null {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const session = await new UsersApi().GetSession();
|
||||
if (!cancelled && session.success && session.data?.user) {
|
||||
setUsername(session.data.user.username);
|
||||
}
|
||||
} catch {
|
||||
// ignore — stays null and the page shows the empty state
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return username;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const AppsQuickMenu = () => {
|
||||
return (
|
||||
<div className="grid w-[680px] grid-cols-2 gap-3">
|
||||
<div>
|
||||
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Generate</h3>
|
||||
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Create</h3>
|
||||
<div className="space-y-0.5">
|
||||
{generateApps.map((app) => (
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
faGear,
|
||||
faGem,
|
||||
faGrid2,
|
||||
faHouse,
|
||||
faImages,
|
||||
faCalculator,
|
||||
faExclamation,
|
||||
@@ -17,29 +18,12 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { signal } from "@preact/signals-react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import {
|
||||
FilterMediaClasses,
|
||||
downloadUrlToPath,
|
||||
promptDownloadLocationIfNeeded,
|
||||
} from "@storyteller/api";
|
||||
import {
|
||||
getCreatorIcon,
|
||||
ModelCreator,
|
||||
IMAGE_MODELS_BY_ID,
|
||||
VIDEO_MODELS_BY_ID,
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
} from "@storyteller/model-list";
|
||||
import {
|
||||
useClassyModelSelectorStore,
|
||||
ModelPage,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
|
||||
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
|
||||
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import {
|
||||
useCreditsBalanceChangedEvent,
|
||||
useSubscriptionPlanChangedEvent,
|
||||
@@ -67,19 +51,16 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
CreditsModal,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import {
|
||||
RefImage,
|
||||
RefVideo,
|
||||
RefAudio,
|
||||
usePromptImageStore,
|
||||
usePromptVideoStore,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
|
||||
import {
|
||||
applyMakeVideoFromImage,
|
||||
applyRecreateFromPromptData,
|
||||
downloadMediaFileToDisk,
|
||||
} from "~/components/generation-feed/desktopMediaActions";
|
||||
import { useStoryboardStore } from "~/pages/PageStoryboard";
|
||||
import { useSceneStore } from "@storyteller/ui-pagedraw";
|
||||
import { usePageSceneStore } from "@storyteller/ui-pagescene";
|
||||
@@ -117,8 +98,19 @@ type SettingsSection =
|
||||
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
|
||||
const CREDITS_POLL_INTERVAL = 60_000; // milliseconds
|
||||
|
||||
// NB: See `TabState` for the default tab.
|
||||
// NB: See `TabState` for the default tab. The Apps ("More") entry is first so
|
||||
// it's the landing tab and leftmost in the switcher.
|
||||
const appMenuTabs: MenuIconItem[] = [
|
||||
{
|
||||
id: "APPS",
|
||||
label: "Home",
|
||||
icon: <FontAwesomeIcon icon={faHouse} />,
|
||||
description: "Explore all apps and miniapps",
|
||||
large: true,
|
||||
tooltipContent: <AppsQuickMenu />,
|
||||
tooltipInteractive: true,
|
||||
tooltipPosition: "bottom",
|
||||
},
|
||||
...APP_DESCRIPTORS.map((d) => ({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
@@ -127,16 +119,6 @@ const appMenuTabs: MenuIconItem[] = [
|
||||
description: d.description,
|
||||
large: d.large,
|
||||
})),
|
||||
{
|
||||
id: "APPS",
|
||||
label: "More",
|
||||
icon: <FontAwesomeIcon icon={faGrid2} />,
|
||||
description: "Explore more apps and miniapps",
|
||||
large: true,
|
||||
tooltipContent: <AppsQuickMenu />,
|
||||
tooltipInteractive: true,
|
||||
tooltipPosition: "bottom",
|
||||
},
|
||||
];
|
||||
|
||||
export const topNavMediaId = signal<string>("");
|
||||
@@ -286,29 +268,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadFile = async (url: string, mediaClass?: string) => {
|
||||
try {
|
||||
const chosenPath = await promptDownloadLocationIfNeeded(url);
|
||||
if (chosenPath === null) {
|
||||
// User dismissed the picker.
|
||||
return;
|
||||
}
|
||||
if (typeof chosenPath === "string") {
|
||||
await downloadUrlToPath(url, chosenPath);
|
||||
} else {
|
||||
await DownloadUrl(url);
|
||||
}
|
||||
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
|
||||
toast.success(`Downloaded 3D model`);
|
||||
} else {
|
||||
toast.success(`Downloaded ${mediaClass}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(">>> Failed to download file:", error);
|
||||
// NB: Rust/Tauri should now flash a toast instead.
|
||||
//toast.error("Failed to download file");
|
||||
}
|
||||
};
|
||||
const downloadFile = downloadMediaFileToDisk;
|
||||
|
||||
const handleEditFromGallery = async (url: string, mediaId?: string) => {
|
||||
try {
|
||||
@@ -337,174 +297,9 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTurnIntoVideoFromGallery = async (
|
||||
url: string,
|
||||
mediaId?: string,
|
||||
) => {
|
||||
try {
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: mediaId || "",
|
||||
};
|
||||
// Update zustand store for Video directly
|
||||
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
const handleTurnIntoVideoFromGallery = applyMakeVideoFromImage;
|
||||
|
||||
const handleRecreateFromGallery = (data: {
|
||||
promptData: Prompts;
|
||||
mediaClass: string | undefined;
|
||||
}) => {
|
||||
try {
|
||||
const { promptData, mediaClass: recreateMediaClass } = data;
|
||||
const contextImages = promptData.maybe_context_images || [];
|
||||
|
||||
// Partition context images by semantic type
|
||||
const imgRefs: RefImage[] = [];
|
||||
let endFrameImage: RefImage | undefined;
|
||||
const vidRefs: RefVideo[] = [];
|
||||
const audioRefs: RefAudio[] = [];
|
||||
|
||||
for (const ci of contextImages) {
|
||||
const base = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: ci.media_links.cdn_url,
|
||||
mediaToken: ci.media_token,
|
||||
};
|
||||
|
||||
switch (ci.semantic) {
|
||||
case "vid_end_frame":
|
||||
endFrameImage = { ...base, file: new File([], "recreate-ref") };
|
||||
break;
|
||||
case "vid_ref":
|
||||
vidRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
case "audioref":
|
||||
audioRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
|
||||
imgRefs.push({ ...base, file: new File([], "recreate-ref") });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine input mode from generation_mode or context image semantics
|
||||
const hasKeyframeSemantics = contextImages.some(
|
||||
(ci) =>
|
||||
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
|
||||
);
|
||||
const inputMode: "keyframe" | "reference" =
|
||||
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
|
||||
? "keyframe"
|
||||
: promptData.maybe_generation_mode === "reference"
|
||||
? "reference"
|
||||
: imgRefs.length > 0
|
||||
? "reference"
|
||||
: "keyframe";
|
||||
|
||||
const modelStore = useClassyModelSelectorStore.getState();
|
||||
|
||||
if (recreateMediaClass === "video") {
|
||||
const videoStore = usePromptVideoStore.getState();
|
||||
|
||||
// Set model first so the UI syncs sizeOptions / durationOptions
|
||||
const videoModel = promptData.maybe_model_type
|
||||
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
|
||||
: undefined;
|
||||
if (videoModel) {
|
||||
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
|
||||
}
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
videoStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) videoStore.setReferenceImages(imgRefs);
|
||||
if (endFrameImage) videoStore.setEndFrameImage(endFrameImage);
|
||||
if (vidRefs.length > 0) videoStore.setReferenceVideos(vidRefs);
|
||||
if (audioRefs.length > 0) videoStore.setReferenceAudios(audioRefs);
|
||||
if (promptData.maybe_generate_audio !== null) {
|
||||
videoStore.setGenerateWithSound(promptData.maybe_generate_audio);
|
||||
}
|
||||
if (promptData.maybe_duration_seconds !== null) {
|
||||
videoStore.setDuration(promptData.maybe_duration_seconds);
|
||||
}
|
||||
|
||||
// Map API aspect ratio (tauriValue like "wide_sixteen_by_nine") → textLabel (like "16:9")
|
||||
if (promptData.maybe_aspect_ratio && videoModel?.sizeOptions) {
|
||||
const match = videoModel.sizeOptions.find(
|
||||
(opt) => opt.tauriValue === promptData.maybe_aspect_ratio,
|
||||
);
|
||||
if (match) {
|
||||
videoStore.setAspectRatio(match.textLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// Map API resolution (like "one_k") → video store format (like "1080p")
|
||||
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
|
||||
const resolutionMap: Record<string, string> = {
|
||||
one_k: "1080p",
|
||||
two_k: "2k",
|
||||
three_k: "3k",
|
||||
four_k: "4k",
|
||||
};
|
||||
const mapped = resolutionMap[promptData.maybe_resolution];
|
||||
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
|
||||
videoStore.setResolution(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
videoStore.setInputMode(inputMode);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
} else {
|
||||
// Default to image
|
||||
const imageStore = usePromptImageStore.getState();
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
imageStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
|
||||
if (promptData.maybe_aspect_ratio) {
|
||||
imageStore.setCommonAspectRatio(
|
||||
promptData.maybe_aspect_ratio as CommonAspectRatio,
|
||||
);
|
||||
}
|
||||
if (promptData.maybe_resolution) {
|
||||
imageStore.setCommonResolution(
|
||||
promptData.maybe_resolution as CommonResolution,
|
||||
);
|
||||
}
|
||||
|
||||
if (promptData.maybe_model_type) {
|
||||
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
|
||||
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
|
||||
}
|
||||
useTabStore.getState().setActiveTab("IMAGE");
|
||||
}
|
||||
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
const handleRecreateFromGallery = applyRecreateFromPromptData;
|
||||
|
||||
const handleRemoveBackgroundFromGallery = async (url: string) => {
|
||||
try {
|
||||
@@ -559,9 +354,9 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
case "3D":
|
||||
return "3D Editor";
|
||||
case "IMAGE":
|
||||
return "Text to Image";
|
||||
return "Create Image";
|
||||
case "VIDEO":
|
||||
return "Generate Video";
|
||||
return "Create Video";
|
||||
case "EDIT":
|
||||
return "Edit Image";
|
||||
case "VIDEO_FRAME_EXTRACTOR":
|
||||
@@ -575,7 +370,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
case "IMAGE_TO_3D_WORLD":
|
||||
return "Image to 3D World";
|
||||
case "APPS":
|
||||
return "ArtCraft Apps";
|
||||
return "ArtCraft";
|
||||
case "BACKGROUND_CHANGE":
|
||||
return "Background Change";
|
||||
default:
|
||||
@@ -703,6 +498,8 @@ 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 === "IMAGE" ||
|
||||
tabStore.activeTabId === "VIDEO") && <GalleryViewToggle />}
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="center"
|
||||
@@ -786,7 +583,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
onClick={toggleSubscriptionModal}
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
Support
|
||||
Upgrade
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface EnqueueMeta {
|
||||
refImageUrls?: string[];
|
||||
modelType?: string;
|
||||
timestamp: number; // Date.now() at enqueue time
|
||||
// Requested generation count when one task produces a batch (image page).
|
||||
batchCount?: number;
|
||||
}
|
||||
|
||||
interface StoredEntry {
|
||||
@@ -27,12 +29,14 @@ interface StoredEntry {
|
||||
refImageUrls?: string[]; // base64 data-URLs after conversion
|
||||
modelType?: string;
|
||||
timestamp: number;
|
||||
batchCount?: number;
|
||||
matchedTaskId?: string; // set once matched to a specific task
|
||||
}
|
||||
|
||||
interface TaskMatch {
|
||||
prompt?: string;
|
||||
refImageUrls?: string[];
|
||||
batchCount?: number;
|
||||
}
|
||||
|
||||
// In-memory cache of task_id → matched metadata
|
||||
@@ -113,6 +117,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
|
||||
refImageUrls: meta.refImageUrls,
|
||||
modelType: meta.modelType,
|
||||
timestamp: meta.timestamp,
|
||||
batchCount: meta.batchCount,
|
||||
};
|
||||
const entries = readEntries();
|
||||
entries.push(entry);
|
||||
@@ -153,6 +158,7 @@ export function getMetaForTask(
|
||||
const result: TaskMatch = {
|
||||
prompt: previousMatch.prompt,
|
||||
refImageUrls: previousMatch.refImageUrls,
|
||||
batchCount: previousMatch.batchCount,
|
||||
};
|
||||
matchedTasks.set(taskId, result);
|
||||
return result;
|
||||
@@ -187,6 +193,7 @@ export function getMetaForTask(
|
||||
const result: TaskMatch = {
|
||||
prompt: matched.prompt,
|
||||
refImageUrls: matched.refImageUrls,
|
||||
batchCount: matched.batchCount,
|
||||
};
|
||||
matchedTasks.set(taskId, result);
|
||||
return result;
|
||||
|
||||
@@ -48,12 +48,12 @@ export interface AppDescriptor {
|
||||
export const APP_DESCRIPTORS: AppDescriptor[] = [
|
||||
{
|
||||
id: "IMAGE",
|
||||
label: "Text to Image",
|
||||
label: "Create Image",
|
||||
icon: faImage,
|
||||
},
|
||||
{
|
||||
id: "VIDEO",
|
||||
label: "Generate Video",
|
||||
label: "Create Video",
|
||||
icon: faFilm,
|
||||
},
|
||||
{
|
||||
@@ -82,13 +82,115 @@ export interface FullAppItem {
|
||||
category: "generate" | "edit";
|
||||
badge?: "NEW" | "BEST" | "SOON" | "BETA";
|
||||
action?: AppId;
|
||||
/** Legacy single-tone icon-square background (e.g. "bg-blue-600/40"). Still
|
||||
* used as the fallback for the apps-page card styling. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// Per-app card palette for the Apps page (webapp-home-style cards). Derived
|
||||
// from each app's Tailwind color family so the icon box, its border, and the
|
||||
// hover gradient all share a hue. Keyed by app id; falls back to a neutral
|
||||
// slate when an id is missing.
|
||||
interface AppCardPalette {
|
||||
/** Hover gradient overlay, e.g. "from-blue-500/20 to-blue-500/0". */
|
||||
accent: string;
|
||||
/** Icon box background + border, e.g. "bg-blue-500/20 border-blue-400/30". */
|
||||
iconBg: string;
|
||||
/** Icon glyph color, e.g. "text-blue-300". */
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
const APP_CARD_PALETTES: Record<string, AppCardPalette> = {
|
||||
"text-to-image": {
|
||||
accent: "from-blue-500/20 to-blue-500/0",
|
||||
iconBg: "bg-blue-500/20 border-blue-400/30",
|
||||
iconColor: "text-blue-300",
|
||||
},
|
||||
"image-to-video": {
|
||||
accent: "from-amber-500/20 to-amber-500/0",
|
||||
iconBg: "bg-amber-500/20 border-amber-400/30",
|
||||
iconColor: "text-amber-300",
|
||||
},
|
||||
"image-to-3d-object": {
|
||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
||||
iconBg: "bg-emerald-500/20 border-emerald-400/30",
|
||||
iconColor: "text-emerald-300",
|
||||
},
|
||||
"image-to-3d-world": {
|
||||
accent: "from-sky-500/20 to-sky-500/0",
|
||||
iconBg: "bg-sky-500/20 border-sky-400/30",
|
||||
iconColor: "text-sky-300",
|
||||
},
|
||||
angles: {
|
||||
accent: "from-lime-500/20 to-lime-500/0",
|
||||
iconBg: "bg-lime-500/20 border-lime-400/30",
|
||||
iconColor: "text-lime-300",
|
||||
},
|
||||
storyboard: {
|
||||
accent: "from-fuchsia-500/20 to-fuchsia-500/0",
|
||||
iconBg: "bg-fuchsia-500/20 border-fuchsia-400/30",
|
||||
iconColor: "text-fuchsia-300",
|
||||
},
|
||||
"2d-canvas": {
|
||||
accent: "from-sky-500/20 to-sky-500/0",
|
||||
iconBg: "bg-sky-500/20 border-sky-400/30",
|
||||
iconColor: "text-sky-300",
|
||||
},
|
||||
"3d-editor": {
|
||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
||||
iconBg: "bg-emerald-500/20 border-emerald-400/30",
|
||||
iconColor: "text-emerald-300",
|
||||
},
|
||||
"edit-image": {
|
||||
accent: "from-purple-500/20 to-purple-500/0",
|
||||
iconBg: "bg-purple-500/20 border-purple-400/30",
|
||||
iconColor: "text-purple-300",
|
||||
},
|
||||
"remove-background": {
|
||||
accent: "from-violet-500/20 to-violet-500/0",
|
||||
iconBg: "bg-violet-500/20 border-violet-400/30",
|
||||
iconColor: "text-violet-300",
|
||||
},
|
||||
"video-frame-extractor": {
|
||||
accent: "from-rose-500/20 to-rose-500/0",
|
||||
iconBg: "bg-rose-500/20 border-rose-400/30",
|
||||
iconColor: "text-rose-300",
|
||||
},
|
||||
"background-change": {
|
||||
accent: "from-orange-500/20 to-orange-500/0",
|
||||
iconBg: "bg-orange-500/20 border-orange-400/30",
|
||||
iconColor: "text-orange-300",
|
||||
},
|
||||
"video-editor": {
|
||||
accent: "from-teal-500/20 to-teal-500/0",
|
||||
iconBg: "bg-teal-500/20 border-teal-400/30",
|
||||
iconColor: "text-teal-300",
|
||||
},
|
||||
"video-watermark-removal": {
|
||||
accent: "from-cyan-500/20 to-cyan-500/0",
|
||||
iconBg: "bg-cyan-500/20 border-cyan-400/30",
|
||||
iconColor: "text-cyan-300",
|
||||
},
|
||||
"image-watermark-removal": {
|
||||
accent: "from-indigo-500/20 to-indigo-500/0",
|
||||
iconBg: "bg-indigo-500/20 border-indigo-400/30",
|
||||
iconColor: "text-indigo-300",
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_APP_CARD_PALETTE: AppCardPalette = {
|
||||
accent: "from-white/10 to-white/0",
|
||||
iconBg: "bg-ui-controls border-ui-controls-border",
|
||||
iconColor: "text-base-fg",
|
||||
};
|
||||
|
||||
export const getAppCardPalette = (id: string): AppCardPalette =>
|
||||
APP_CARD_PALETTES[id] ?? FALLBACK_APP_CARD_PALETTE;
|
||||
|
||||
export const ALL_APPS: FullAppItem[] = [
|
||||
{
|
||||
id: "text-to-image",
|
||||
label: "Text to Image",
|
||||
label: "Create Image",
|
||||
description: "Generate AI images",
|
||||
icon: faImage,
|
||||
category: "generate",
|
||||
@@ -97,7 +199,7 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
},
|
||||
{
|
||||
id: "image-to-video",
|
||||
label: "Generate Video",
|
||||
label: "Create Video",
|
||||
description: "Create video from images",
|
||||
icon: faFilm,
|
||||
category: "generate",
|
||||
@@ -213,7 +315,7 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
label: "Image Editor",
|
||||
description: "Easy edits. Great for graphic design.",
|
||||
icon: faPenNib,
|
||||
category: "generate",
|
||||
category: "edit",
|
||||
action: "2D",
|
||||
color: "bg-sky-500/40",
|
||||
},
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button, GenerateButton } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { MediaUploadApi, MediaFilesApi, downloadFileFromUrl } from "@storyteller/api";
|
||||
import {
|
||||
MediaUploadApi,
|
||||
MediaFilesApi,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -43,7 +47,11 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import {
|
||||
HistoryStack,
|
||||
type ImageBundle,
|
||||
type BaseSelectorImage,
|
||||
} from "@storyteller/ui-pagedraw";
|
||||
|
||||
const ANGLES_MODEL_PAGE = ModelPage.Angles;
|
||||
|
||||
@@ -76,7 +84,9 @@ export const Angles = () => {
|
||||
// State selectors (only re-render when specific values change)
|
||||
const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl);
|
||||
const sourceMediaToken = useAnglesStore((s) => s.sourceMediaToken);
|
||||
const sourceThumbnailUrlTemplate = useAnglesStore((s) => s.sourceThumbnailUrlTemplate);
|
||||
const sourceThumbnailUrlTemplate = useAnglesStore(
|
||||
(s) => s.sourceThumbnailUrlTemplate,
|
||||
);
|
||||
const imageDimensions = useAnglesStore((s) => s.imageDimensions);
|
||||
const angleConfig = useAnglesStore((s) => s.angleConfig);
|
||||
// const generateFromBestAngles = useAnglesStore(
|
||||
@@ -194,11 +204,23 @@ export const Angles = () => {
|
||||
let thumbnailUrlTemplate: string | undefined;
|
||||
try {
|
||||
const api = new MediaFilesApi();
|
||||
const mediaFile = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
|
||||
const mediaFile = await api.GetMediaFileByToken({
|
||||
mediaFileToken: mediaToken,
|
||||
});
|
||||
if (mediaFile.success && mediaFile.data) {
|
||||
const links = mediaFile.data.media_links as { cdn_url?: string; thumbnail_template?: string; maybe_thumbnail_template?: string } | undefined;
|
||||
cdnUrl = links?.cdn_url || (mediaFile.data as any).public_bucket_url || objectUrl;
|
||||
thumbnailUrlTemplate = links?.thumbnail_template || links?.maybe_thumbnail_template;
|
||||
const links = mediaFile.data.media_links as
|
||||
| {
|
||||
cdn_url?: string;
|
||||
thumbnail_template?: string;
|
||||
maybe_thumbnail_template?: string;
|
||||
}
|
||||
| undefined;
|
||||
cdnUrl =
|
||||
links?.cdn_url ||
|
||||
(mediaFile.data as any).public_bucket_url ||
|
||||
objectUrl;
|
||||
thumbnailUrlTemplate =
|
||||
links?.thumbnail_template || links?.maybe_thumbnail_template;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to blob URL if fetching CDN info fails
|
||||
@@ -288,7 +310,6 @@ export const Angles = () => {
|
||||
adjust_zoom: state.angleConfig.zoom,
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating angle:", error);
|
||||
toast.error("Failed to generate angle");
|
||||
@@ -435,7 +456,12 @@ export const Angles = () => {
|
||||
],
|
||||
};
|
||||
return [sourceBundle, ...historyBundles];
|
||||
}, [sourceImageUrl, sourceMediaToken, sourceThumbnailUrlTemplate, historyBundles]);
|
||||
}, [
|
||||
sourceImageUrl,
|
||||
sourceMediaToken,
|
||||
sourceThumbnailUrlTemplate,
|
||||
historyBundles,
|
||||
]);
|
||||
|
||||
const handleHistoryImageSelect = useCallback(
|
||||
(image: BaseSelectorImage) => {
|
||||
@@ -705,6 +731,11 @@ export const Angles = () => {
|
||||
</div>
|
||||
</Switch.Group> */}
|
||||
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={ANGLES_PAGE_MODEL_LIST}
|
||||
page={ANGLES_MODEL_PAGE}
|
||||
/>
|
||||
<GenerateButton
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
@@ -718,20 +749,8 @@ export const Angles = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Model selector (bottom-left) ── */}
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={ANGLES_PAGE_MODEL_LIST}
|
||||
page={ANGLES_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Cost calculator + Help (bottom-right) ── */}
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={ANGLES_MODEL_PAGE} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
|
||||
@@ -1,81 +1,45 @@
|
||||
import { faGrid2 } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
useGenerateApps,
|
||||
useEditApps,
|
||||
getAppCardPalette,
|
||||
getBadgeStyles,
|
||||
goToApp,
|
||||
type FullAppItem,
|
||||
} from "~/config/appMenu";
|
||||
|
||||
export const AppsIndexPage = () => {
|
||||
const generateApps = useGenerateApps();
|
||||
const editApps = useEditApps();
|
||||
const categories = [
|
||||
{ title: "Generate", apps: generateApps },
|
||||
{ title: "Create", apps: generateApps },
|
||||
{ title: "Edit", apps: editApps },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden bg-ui-background pt-[56px] text-base-fg">
|
||||
<main className="mx-auto h-full max-w-6xl overflow-y-auto px-5 pb-12 pt-16">
|
||||
<div className="flex w-full flex-col items-start gap-2 pb-4">
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<FontAwesomeIcon icon={faGrid2} className="text-2xl opacity-70" />
|
||||
<h1 className="text-4xl font-bold">
|
||||
Explore <span className="text-primary-400">ArtCraft Apps</span>
|
||||
</h1>
|
||||
</div>
|
||||
<hr className="mb-3.5 w-full border-ui-panel-border" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
// The scroll container spans the full window width so its scrollbar sits
|
||||
// flush against the right edge; horizontal padding lives on the inner
|
||||
// wrapper instead.
|
||||
<div className="fixed inset-0 overflow-y-auto bg-ui-background pt-[56px] text-base-fg">
|
||||
<main className="mx-auto max-w-6xl px-6 pb-20 pt-12 sm:pt-20">
|
||||
<h1 className="mx-auto text-center text-4xl font-semibold tracking-tight sm:text-6xl">
|
||||
What will you <span className="text-primary">craft</span> today?
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-xl text-center text-lg text-base-fg/55">
|
||||
Every tool in your toolbox - pick one and start creating.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 space-y-10">
|
||||
{categories.map((category) => (
|
||||
<section key={category.title}>
|
||||
<h2 className="mb-3 text-sm font-semibold opacity-50">
|
||||
<h2 className="mb-4 text-sm font-semibold text-base-fg/85">
|
||||
{category.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<div className="grid auto-rows-min gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{category.apps.map((app) => (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => goToApp(app.action)}
|
||||
disabled={!app.action}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center gap-4 rounded-lg px-3 py-3 text-left transition-colors",
|
||||
app.action
|
||||
? "cursor-pointer hover:bg-ui-controls/60"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
app.color || "bg-ui-controls",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[15px] font-medium">
|
||||
{app.label}
|
||||
</div>
|
||||
{app.badge && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider",
|
||||
getBadgeStyles(app.badge),
|
||||
)}
|
||||
>
|
||||
{app.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[13px] opacity-60">
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,3 +49,72 @@ export const AppsIndexPage = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function AppCard({ app }: { app: FullAppItem }) {
|
||||
const palette = getAppCardPalette(app.id);
|
||||
const enabled = !!app.action;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => goToApp(app.action)}
|
||||
disabled={!enabled}
|
||||
className={twMerge(
|
||||
"group relative block overflow-hidden rounded-2xl bg-ui-controls/50 p-5 text-left transition-colors",
|
||||
enabled
|
||||
? "cursor-pointer hover:bg-ui-controls"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* Hover accent — gradient wash matching the app's hue. */}
|
||||
{enabled && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute -inset-px rounded-2xl bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
|
||||
palette.accent,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border",
|
||||
palette.iconBg,
|
||||
palette.iconColor,
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-base font-semibold text-base-fg">
|
||||
{app.label}
|
||||
</h3>
|
||||
{app.badge && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none tracking-wider",
|
||||
getBadgeStyles(app.badge),
|
||||
)}
|
||||
>
|
||||
{app.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{enabled && (
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-sm text-base-fg/40 transition-all group-hover:translate-x-0.5 group-hover:text-base-fg/70"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug text-base-fg/55">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
JobContextType,
|
||||
getThumbnailUrl,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
import { PromptBoxImage } from "@storyteller/ui-promptbox";
|
||||
import { UploadImageMedia } from "@storyteller/api";
|
||||
import { UploadImageMedia, FilterMediaClasses } from "@storyteller/api";
|
||||
import BackgroundGallery from "./BackgroundGallery";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
ClassyModelSelector,
|
||||
useSelectedImageModel,
|
||||
useSelectedProviderForModel,
|
||||
//ProviderSelector,
|
||||
//PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { ImageModel, getCreatorIcon } from "@storyteller/model-list";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCheck,
|
||||
faCircleExclamation,
|
||||
faCopy,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
interface TextToImageProps {
|
||||
imageMediaId?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
//import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { useTextToImageStore } from "./TextToImageStore";
|
||||
import { animated, useSpring } from "@react-spring/web";
|
||||
import {
|
||||
galleryModalLightboxImage,
|
||||
galleryModalLightboxMediaId,
|
||||
@@ -39,28 +19,32 @@ import {
|
||||
galleryModalLightboxNavPrev,
|
||||
galleryModalLightboxNavNext,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { Badge } from "@storyteller/ui-badge";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import {
|
||||
CostCalculatorButton,
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import Button from "node_modules/@storyteller/ui-button/src/lib/button";
|
||||
import {
|
||||
useGalleryData,
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useDesktopGenerationFeed } from "~/components/generation-feed/useDesktopGenerationFeed";
|
||||
import { useDesktopUsername } from "~/components/generation-feed/useDesktopUsername";
|
||||
import { DesktopCreatePageShell } from "~/components/generation-feed/DesktopCreatePageShell";
|
||||
import { DesktopGenerationGallery } from "~/components/generation-feed/DesktopGenerationGallery";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.TextToImage;
|
||||
|
||||
const IMAGE_FILTER = [FilterMediaClasses.IMAGE];
|
||||
|
||||
interface TextToImageProps {
|
||||
imageMediaId?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const batches = useTextToImageStore((s) => s.batches);
|
||||
const startBatch = useTextToImageStore((s) => s.startBatch);
|
||||
//const completeBatch = useTextToImageStore((s) => s.completeBatch);
|
||||
const failBatch = useTextToImageStore((s) => s.failBatch);
|
||||
const dismissBatch = useTextToImageStore((s) => s.dismissBatch);
|
||||
const resetBatches = useTextToImageStore((s) => s.reset);
|
||||
const [imageRowVisible, setImageRowVisible] = useState(false);
|
||||
const [copiedBatchId, setCopiedBatchId] = useState<string | null>(null);
|
||||
const promptContentRef = useRef<HTMLDivElement>(null);
|
||||
const [promptHeight, setPromptHeight] = useState<number>(138);
|
||||
|
||||
@@ -76,23 +60,12 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
|
||||
const jobContext: JobContextType = {
|
||||
jobTokens: [],
|
||||
addJobToken: () => { },
|
||||
removeJobToken: () => { },
|
||||
clearJobTokens: () => { },
|
||||
addJobToken: () => {},
|
||||
removeJobToken: () => {},
|
||||
clearJobTokens: () => {},
|
||||
};
|
||||
|
||||
const hasAnyBatches = batches.length > 0;
|
||||
const showPromptAtBottom = useMemo(() => hasAnyBatches, [hasAnyBatches]);
|
||||
|
||||
const [vh, setVh] = useState<number>(
|
||||
typeof window !== "undefined" ? window.innerHeight : 800,
|
||||
);
|
||||
useEffect(() => {
|
||||
const onResize = () => setVh(window.innerHeight);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Track the promptbox height so the feed can pad past it.
|
||||
useEffect(() => {
|
||||
const el = promptContentRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
@@ -103,323 +76,145 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const bottomMarginPx = 24;
|
||||
const bottomOffsetPx = promptHeight + bottomMarginPx;
|
||||
const targetTop = showPromptAtBottom
|
||||
? Math.max(0, vh - bottomOffsetPx)
|
||||
: Math.floor(vh / 2);
|
||||
const promptAnim = useSpring({
|
||||
top: targetTop,
|
||||
config: { tension: 200, friction: 28, mass: 1.1 },
|
||||
// The merged generation feed: in-progress / failed from the Tauri task
|
||||
// queue, completed history from the library (like the webapp list view).
|
||||
const username = useDesktopUsername();
|
||||
const feed = useDesktopGenerationFeed({ mediaType: "image" });
|
||||
const gallery = useGalleryData({
|
||||
username,
|
||||
filterMediaClasses: IMAGE_FILTER,
|
||||
excludeUploads: true,
|
||||
});
|
||||
|
||||
// Listen for generation-failed-event and mark the oldest pending image batch as failed
|
||||
useEffect(() => {
|
||||
let unlisten: Promise<UnlistenFn> | null = null;
|
||||
unlisten = listen<{ data: { action: string; reason?: string } }>(
|
||||
"generation-failed-event",
|
||||
(event) => {
|
||||
const { action, reason } = event.payload.data;
|
||||
if (action === "generate_image") {
|
||||
failBatch(reason || "Generation failed");
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
if (unlisten) unlisten.then((f) => f());
|
||||
};
|
||||
}, [failBatch]);
|
||||
// Content only — while the gallery is still loading the shell keeps the
|
||||
// hero + background up as a splash and fades them out when items land.
|
||||
const hasContent =
|
||||
feed.inProgress.length > 0 ||
|
||||
feed.failed.length > 0 ||
|
||||
feed.newlyCompleted.length > 0 ||
|
||||
gallery.items.length > 0;
|
||||
|
||||
// Show the batches in reverse order, with the newest items at top.
|
||||
// Like Midjourney instead of a "chat history" style.
|
||||
const inverseBatch = [...batches].reverse();
|
||||
|
||||
// Keep a ref so nav callbacks always see the latest batch list without
|
||||
// needing to be recreated on every render.
|
||||
const inverseBatchRef = useRef(inverseBatch);
|
||||
inverseBatchRef.current = inverseBatch;
|
||||
|
||||
const openBatchInLightbox = useCallback(
|
||||
(targetBatch: (typeof inverseBatch)[0], startIndex = 0) => {
|
||||
const images = targetBatch.images;
|
||||
if (!images.length) return;
|
||||
|
||||
const imageUrls = images.map(
|
||||
(img) =>
|
||||
getThumbnailUrl(img.maybe_thumbnail_template, { width: 3200 }) ??
|
||||
img.cdn_url,
|
||||
);
|
||||
const mediaTokens = images.map((img) => img.media_token);
|
||||
const actionUrls = images.map((img) => img.cdn_url);
|
||||
|
||||
const clickedImg = images[startIndex];
|
||||
const batchIndex = inverseBatchRef.current.findIndex(
|
||||
(b) => b.id === targetBatch.id,
|
||||
);
|
||||
|
||||
galleryModalLightboxNavPrev.value =
|
||||
batchIndex > 0
|
||||
? () => {
|
||||
const prevBatch = inverseBatchRef.current[batchIndex - 1];
|
||||
openBatchInLightbox(
|
||||
prevBatch,
|
||||
Math.max(0, prevBatch.images.length - 1),
|
||||
);
|
||||
}
|
||||
: null;
|
||||
galleryModalLightboxNavNext.value =
|
||||
batchIndex < inverseBatchRef.current.length - 1
|
||||
? () => openBatchInLightbox(inverseBatchRef.current[batchIndex + 1])
|
||||
: null;
|
||||
|
||||
galleryModalLightboxMediaId.value = clickedImg.media_token;
|
||||
galleryModalLightboxImage.value = {
|
||||
id: clickedImg.media_token,
|
||||
label: targetBatch.prompt || "Generated Image",
|
||||
thumbnail:
|
||||
getThumbnailUrl(clickedImg.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.MEDIUM,
|
||||
}) ?? clickedImg.cdn_url,
|
||||
fullImage:
|
||||
getThumbnailUrl(clickedImg.maybe_thumbnail_template, {
|
||||
width: 2048,
|
||||
}) ?? clickedImg.cdn_url,
|
||||
createdAt: new Date(targetBatch.createdAt).toISOString(),
|
||||
mediaClass: "image" as const,
|
||||
mediaTokens,
|
||||
imageUrls,
|
||||
actionUrls,
|
||||
initialIndex: startIndex,
|
||||
thumbnailUrlTemplate: clickedImg.maybe_thumbnail_template,
|
||||
};
|
||||
galleryModalLightboxVisible.value = true;
|
||||
},
|
||||
[],
|
||||
const newlyCompletedTokens = useMemo(
|
||||
() => new Set(feed.newlyCompleted.map((item) => item.id)),
|
||||
[feed.newlyCompleted],
|
||||
);
|
||||
|
||||
// Flat, time-sorted completed list driving lightbox prev/next navigation.
|
||||
const flatCompleted = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: GalleryItem[] = [];
|
||||
for (const item of feed.newlyCompleted) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
for (const item of gallery.items) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
merged.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return merged;
|
||||
}, [feed.newlyCompleted, gallery.items]);
|
||||
|
||||
const flatCompletedRef = useRef(flatCompleted);
|
||||
flatCompletedRef.current = flatCompleted;
|
||||
|
||||
// Open a completed row in the global lightbox (rendered by TopBar's
|
||||
// gallery modal); prev/next walk the merged feed order.
|
||||
const openInLightbox = useCallback((item: GalleryItem) => {
|
||||
const list = flatCompletedRef.current;
|
||||
const index = list.findIndex((i) => i.id === item.id);
|
||||
galleryModalLightboxNavPrev.value =
|
||||
index > 0 ? () => openInLightbox(list[index - 1]) : null;
|
||||
galleryModalLightboxNavNext.value =
|
||||
index >= 0 && index < list.length - 1
|
||||
? () => openInLightbox(list[index + 1])
|
||||
: null;
|
||||
galleryModalLightboxMediaId.value = item.id;
|
||||
galleryModalLightboxImage.value = {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
thumbnail: item.thumbnail,
|
||||
fullImage: item.fullImage,
|
||||
createdAt: item.createdAt,
|
||||
mediaClass: item.mediaClass,
|
||||
};
|
||||
galleryModalLightboxVisible.value = true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-[calc(100vh-56px)] w-full bg-ui-background"
|
||||
>
|
||||
<div className="relative h-full w-full p-16">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md pb-12">
|
||||
{!showPromptAtBottom && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl",
|
||||
imageRowVisible && "mb-80",
|
||||
)}
|
||||
>
|
||||
<h1 className="text-7xl font-bold text-base-fg">
|
||||
Generate Image
|
||||
</h1>
|
||||
<span className="pt-2 text-xl text-base-fg opacity-80">
|
||||
Add a prompt, then generate
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyBatches && (
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto"
|
||||
style={{ paddingBottom: bottomOffsetPx + 24 }}
|
||||
>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 pr-2">
|
||||
{inverseBatch.map((batch) => (
|
||||
<div
|
||||
key={batch.id}
|
||||
className="relative flex items-stretch gap-4"
|
||||
>
|
||||
<div className="grid flex-1 grid-cols-4 gap-4">
|
||||
{batch.status === "failed" ? (
|
||||
<>
|
||||
<div className="flex aspect-square w-full flex-col items-center justify-center gap-3 rounded-lg bg-red-500/10 text-red-400">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleExclamation}
|
||||
size="2x"
|
||||
/>
|
||||
<span className="px-4 text-center text-sm font-medium">
|
||||
{batch.failureReason || "Generation failed"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => dismissBatch(batch.id)}
|
||||
className="mt-1 flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${batch.id}-${i}`}
|
||||
className="aspect-square w-full rounded-lg bg-white/[0.02]"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : batch.status === "pending" &&
|
||||
batch.images.length === 0 ? (
|
||||
<>
|
||||
{Array.from({
|
||||
length: Math.max(
|
||||
1,
|
||||
Math.min(4, batch.requestedCount ?? 4),
|
||||
),
|
||||
}).map((_, i) => (
|
||||
<div
|
||||
key={`sk-${batch.id}-${i}`}
|
||||
className="aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]"
|
||||
>
|
||||
<div className="h-full w-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent bg-[length:200%_100%]" />
|
||||
</div>
|
||||
))}
|
||||
{Array.from({
|
||||
length: Math.max(
|
||||
0,
|
||||
4 -
|
||||
Math.max(
|
||||
1,
|
||||
Math.min(4, batch.requestedCount ?? 4),
|
||||
),
|
||||
),
|
||||
}).map((_, i) => (
|
||||
<div
|
||||
key={`filler-sk-${batch.id}-${i}`}
|
||||
className="aspect-square w-full rounded-lg bg-white/[0.02]"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{batch.images.slice(0, 4).map((img) => (
|
||||
<button
|
||||
key={img.media_token}
|
||||
onClick={() => {
|
||||
const startIndex = batch.images.indexOf(img);
|
||||
openBatchInLightbox(batch, startIndex);
|
||||
}}
|
||||
className="aspect-square w-full overflow-hidden rounded-lg transition-opacity duration-200 hover:cursor-pointer hover:opacity-75"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
getThumbnailUrl(
|
||||
img.maybe_thumbnail_template,
|
||||
{ width: THUMBNAIL_SIZES.LARGE },
|
||||
) ?? img.cdn_url
|
||||
}
|
||||
alt="Generated"
|
||||
loading="lazy"
|
||||
className="h-full w-full bg-black/10 object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{Array.from({
|
||||
length: Math.max(
|
||||
0,
|
||||
4 - batch.images.slice(0, 4).length,
|
||||
),
|
||||
}).map((_, i) => (
|
||||
<div
|
||||
key={`filler-${batch.id}-${i}`}
|
||||
className="aspect-square w-full rounded-lg bg-white/[0.02]"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex w-[320px] shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute bottom-0 right-0 top-0 flex w-[320px] flex-col">
|
||||
<div className="glass min-h-0 overflow-y-auto rounded-xl px-4 py-3 text-left text-sm text-base-fg/90">
|
||||
<div>{batch.prompt}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(batch.prompt || "");
|
||||
setCopiedBatchId(batch.id);
|
||||
setTimeout(() => setCopiedBatchId(null), 2000);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-white/50 transition-colors bg-transparent hover:bg-white/10 hover:text-white/70"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={copiedBatchId === batch.id ? faCheck : faCopy}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{copiedBatchId === batch.id ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Badge
|
||||
label={batch.modelLabel}
|
||||
icon={batch.modelCreator ? getCreatorIcon(batch.modelCreator, "h-3.5 w-3.5 icon-auto-contrast") : undefined}
|
||||
className="px-2 py-1 text-xs opacity-70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<animated.div
|
||||
className="fixed left-1/2 z-20 -translate-x-1/2"
|
||||
style={promptAnim}
|
||||
>
|
||||
{showPromptAtBottom && batches.length > 0 && (
|
||||
<div
|
||||
className={`absolute ${imageRowVisible ? "-top-[108px]" : "-top-9"} flex w-full justify-end`}
|
||||
>
|
||||
<button
|
||||
onClick={() => resetBatches()}
|
||||
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
|
||||
>
|
||||
Clear session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={promptContentRef}>
|
||||
<PromptBoxImage
|
||||
useJobContext={() => {
|
||||
return jobContext;
|
||||
}}
|
||||
uploadImage={UploadImageMedia}
|
||||
selectedModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
onImageRowVisibilityChange={setImageRowVisible}
|
||||
credits={imageCredits}
|
||||
onEnqueuePressed={async (prompt, count, subscriberId) => {
|
||||
const modelLabel = selectedImageModel?.fullName ?? "";
|
||||
startBatch(prompt, count, modelLabel, subscriberId, selectedImageModel?.creator);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
{!showPromptAtBottom && <BackgroundGallery />}
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
<DesktopCreatePageShell
|
||||
hasContent={hasContent}
|
||||
emptyStateTitle="Create Image"
|
||||
emptyStateSubtitle="Add a prompt, then generate"
|
||||
background={<BackgroundGallery />}
|
||||
bottomOffset={promptHeight + 40}
|
||||
listContent={
|
||||
<DesktopGenerationGallery
|
||||
inProgressJobs={feed.inProgress}
|
||||
failedJobs={feed.failed}
|
||||
onDismissFailed={feed.dismissFailed}
|
||||
newlyCompletedItems={feed.newlyCompleted}
|
||||
galleryItems={gallery.items}
|
||||
newlyCompletedTokens={newlyCompletedTokens}
|
||||
hasMore={gallery.hasMore}
|
||||
isLoading={gallery.isLoading}
|
||||
isInitialLoading={gallery.isInitialLoading}
|
||||
onLoadMore={gallery.loadMore}
|
||||
onGalleryItemClick={openInLightbox}
|
||||
enableMakeVideo
|
||||
/>
|
||||
}
|
||||
promptBox={
|
||||
<div className="fixed bottom-4 left-1/2 z-20 w-full max-w-5xl -translate-x-1/2 px-2 sm:px-4">
|
||||
<div ref={promptContentRef}>
|
||||
<PromptBoxImage
|
||||
useJobContext={() => {
|
||||
return jobContext;
|
||||
}}
|
||||
uploadImage={UploadImageMedia}
|
||||
selectedModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
credits={imageCredits}
|
||||
modelSelector={
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
/>
|
||||
}
|
||||
onEnqueuePressed={async (prompt, count, subscriberId) => {
|
||||
const modelLabel = selectedImageModel?.fullName ?? "";
|
||||
startBatch(
|
||||
prompt,
|
||||
count,
|
||||
modelLabel,
|
||||
subscriberId,
|
||||
selectedImageModel?.creator,
|
||||
);
|
||||
// Nudge the feed (and the TopBar task queue) to refetch so the
|
||||
// pending row appears immediately.
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
bottomRight={
|
||||
<>
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
<HelpMenuButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { JobContextType } from "@storyteller/common";
|
||||
import { PromptBoxVideo, usePromptVideoStore, PromptBoxErrorBoundary } from "@storyteller/ui-promptbox";
|
||||
import { UploadImageMedia, UploadVideoMedia, UploadAudioMedia } from "@storyteller/api";
|
||||
import {
|
||||
PromptBoxVideo,
|
||||
PromptBoxErrorBoundary,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import {
|
||||
UploadImageMedia,
|
||||
UploadVideoMedia,
|
||||
UploadAudioMedia,
|
||||
FilterMediaClasses,
|
||||
} from "@storyteller/api";
|
||||
import BackgroundGallery from "./BackgroundGallery";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
@@ -9,42 +17,49 @@ import {
|
||||
ModelPage,
|
||||
useSelectedVideoModel,
|
||||
useSelectedProviderForModel,
|
||||
//ProviderSelector,
|
||||
//PROVIDER_LOOKUP_BY_PAGE,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { VideoModel } from "@storyteller/model-list";
|
||||
import { animated, useSpring } from "@react-spring/web";
|
||||
import { useImageToVideoStore } from "./ImageToVideoStore";
|
||||
import {
|
||||
useVideoGenerationCompleteEvent,
|
||||
VideoGenerationCompleteEvent,
|
||||
} from "@storyteller/tauri-events";
|
||||
// import { Badge } from "@storyteller/ui-badge";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
galleryModalLightboxImage,
|
||||
galleryModalLightboxMediaId,
|
||||
galleryModalLightboxVisible,
|
||||
galleryModalLightboxNavPrev,
|
||||
galleryModalLightboxNavNext,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import {
|
||||
CostCalculatorButton,
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import {
|
||||
useGalleryData,
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useDesktopGenerationFeed } from "~/components/generation-feed/useDesktopGenerationFeed";
|
||||
import { useDesktopUsername } from "~/components/generation-feed/useDesktopUsername";
|
||||
import { DesktopCreatePageShell } from "~/components/generation-feed/DesktopCreatePageShell";
|
||||
import { DesktopGenerationGallery } from "~/components/generation-feed/DesktopGenerationGallery";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
|
||||
|
||||
const VIDEO_FILTER = [FilterMediaClasses.VIDEO];
|
||||
|
||||
interface ImageToVideoProps {
|
||||
imageMediaId?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const batches = useImageToVideoStore((s) => s.batches);
|
||||
const startBatch = useImageToVideoStore((s) => s.startBatch);
|
||||
const completeBatch = useImageToVideoStore((s) => s.completeBatch);
|
||||
// const resetBatches = useImageToVideoStore((s) => s.reset);
|
||||
const [imageRowVisible, setImageRowVisible] = useState(true);
|
||||
const inputMode = usePromptVideoStore((s) => s.inputMode);
|
||||
const promptContentRef = useRef<HTMLDivElement>(null);
|
||||
const [_promptHeight, setPromptHeight] = useState<number>(138);
|
||||
const [promptHeight, setPromptHeight] = useState<number>(138);
|
||||
|
||||
const selectedVideoModel: VideoModel | undefined =
|
||||
useSelectedVideoModel(PAGE_ID);
|
||||
@@ -58,11 +73,13 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
|
||||
const jobContext: JobContextType = {
|
||||
jobTokens: [],
|
||||
addJobToken: () => { },
|
||||
removeJobToken: () => { },
|
||||
clearJobTokens: () => { },
|
||||
addJobToken: () => {},
|
||||
removeJobToken: () => {},
|
||||
clearJobTokens: () => {},
|
||||
};
|
||||
|
||||
// Keep the local batch store in sync (other listeners may rely on it); the
|
||||
// visible feed below is driven by the task queue + library instead.
|
||||
useVideoGenerationCompleteEvent(
|
||||
async (event: VideoGenerationCompleteEvent) => {
|
||||
if (!event.generated_video) return;
|
||||
@@ -76,18 +93,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
},
|
||||
);
|
||||
|
||||
const hasAnyBatches = batches.length > 0;
|
||||
const showPromptAtBottom = useMemo(() => hasAnyBatches, [hasAnyBatches]);
|
||||
|
||||
const [vh, setVh] = useState<number>(
|
||||
typeof window !== "undefined" ? window.innerHeight : 800,
|
||||
);
|
||||
useEffect(() => {
|
||||
const onResize = () => setVh(window.innerHeight);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Track the promptbox height so the feed can pad past it.
|
||||
useEffect(() => {
|
||||
const el = promptContentRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
@@ -98,153 +104,142 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// TODO: Uncomment when backend supports history frontend id
|
||||
// const bottomMarginPx = 24;
|
||||
// const bottomOffsetPx = promptHeight + bottomMarginPx;
|
||||
const targetTop = showPromptAtBottom
|
||||
? Math.floor(vh / 2) //change to Math.max(0, vh - bottomOffsetPx) when backend supports history frontend id
|
||||
: Math.floor(vh / 2);
|
||||
const promptAnim = useSpring({
|
||||
top: targetTop,
|
||||
config: { tension: 200, friction: 28, mass: 1.1 },
|
||||
// The merged generation feed: in-progress / failed from the Tauri task
|
||||
// queue, completed history from the library (like the webapp list view).
|
||||
const username = useDesktopUsername();
|
||||
const feed = useDesktopGenerationFeed({ mediaType: "video" });
|
||||
const gallery = useGalleryData({
|
||||
username,
|
||||
filterMediaClasses: VIDEO_FILTER,
|
||||
excludeUploads: true,
|
||||
});
|
||||
|
||||
// const inverseBatch = [...batches].reverse();
|
||||
// Content only — while the gallery is still loading the shell keeps the
|
||||
// hero + background up as a splash and fades them out when items land.
|
||||
const hasContent =
|
||||
feed.inProgress.length > 0 ||
|
||||
feed.failed.length > 0 ||
|
||||
feed.newlyCompleted.length > 0 ||
|
||||
gallery.items.length > 0;
|
||||
|
||||
const newlyCompletedTokens = useMemo(
|
||||
() => new Set(feed.newlyCompleted.map((item) => item.id)),
|
||||
[feed.newlyCompleted],
|
||||
);
|
||||
|
||||
// Flat, time-sorted completed list driving lightbox prev/next navigation.
|
||||
const flatCompleted = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: GalleryItem[] = [];
|
||||
for (const item of feed.newlyCompleted) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
for (const item of gallery.items) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
merged.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return merged;
|
||||
}, [feed.newlyCompleted, gallery.items]);
|
||||
|
||||
const flatCompletedRef = useRef(flatCompleted);
|
||||
flatCompletedRef.current = flatCompleted;
|
||||
|
||||
// Open a completed row in the global lightbox (rendered by TopBar's
|
||||
// gallery modal); prev/next walk the merged feed order.
|
||||
const openInLightbox = useCallback((item: GalleryItem) => {
|
||||
const list = flatCompletedRef.current;
|
||||
const index = list.findIndex((i) => i.id === item.id);
|
||||
galleryModalLightboxNavPrev.value =
|
||||
index > 0 ? () => openInLightbox(list[index - 1]) : null;
|
||||
galleryModalLightboxNavNext.value =
|
||||
index >= 0 && index < list.length - 1
|
||||
? () => openInLightbox(list[index + 1])
|
||||
: null;
|
||||
galleryModalLightboxMediaId.value = item.id;
|
||||
galleryModalLightboxImage.value = {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
thumbnail: item.thumbnail,
|
||||
fullImage: item.fullImage,
|
||||
createdAt: item.createdAt,
|
||||
mediaClass: item.mediaClass,
|
||||
};
|
||||
galleryModalLightboxVisible.value = true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-[calc(100vh-56px)] w-full bg-ui-background"
|
||||
>
|
||||
<div className="relative h-full w-full p-16">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md pb-12">
|
||||
{/* TODO: Uncomment when backend supports history frontend id */}
|
||||
{/* {!showPromptAtBottom && ( */}
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl",
|
||||
imageRowVisible &&
|
||||
(inputMode === "reference" &&
|
||||
selectedVideoModel?.supportsReferenceMode
|
||||
? "mb-[30rem]"
|
||||
: "mb-80"),
|
||||
)}
|
||||
>
|
||||
<h1 className="text-7xl font-bold text-base-fg">Generate Video</h1>
|
||||
<span className="pt-2 text-xl text-base-fg opacity-80">
|
||||
Choose an image, add a prompt, then generate
|
||||
</span>
|
||||
</div>
|
||||
{/* )} */}
|
||||
|
||||
{/* TODO: Uncomment when backend supports history frontend id */}
|
||||
{/* {hasAnyBatches && (
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto"
|
||||
style={{ paddingBottom: bottomOffsetPx + 24 }}
|
||||
>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 pr-2">
|
||||
{inverseBatch.map((batch) => (
|
||||
<div key={batch.id} className="flex items-start gap-4">
|
||||
<div className="grid flex-1 grid-cols-1 gap-4">
|
||||
{batch.status === "pending" && !batch.video ? (
|
||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-white/5" />
|
||||
) : batch.video ? (
|
||||
<button
|
||||
className="aspect-video w-full overflow-hidden rounded-lg"
|
||||
onClick={() => {
|
||||
// No-op for now (could open lightbox if desired)
|
||||
}}
|
||||
>
|
||||
<video
|
||||
className="h-full w-full object-cover"
|
||||
src={batch.video.cdn_url}
|
||||
controls
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="glass inline-block w-[320px] shrink-0 rounded-xl px-4 py-3 text-left text-sm text-white/90">
|
||||
<div>{batch.prompt}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Badge
|
||||
label={batch.modelLabel}
|
||||
className="px-2 py-1 text-xs opacity-70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
<animated.div
|
||||
className="fixed left-1/2 z-20 w-[860px] -translate-x-1/2"
|
||||
style={promptAnim}
|
||||
>
|
||||
{/* TODO: Uncomment when backend supports history frontend id */}
|
||||
{/* {showPromptAtBottom && batches.length > 0 && (
|
||||
<div
|
||||
className={`absolute ${imageRowVisible ? "-top-[108px]" : "-top-9"} flex w-full justify-end`}
|
||||
>
|
||||
<button
|
||||
onClick={() => resetBatches()}
|
||||
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
|
||||
>
|
||||
Clear session
|
||||
</button>
|
||||
</div>
|
||||
)} */}
|
||||
<div ref={promptContentRef}>
|
||||
<PromptBoxErrorBoundary>
|
||||
<PromptBoxVideo
|
||||
useJobContext={() => jobContext}
|
||||
selectedModel={selectedVideoModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
onImageRowVisibilityChange={setImageRowVisible}
|
||||
uploadImage={UploadImageMedia}
|
||||
uploadVideo={UploadVideoMedia}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
credits={videoCredits}
|
||||
onEnqueuePressed={async (prompt, subscriberIds) => {
|
||||
const modelLabel = selectedVideoModel?.fullName ?? "";
|
||||
for (const subscriberId of subscriberIds) {
|
||||
startBatch(prompt, modelLabel, subscriberId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PromptBoxErrorBoundary>
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
{/* TODO: Uncomment when backend supports history frontend id */}
|
||||
{/* {!showPromptAtBottom && */}
|
||||
<BackgroundGallery />
|
||||
{/* } */}
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
<HelpMenuButton />
|
||||
<DesktopCreatePageShell
|
||||
hasContent={hasContent}
|
||||
emptyStateTitle="Create Video"
|
||||
emptyStateSubtitle="Choose an image, add a prompt, then generate"
|
||||
background={<BackgroundGallery />}
|
||||
bottomOffset={promptHeight + 40}
|
||||
listContent={
|
||||
<DesktopGenerationGallery
|
||||
inProgressJobs={feed.inProgress}
|
||||
failedJobs={feed.failed}
|
||||
onDismissFailed={feed.dismissFailed}
|
||||
newlyCompletedItems={feed.newlyCompleted}
|
||||
galleryItems={gallery.items}
|
||||
newlyCompletedTokens={newlyCompletedTokens}
|
||||
hasMore={gallery.hasMore}
|
||||
isLoading={gallery.isLoading}
|
||||
isInitialLoading={gallery.isInitialLoading}
|
||||
onLoadMore={gallery.loadMore}
|
||||
onGalleryItemClick={openInLightbox}
|
||||
/>
|
||||
}
|
||||
promptBox={
|
||||
<div className="fixed bottom-4 left-1/2 z-20 w-full max-w-5xl -translate-x-1/2 px-2 sm:px-4">
|
||||
<div ref={promptContentRef}>
|
||||
<PromptBoxErrorBoundary>
|
||||
<PromptBoxVideo
|
||||
useJobContext={() => jobContext}
|
||||
selectedModel={selectedVideoModel}
|
||||
selectedProvider={selectedProvider}
|
||||
imageMediaId={imageMediaId}
|
||||
url={imageUrl ?? undefined}
|
||||
uploadImage={UploadImageMedia}
|
||||
uploadVideo={UploadVideoMedia}
|
||||
uploadAudio={UploadAudioMedia}
|
||||
credits={videoCredits}
|
||||
modelSelector={
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
/>
|
||||
}
|
||||
onEnqueuePressed={async (prompt, subscriberIds) => {
|
||||
const modelLabel = selectedVideoModel?.fullName ?? "";
|
||||
for (const subscriberId of subscriberIds) {
|
||||
startBatch(prompt, modelLabel, subscriberId);
|
||||
}
|
||||
// Nudge the feed (and the TopBar task queue) to refetch so
|
||||
// the pending row appears immediately.
|
||||
window.dispatchEvent(new Event("task-queue-update"));
|
||||
}}
|
||||
/>
|
||||
</PromptBoxErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
bottomRight={
|
||||
<>
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
<HelpMenuButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export type TabId =
|
||||
| "BACKGROUND_CHANGE"
|
||||
| "VIDEO_EDITOR";
|
||||
|
||||
const DEFAULT_TAB: TabId = "IMAGE";
|
||||
const DEFAULT_TAB: TabId = "APPS";
|
||||
|
||||
interface TabState {
|
||||
// Current active tab
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
border: 2px solid #171717;
|
||||
}
|
||||
|
||||
/* Light theme: the scrollbar track/thumb must be light, not the dark defaults. */
|
||||
.theme-light {
|
||||
scrollbar-color: #c4c4c8 #ececef;
|
||||
}
|
||||
|
||||
.theme-light *::-webkit-scrollbar-track {
|
||||
background: #ececef;
|
||||
}
|
||||
|
||||
.theme-light *::-webkit-scrollbar-thumb {
|
||||
background-color: #c4c4c8;
|
||||
border: 2px solid #ececef;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
@@ -324,6 +338,19 @@ img {
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Light theme: white thumb is invisible on the light glass — use a dark tint. */
|
||||
.theme-light .promptbox-scrollbar {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||
}
|
||||
|
||||
.theme-light .promptbox-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.theme-light .promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -647,3 +674,26 @@ img {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer skeleton used by the shared generation-list rows (matches the
|
||||
webapp's .animate-shimmer — gradient sweep over a translucent base). */
|
||||
@keyframes generation-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: generation-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
|
||||
.glass {
|
||||
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
|
||||
@apply bg-ui-controls/50 !important;
|
||||
@apply bg-[#303032]/90 !important;
|
||||
}
|
||||
|
||||
/* Light theme: the glass surface must be a light panel, not the dark
|
||||
hard-coded fill above. Tint + border swapped to read on a light bg. */
|
||||
.theme-light .glass {
|
||||
@apply border-black/10 !important;
|
||||
background-color: rgb(248 248 248 / 0.92) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/badge"
|
||||
"path": "../../libs/components/video-editor"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/vfx"
|
||||
@@ -40,12 +40,6 @@
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/video-editor"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/login-modal"
|
||||
},
|
||||
@@ -58,15 +52,9 @@
|
||||
{
|
||||
"path": "../../libs/components/settings-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/menu-icon-selector"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-events"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/state/subscription"
|
||||
},
|
||||
@@ -82,9 +70,6 @@
|
||||
{
|
||||
"path": "../../libs/state/credits"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/common"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/api-enums"
|
||||
},
|
||||
@@ -118,6 +103,21 @@
|
||||
{
|
||||
"path": "../../libs/components/checkbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/common"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/tauri-events"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/generation-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/help-menu"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -52,7 +52,7 @@ import {
|
||||
galleryModalLightboxNavPrev,
|
||||
galleryModalLightboxNavNext,
|
||||
} from "./galleryModalSignals";
|
||||
import { FOLDER_DROP_EVENT } from "./galleryDnd";
|
||||
import galleryDnd, { FOLDER_DROP_EVENT } from "./galleryDnd";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBorderAll,
|
||||
@@ -496,6 +496,11 @@ export const GalleryModal = React.memo(
|
||||
const lightboxVisibleSignal = galleryModalLightboxVisible;
|
||||
const failedImageUrls = useRef<Set<string>>(new Set());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Marquee (drag-rectangle) selection — view mode only. The rectangle is
|
||||
// positioned imperatively (no React state per frame) and tile rects are
|
||||
// cached at drag start; see handleMarqueePointerDown below.
|
||||
const marqueeRef = useRef<HTMLDivElement>(null);
|
||||
const marqueeRaf = useRef(0);
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [usernameError, setUsernameError] = useState(false);
|
||||
const [usernameRetryCount, setUsernameRetryCount] = useState(0);
|
||||
@@ -608,6 +613,27 @@ export const GalleryModal = React.memo(
|
||||
[folders, activeFolderId],
|
||||
);
|
||||
|
||||
// Root-level folders for the filter sidebar — always roots, regardless of
|
||||
// which folder is open in the browser tab (mirrors the webapp sidebar).
|
||||
const rootFolders = useMemo(
|
||||
() => folders.filter((f) => !f.parentId).sort(compareFolders),
|
||||
[folders],
|
||||
);
|
||||
|
||||
// Root ancestor of the open folder — drives the sidebar highlight (the
|
||||
// sidebar lists only root folders), guarded against parent-chain cycles.
|
||||
const activeRootFolderId = useMemo(() => {
|
||||
if (galleryTab !== "folders" || !activeFolderId) return null;
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const seen = new Set<string>();
|
||||
let cursor = byId.get(activeFolderId);
|
||||
while (cursor && cursor.parentId && !seen.has(cursor.id)) {
|
||||
seen.add(cursor.id);
|
||||
cursor = byId.get(cursor.parentId);
|
||||
}
|
||||
return cursor?.id ?? null;
|
||||
}, [galleryTab, folders, activeFolderId]);
|
||||
|
||||
// Breadcrumb trail from root → active folder (inclusive), guarded against
|
||||
// cycles so a corrupted parent chain can never loop forever.
|
||||
const folderPath = useMemo(() => {
|
||||
@@ -1027,6 +1053,214 @@ export const GalleryModal = React.memo(
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Marquee (drag) selection ──────────────────────────────────────────
|
||||
// Dragging from blank background draws a selection rectangle; tiles it
|
||||
// covers are added to the bulk selection (additive to whatever was
|
||||
// selected on start). Ported from the webapp library page, scoped to the
|
||||
// modal's scroll container. Perf-sensitive: the rectangle is positioned
|
||||
// imperatively, tile rects are cached at drag start, and selection commits
|
||||
// only when the covered set actually changes.
|
||||
const handleMarqueePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || lightboxVisibleSignal.value) return;
|
||||
// A per-tile drag (to a folder / the canvas) is already underway —
|
||||
// never start a marquee on top of it.
|
||||
if (galleryDnd.getDragState().isDragging) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
// Start only on blank background — never from tiles, folder cards,
|
||||
// controls, or opted-out chrome.
|
||||
if (
|
||||
target.closest(
|
||||
"[data-media-id], [data-folder-id], button, a, input, [data-no-marquee]",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scroller = scrollContainerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
// Blank-background press: suppress the browser's native text-selection
|
||||
// default so it can't hijack the marquee drag.
|
||||
e.preventDefault();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
// No marquee on touch — a finger drag should scroll. A still tap on
|
||||
// blank space still clears the selection below.
|
||||
const isTouch = e.pointerType !== "mouse";
|
||||
const base = new Set(bulkSelectedIds);
|
||||
let applied = base;
|
||||
let active = false;
|
||||
|
||||
const readScrollTop = () => scroller.scrollTop;
|
||||
const readScrollLeft = () => scroller.scrollLeft;
|
||||
const startScrollTop = readScrollTop();
|
||||
const startScrollLeft = readScrollLeft();
|
||||
let lastCx = startX;
|
||||
let lastCy = startY;
|
||||
let edgeRaf = 0;
|
||||
|
||||
let tiles: {
|
||||
id: string;
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}[] = [];
|
||||
const cacheTiles = () => {
|
||||
tiles = [];
|
||||
scroller
|
||||
.querySelectorAll<HTMLElement>("[data-media-id]")
|
||||
.forEach((el) => {
|
||||
const id = el.dataset.mediaId;
|
||||
if (!id) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
tiles.push({
|
||||
id,
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
right: r.right,
|
||||
bottom: r.bottom,
|
||||
});
|
||||
});
|
||||
};
|
||||
if (!isTouch) cacheTiles();
|
||||
|
||||
const applyMarquee = (cx: number, cy: number) => {
|
||||
// Compensate the anchor for scrolling since drag start so the
|
||||
// rectangle keeps growing in content space, not viewport space.
|
||||
const ax = startX - (readScrollLeft() - startScrollLeft);
|
||||
const ay = startY - (readScrollTop() - startScrollTop);
|
||||
const left = Math.min(ax, cx);
|
||||
const top = Math.min(ay, cy);
|
||||
const width = Math.abs(cx - ax);
|
||||
const height = Math.abs(cy - ay);
|
||||
const right = left + width;
|
||||
const bottom = top + height;
|
||||
const box = marqueeRef.current;
|
||||
if (box) {
|
||||
// Clamp the *visible* rectangle to the scroll viewport so it never
|
||||
// paints over the modal header / sidebar. Selection math below
|
||||
// uses the unclamped rect, so tiles scrolled out of view stay
|
||||
// selected.
|
||||
const vr = scroller.getBoundingClientRect();
|
||||
const dispLeft = Math.max(left, vr.left);
|
||||
const dispRight = Math.min(right, vr.right);
|
||||
const dispTop = Math.max(top, vr.top);
|
||||
const dispBottom = Math.min(bottom, vr.bottom);
|
||||
box.style.display = "block";
|
||||
box.style.left = `${dispLeft}px`;
|
||||
box.style.top = `${dispTop}px`;
|
||||
box.style.width = `${Math.max(0, dispRight - dispLeft)}px`;
|
||||
box.style.height = `${Math.max(0, dispBottom - dispTop)}px`;
|
||||
}
|
||||
const mounted = new Set(tiles.map((t) => t.id));
|
||||
const next = new Set(base);
|
||||
// Tiles swept over earlier may have unmounted (virtualized list)
|
||||
// once scrolled far away — keep them selected.
|
||||
for (const id of applied) {
|
||||
if (!base.has(id) && !mounted.has(id)) next.add(id);
|
||||
}
|
||||
for (const t of tiles) {
|
||||
if (
|
||||
t.left < right &&
|
||||
t.right > left &&
|
||||
t.top < bottom &&
|
||||
t.bottom > top
|
||||
) {
|
||||
next.add(t.id);
|
||||
}
|
||||
}
|
||||
// Commit only when coverage changed — most frames it hasn't.
|
||||
let changed = next.size !== applied.size;
|
||||
if (!changed) {
|
||||
for (const id of next) {
|
||||
if (!applied.has(id)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
applied = next;
|
||||
setBulkSelectedIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll while the cursor sits within EDGE px of the scroller's
|
||||
// top or bottom; speed ramps up nearer the edge. Self-perpetuating
|
||||
// via rAF until the cursor leaves the zone or the drag ends.
|
||||
const EDGE = 64;
|
||||
const MAX_SPEED = 24;
|
||||
const autoScrollTick = () => {
|
||||
edgeRaf = 0;
|
||||
if (!active || isTouch) return;
|
||||
const r = scroller.getBoundingClientRect();
|
||||
let dy = 0;
|
||||
if (lastCy < r.top + EDGE) {
|
||||
dy = -Math.min(MAX_SPEED, Math.ceil((r.top + EDGE - lastCy) / 3));
|
||||
} else if (lastCy > r.bottom - EDGE) {
|
||||
dy = Math.min(MAX_SPEED, Math.ceil((lastCy - (r.bottom - EDGE)) / 3));
|
||||
}
|
||||
if (dy === 0) return;
|
||||
scroller.scrollTop += dy;
|
||||
cacheTiles();
|
||||
applyMarquee(lastCx, lastCy);
|
||||
edgeRaf = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
const ensureAutoScroll = () => {
|
||||
if (!edgeRaf) edgeRaf = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const handleMove = (ev: PointerEvent) => {
|
||||
if (isTouch) return;
|
||||
if (!active) {
|
||||
// Small threshold so plain background clicks don't flash a marquee.
|
||||
if (
|
||||
Math.abs(ev.clientX - startX) < 5 &&
|
||||
Math.abs(ev.clientY - startY) < 5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
active = true;
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
lastCx = ev.clientX;
|
||||
lastCy = ev.clientY;
|
||||
ensureAutoScroll();
|
||||
cancelAnimationFrame(marqueeRaf.current);
|
||||
marqueeRaf.current = requestAnimationFrame(() =>
|
||||
applyMarquee(ev.clientX, ev.clientY),
|
||||
);
|
||||
};
|
||||
const handleScroll = () => {
|
||||
if (active && !isTouch) {
|
||||
cacheTiles();
|
||||
applyMarquee(lastCx, lastCy);
|
||||
}
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
window.removeEventListener("pointerup", handleUp);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
cancelAnimationFrame(marqueeRaf.current);
|
||||
cancelAnimationFrame(edgeRaf);
|
||||
document.body.style.userSelect = "";
|
||||
if (marqueeRef.current) marqueeRef.current.style.display = "none";
|
||||
// Plain click on blank background (no marquee drawn) clears the
|
||||
// selection, like clicking the desktop on an OS.
|
||||
if (!active) {
|
||||
setBulkSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
},
|
||||
[bulkSelectedIds, lightboxVisibleSignal],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: GalleryItem) => {
|
||||
if (mode === "select" && onSelectItem) {
|
||||
@@ -1280,6 +1514,16 @@ export const GalleryModal = React.memo(
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
// Open a folder from the filter sidebar: jump to the folder browser tab
|
||||
// with that folder active.
|
||||
const handleOpenFolderFromSidebar = useCallback(
|
||||
(folderId: string) => {
|
||||
setGalleryTab("folders");
|
||||
handleOpenFolder(folderId);
|
||||
},
|
||||
[handleOpenFolder],
|
||||
);
|
||||
|
||||
// Close any open folder menus
|
||||
const closeFolderMenus = useCallback(() => {
|
||||
setFolderMenuOpen(false);
|
||||
@@ -2167,49 +2411,118 @@ export const GalleryModal = React.memo(
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden" data-gallery-modal>
|
||||
{/* ── Filter sidebar ── (Unsorted tab / picker only; the Folders
|
||||
tab is a full-width browser) */}
|
||||
{!hideFilter && galleryTab === "unsorted" && (
|
||||
<div className="w-52 min-w-[13rem] border-r border-ui-panel-border bg-ui-background flex flex-col overflow-y-auto">
|
||||
{/* Filter items — hidden when the gallery is used as a constrained picker */}
|
||||
{!hideFilter && (
|
||||
<>
|
||||
<div className="flex flex-col px-1.5 pt-2 pb-1">
|
||||
{SIDEBAR_FILTERS.map((f) => {
|
||||
const isActive = activeFilter === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!forceFilter) {
|
||||
setActiveFilter(f.id);
|
||||
setActiveFolderId(null);
|
||||
}
|
||||
}}
|
||||
className={twMerge(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
|
||||
isActive && !activeFolderId
|
||||
? "bg-ui-controls/60 text-base-fg font-medium"
|
||||
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
|
||||
forceFilter &&
|
||||
f.id !== activeFilter &&
|
||||
"opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FontAwesomeIcon
|
||||
icon={f.icon}
|
||||
className="text-xs w-4"
|
||||
/>
|
||||
<span>{f.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* ── Filter sidebar ── shown on both tabs; clicking a filter
|
||||
jumps back to the library, clicking a folder into the browser */}
|
||||
{!hideFilter && (
|
||||
<div className="w-52 min-w-[13rem] border-r border-ui-panel-border bg-ui-panel flex flex-col overflow-hidden">
|
||||
{/* Filter items — pinned; they never scroll out of view */}
|
||||
<div className="flex flex-col px-1.5 pt-2 pb-1 flex-shrink-0">
|
||||
{SIDEBAR_FILTERS.map((f) => {
|
||||
const isActive =
|
||||
galleryTab === "unsorted" && activeFilter === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!forceFilter) {
|
||||
setActiveFilter(f.id);
|
||||
switchGalleryTab("unsorted");
|
||||
}
|
||||
}}
|
||||
className={twMerge(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
|
||||
isActive && !activeFolderId
|
||||
? "bg-ui-controls/60 text-base-fg font-medium"
|
||||
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
|
||||
forceFilter &&
|
||||
f.id !== activeFilter &&
|
||||
"opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FontAwesomeIcon
|
||||
icon={f.icon}
|
||||
className="text-xs w-4"
|
||||
/>
|
||||
<span>{f.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Folders header — pinned alongside the filters */}
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-1 flex-shrink-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-base-fg/40">
|
||||
Folders
|
||||
</span>
|
||||
{mode === "view" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenNewFolderModal(null)}
|
||||
aria-label="New folder"
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-base-fg/50 hover:bg-ui-controls/60 hover:text-base-fg transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="text-[10px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Folder list — the only scrolling region; fills the rest of the column */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-y-auto px-1.5 pb-2">
|
||||
{rootFolders.length === 0 ? (
|
||||
<div className="px-2.5 py-1.5 text-xs text-base-fg/30 italic">
|
||||
No folders yet
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
rootFolders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
data-folder-id={folder.id}
|
||||
onClick={() => handleOpenFolderFromSidebar(folder.id)}
|
||||
onContextMenu={
|
||||
// Folder management is view-mode only; the select picker just browses.
|
||||
mode === "view"
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
folderId: folder.id,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(
|
||||
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-base-fg",
|
||||
activeRootFolderId === folder.id
|
||||
? "bg-ui-controls/60 text-base-fg font-medium"
|
||||
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faFolder}
|
||||
className={twMerge(
|
||||
"text-xs w-4",
|
||||
!folder.colorCode && "text-primary",
|
||||
)}
|
||||
style={
|
||||
folder.colorCode
|
||||
? { color: folder.colorCode }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.hasStar && (
|
||||
<FontAwesomeIcon
|
||||
icon={faStar}
|
||||
className="ml-auto text-[10px] text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2218,6 +2531,9 @@ export const GalleryModal = React.memo(
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto bg-ui-panel"
|
||||
onScroll={handleScroll}
|
||||
onPointerDown={
|
||||
mode === "view" ? handleMarqueePointerDown : undefined
|
||||
}
|
||||
>
|
||||
{/* Subfolder tiles for the current location (root or open folder) */}
|
||||
{folderChipsSection}
|
||||
@@ -2767,6 +3083,22 @@ export const GalleryModal = React.memo(
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Marquee selection rectangle — fixed-positioned, painted imperatively
|
||||
by handleMarqueePointerDown. View mode only. Portaled to <body> so
|
||||
its `position: fixed` resolves against the viewport (the draggable
|
||||
modal applies a transform, which would otherwise make a fixed child
|
||||
position relative to the modal — and the rectangle, set with
|
||||
viewport clientX/Y, would render off-screen). */}
|
||||
{mode === "view" &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={marqueeRef}
|
||||
className="pointer-events-none fixed z-[9999] rounded-sm border border-primary/60 bg-primary/10"
|
||||
style={{ display: "none" }}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@storyteller/ui-generation-list",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export * from "./lib/types";
|
||||
export * from "./lib/pending-status";
|
||||
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/GalleryViewToggle";
|
||||
export * from "./lib/useGalleryData";
|
||||
export * from "./lib/useGalleryEntries";
|
||||
export * from "./lib/GalleryThumbnail";
|
||||
export * from "./lib/CopyPromptButton";
|
||||
export * from "./lib/PendingRow";
|
||||
export * from "./lib/FailedRow";
|
||||
export * from "./lib/GalleryRow";
|
||||
export * from "./lib/PendingCard";
|
||||
export * from "./lib/FailedCard";
|
||||
export * from "./lib/GalleryCard";
|
||||
export * from "./lib/GenerationListView";
|
||||
export * from "./lib/GenerationGridView";
|
||||
+11
-5
@@ -2,12 +2,18 @@ import { useCallback, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCheck, faCopy } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { toast } from "../toast/toast";
|
||||
|
||||
// Copies a prompt to the clipboard, with copy→check feedback. Sits at the
|
||||
// right of a row's prompt line. Shared by the completed / pending / failed
|
||||
// rows. stopPropagation keeps a tap from also triggering the row's onClick.
|
||||
export function CopyPromptButton({ text }: { text: string }) {
|
||||
// Hosts surface success/failure feedback (e.g. a toast) via `onCopyResult`.
|
||||
export function CopyPromptButton({
|
||||
text,
|
||||
onCopyResult,
|
||||
}: {
|
||||
text: string;
|
||||
onCopyResult?: (success: boolean) => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
@@ -15,14 +21,14 @@ export function CopyPromptButton({ text }: { text: string }) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Prompt copied");
|
||||
onCopyResult?.(true);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("Unable to copy prompt");
|
||||
onCopyResult?.(false);
|
||||
}
|
||||
},
|
||||
[text],
|
||||
[text, onCopyResult],
|
||||
);
|
||||
|
||||
return (
|
||||
+8
-34
@@ -1,13 +1,7 @@
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faCircleExclamation,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
|
||||
export interface FailedCardProps {
|
||||
id: string;
|
||||
@@ -20,10 +14,8 @@ export interface FailedCardProps {
|
||||
// at low opacity so failed cards still hint at what the user was trying to
|
||||
// generate.
|
||||
refImageUrl?: string;
|
||||
// Prompt token + media class enable the "Recreate" action. Without a prompt
|
||||
// token there's nothing to seed the create page with, so the button hides.
|
||||
promptToken?: string;
|
||||
recreateMediaClass: "image" | "video";
|
||||
/** Inline action (e.g. a labeled Recreate button) next to Dismiss. */
|
||||
recreateSlot?: ReactNode;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -35,15 +27,10 @@ export const FailedCard = memo(function FailedCard({
|
||||
modelId,
|
||||
modelLabel,
|
||||
refImageUrl,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
recreateSlot,
|
||||
onDismiss,
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10">
|
||||
@@ -69,20 +56,7 @@ export const FailedCard = memo(function FailedCard({
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
{promptToken && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={isRecreating ? "animate-spin" : ""}
|
||||
/>
|
||||
Recreate
|
||||
</button>
|
||||
)}
|
||||
{recreateSlot}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(id)}
|
||||
+27
-35
@@ -1,16 +1,25 @@
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faCircleExclamation,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import { CopyPromptButton } from "./CopyPromptButton";
|
||||
import type { FailedCardProps } from "./FailedCard";
|
||||
|
||||
export interface FailedRowProps {
|
||||
id: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
// First user-supplied still image, if any. Rendered behind the error overlay
|
||||
// at low opacity so failed rows still hint at what the user was trying to
|
||||
// generate.
|
||||
refImageUrl?: string;
|
||||
/** Inline action (e.g. a Recreate button) after the failure reason. */
|
||||
recreateSlot?: ReactNode;
|
||||
onDismiss: (id: string) => void;
|
||||
onCopyPromptResult?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export const FailedRow = memo(function FailedRow({
|
||||
id,
|
||||
@@ -20,15 +29,11 @@ export const FailedRow = memo(function FailedRow({
|
||||
modelId,
|
||||
modelLabel,
|
||||
refImageUrl,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
recreateSlot,
|
||||
onDismiss,
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
onCopyPromptResult,
|
||||
}: FailedRowProps) {
|
||||
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg px-2.5 py-2">
|
||||
@@ -54,23 +59,10 @@ export const FailedRow = memo(function FailedRow({
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-red-400">
|
||||
{failureReason || "Generation failed"}
|
||||
</p>
|
||||
{prompt && <CopyPromptButton text={prompt} />}
|
||||
{promptToken && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{prompt && (
|
||||
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
|
||||
)}
|
||||
{recreateSlot}
|
||||
</div>
|
||||
{prompt && (
|
||||
<p className="mt-0.5 truncate text-xs text-white/45">{prompt}</p>
|
||||
+26
-92
@@ -1,17 +1,12 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { memo, useCallback, useState, type ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
import { useGalleryItemActions } from "./useGalleryItemActions";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
import type { GalleryItem } from "./types";
|
||||
|
||||
// ── Persistent aspect ratio cache ─────────────────────────────────────────
|
||||
|
||||
@@ -58,43 +53,36 @@ export const aspectRatioCache = loadCache();
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface GalleryCardProps {
|
||||
export interface GalleryCardProps {
|
||||
item: GalleryItem;
|
||||
onClick: (item: GalleryItem) => void;
|
||||
// "auto" = dynamic aspect ratio from the loaded image (masonry layouts).
|
||||
// "square" = fixed 1:1; skips the ratio measurement path (uniform grids).
|
||||
shape?: "auto" | "square";
|
||||
// When true, image cards show a "Make Video" quick action that seeds the
|
||||
// create-video page with the image as a reference. Enabled on the create
|
||||
// image page only.
|
||||
enableMakeVideo?: boolean;
|
||||
/** Hover-revealed quick-action cluster (recreate / share / download …). */
|
||||
actionsSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export const GalleryCard = memo(function GalleryCard({
|
||||
item,
|
||||
onClick,
|
||||
shape = "auto",
|
||||
enableMakeVideo = false,
|
||||
actionsSlot,
|
||||
}: GalleryCardProps) {
|
||||
const isSquare = shape === "square";
|
||||
const cached = aspectRatioCache.get(item.id);
|
||||
const [ratio, setRatio] = useState<number | undefined>(cached);
|
||||
|
||||
const {
|
||||
mediaIcon,
|
||||
mediaLabel,
|
||||
modelDisplayName,
|
||||
modelIconPath,
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const mediaIcon = isVideo ? faVideo : is3D ? faCube : faImage;
|
||||
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : "Image";
|
||||
const modelDisplayName = item.modelId
|
||||
? getModelDisplayName(item.modelId)
|
||||
: null;
|
||||
const modelIconPath = item.modelId
|
||||
? getCreatorIconPathForModelId(item.modelId)
|
||||
: null;
|
||||
|
||||
const displayRatio = ratio ? Math.min(ratio, MAX_RATIO) : 1;
|
||||
|
||||
@@ -151,7 +139,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
<GalleryThumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.label}
|
||||
isVideo={item.mediaClass === "video"}
|
||||
isVideo={isVideo}
|
||||
fallbackIcon={mediaIcon}
|
||||
onLoad={measureRatio}
|
||||
/>
|
||||
@@ -176,65 +164,11 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
aria-label="Download"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{actionsSlot && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
+36
-117
@@ -1,47 +1,50 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faPlay, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faPlay,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
import { CopyPromptButton } from "./CopyPromptButton";
|
||||
import {
|
||||
useGalleryItemActions,
|
||||
type GalleryItemActions,
|
||||
} from "./useGalleryItemActions";
|
||||
import { formatTimeAgo } from "../../lib/format-time-ago";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
import { formatTimeAgo } from "./format-time-ago";
|
||||
import type { GalleryItem } from "./types";
|
||||
|
||||
interface GalleryRowProps {
|
||||
export interface GalleryRowProps {
|
||||
item: GalleryItem;
|
||||
onClick: (item: GalleryItem) => void;
|
||||
enableMakeVideo?: boolean;
|
||||
// Prompt + model resolved from the prompt record by the list. Fall back to
|
||||
// the item's own fields when the prompt hasn't loaded (or has no text).
|
||||
title?: string;
|
||||
modelId?: string;
|
||||
// The prompt record is still resolving — show a skeleton in place of the title.
|
||||
loading?: boolean;
|
||||
/** Hover-revealed quick-action cluster (recreate / share / download …). */
|
||||
actionsSlot?: ReactNode;
|
||||
onCopyPromptResult?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export const GalleryRow = memo(function GalleryRow({
|
||||
item,
|
||||
onClick,
|
||||
enableMakeVideo = false,
|
||||
title,
|
||||
modelId,
|
||||
loading = false,
|
||||
actionsSlot,
|
||||
onCopyPromptResult,
|
||||
}: GalleryRowProps) {
|
||||
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
|
||||
const { isVideo, mediaIcon, mediaLabel, modelDisplayName, modelIconPath } =
|
||||
actions;
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const mediaIcon = isVideo ? faVideo : is3D ? faCube : faImage;
|
||||
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : "Image";
|
||||
|
||||
const effectiveModelId = modelId ?? item.modelId;
|
||||
const modelDisplayName = effectiveModelId
|
||||
? getModelDisplayName(effectiveModelId)
|
||||
: null;
|
||||
const modelIconPath = effectiveModelId
|
||||
? getCreatorIconPathForModelId(effectiveModelId)
|
||||
: null;
|
||||
|
||||
const handleRowClick = useCallback(() => onClick(item), [item, onClick]);
|
||||
const handleRowKeyDown = useCallback(
|
||||
@@ -97,7 +100,12 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
<p className="line-clamp-3 min-w-0 flex-1 text-sm leading-snug text-white/90">
|
||||
{title || item.label}
|
||||
</p>
|
||||
{title && <CopyPromptButton text={title} />}
|
||||
{title && (
|
||||
<CopyPromptButton
|
||||
text={title}
|
||||
onCopyResult={onCopyPromptResult}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-white/45">
|
||||
@@ -121,12 +129,11 @@ 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. */}
|
||||
<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">
|
||||
<GalleryRowActions
|
||||
actions={actions}
|
||||
hasDownload={!!item.fullImage}
|
||||
/>
|
||||
</div>
|
||||
{actionsSlot && (
|
||||
<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>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-white/40">
|
||||
{timeAgo}
|
||||
</span>
|
||||
@@ -135,91 +142,3 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Recreate / make-video / share / download buttons, shared by the desktop
|
||||
// (hover) and mobile (inline) clusters. Each handler stops propagation so taps
|
||||
// don't also open the lightbox.
|
||||
function GalleryRowActions({
|
||||
actions,
|
||||
hasDownload,
|
||||
}: {
|
||||
actions: GalleryItemActions;
|
||||
hasDownload: boolean;
|
||||
}) {
|
||||
const {
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = actions;
|
||||
|
||||
const buttonClass =
|
||||
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<>
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{hasDownload && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
|
||||
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
|
||||
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
|
||||
// video thumbnails 404 until the render job finishes (so we retry on a timer
|
||||
// and on tab refocus), and broken image URLs fall back to a placeholder.
|
||||
|
||||
// ── Shared visibility listener for video thumbnail retries ────────────────
|
||||
|
||||
const visibilityCallbacks = new Set<() => void>();
|
||||
|
||||
function onTabVisible(cb: () => void) {
|
||||
visibilityCallbacks.add(cb);
|
||||
if (visibilityCallbacks.size === 1) {
|
||||
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
return () => {
|
||||
visibilityCallbacks.delete(cb);
|
||||
if (visibilityCallbacks.size === 0) {
|
||||
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fireVisibilityCallbacks() {
|
||||
if (document.hidden) return;
|
||||
visibilityCallbacks.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ── Retry constants ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 20;
|
||||
const RETRY_INTERVAL = 5000;
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface GalleryThumbnailProps {
|
||||
thumbnail: string | null;
|
||||
alt: string;
|
||||
isVideo: boolean;
|
||||
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
|
||||
fallbackIcon: IconDefinition;
|
||||
// Classes for the <img> element (sizing / object-fit).
|
||||
imgClassName?: string;
|
||||
fallbackIconClassName?: string;
|
||||
// The "Loading thumbnail…" caption is too large for small list rows.
|
||||
showRetryLabel?: boolean;
|
||||
// Called on successful load — the card uses it to measure aspect ratio.
|
||||
onLoad?: (e: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
}
|
||||
|
||||
export function GalleryThumbnail({
|
||||
thumbnail,
|
||||
alt,
|
||||
isVideo,
|
||||
fallbackIcon,
|
||||
imgClassName = "block h-full w-full object-cover",
|
||||
fallbackIconClassName = "text-xl text-white/20",
|
||||
showRetryLabel = true,
|
||||
onLoad,
|
||||
}: GalleryThumbnailProps) {
|
||||
// "retrying" flips to true only after the first error, so videos with ready
|
||||
// thumbnails render the normal <img> path with zero overhead. While retrying,
|
||||
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const retryImgRef = useRef<HTMLImageElement>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const retryCountRef = useRef(0);
|
||||
|
||||
const kickRetry = useCallback(() => {
|
||||
if (!retryImgRef.current || !thumbnail) return;
|
||||
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
|
||||
}, [thumbnail]);
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
|
||||
if (document.hidden) return;
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryCountRef.current++;
|
||||
kickRetry();
|
||||
}, RETRY_INTERVAL);
|
||||
}, [thumbnail, kickRetry]);
|
||||
|
||||
// Subscribe to the shared visibility listener while a video card is retrying.
|
||||
useEffect(() => {
|
||||
if (!isVideo || !thumbnail || !retrying) return;
|
||||
const unsubscribe = onTabVisible(() => {
|
||||
retryCountRef.current = 0;
|
||||
kickRetry();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [isVideo, thumbnail, retrying, kickRetry]);
|
||||
|
||||
// Reset retry state when the thumbnail URL changes.
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}, [isVideo, thumbnail]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo && retrying) {
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
onLoad?.(e);
|
||||
},
|
||||
[isVideo, retrying, onLoad],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo) {
|
||||
setRetrying(true);
|
||||
scheduleRetry();
|
||||
} else {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallback) return;
|
||||
target.dataset.fallback = "1";
|
||||
target.src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
target.style.opacity = "0.3";
|
||||
}
|
||||
},
|
||||
[isVideo, scheduleRetry],
|
||||
);
|
||||
|
||||
if (retrying) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg text-white/30"
|
||||
/>
|
||||
{showRetryLabel && (
|
||||
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Hidden img retries in the background via ref — zero re-renders */}
|
||||
<img
|
||||
ref={retryImgRef}
|
||||
src={thumbnail!}
|
||||
alt=""
|
||||
className="absolute h-0 w-0 opacity-0"
|
||||
aria-hidden
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (thumbnail) {
|
||||
return (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={imgClassName}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon icon={fallbackIcon} className={fallbackIconClassName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGrid2, faListUl } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
useGalleryViewStore,
|
||||
type GalleryViewMode,
|
||||
} from "./gallery-view-store";
|
||||
|
||||
const OPTIONS: {
|
||||
mode: GalleryViewMode;
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ mode: "grid", icon: faGrid2, label: "Grid view" },
|
||||
{ mode: "list", icon: faListUl, label: "List view" },
|
||||
];
|
||||
|
||||
// Segmented grid/list switch for the create-page galleries. Lives in the
|
||||
// TopBar; the selection is shared with the gallery via useGalleryViewStore.
|
||||
export function GalleryViewToggle() {
|
||||
const viewMode = useGalleryViewStore((s) => s.viewMode);
|
||||
const setViewMode = useGalleryViewStore((s) => s.setViewMode);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Gallery layout"
|
||||
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
|
||||
>
|
||||
{OPTIONS.map(({ mode, icon, label }) => {
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Tooltip key={mode} content={label} position="bottom" delay={300}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={() => setViewMode(mode)}
|
||||
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={icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { PendingCard } from "./PendingCard";
|
||||
import { FailedCard } from "./FailedCard";
|
||||
import { GalleryCard } from "./GalleryCard";
|
||||
import {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
import type { RecreateSlotContext } from "./GenerationListView";
|
||||
import type { FailedJob, GalleryItem, InProgressJob } from "./types";
|
||||
|
||||
// ── Grid layout constants ─────────────────────────────────────────────────
|
||||
|
||||
const BREAKPOINT_COLS = {
|
||||
default: 4,
|
||||
1280: 4,
|
||||
900: 3,
|
||||
640: 2,
|
||||
};
|
||||
|
||||
// 12px gap on both axes (≈ Tailwind gap-3).
|
||||
// ml-[-12px] on container offsets the first column's pl-[8px].
|
||||
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
|
||||
const COLUMN_CLASS = "pl-[8px]";
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GenerationGridViewProps {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
onDismissFailed: (jobToken: string) => void;
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
hasMore: boolean;
|
||||
isLoading?: boolean;
|
||||
isInitialLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
onGalleryItemClick: (item: GalleryItem) => void;
|
||||
/** Per-card "Recreate" affordance for pending/failed cards. */
|
||||
renderRecreate?: (ctx: RecreateSlotContext) => ReactNode;
|
||||
/** Per-card hover quick-action cluster for completed cards. */
|
||||
renderGalleryActions?: (
|
||||
item: GalleryItem,
|
||||
ctx: { modelId?: string; promptToken?: string },
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
export function GenerationGridView({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
onDismissFailed,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
renderRecreate,
|
||||
renderGalleryActions,
|
||||
}: GenerationGridViewProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Masonry
|
||||
breakpointCols={BREAKPOINT_COLS}
|
||||
className={MASONRY_CLASS}
|
||||
columnClassName={COLUMN_CLASS}
|
||||
>
|
||||
{mergedEntries.map((entry) => (
|
||||
<div key={entry.key} className="mb-[8px]">
|
||||
{entry.kind === "pending" && (
|
||||
<PendingCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
mediaClass={entry.job.mediaClass}
|
||||
recreateSlot={
|
||||
entry.job.promptToken && renderRecreate
|
||||
? renderRecreate({
|
||||
promptToken: entry.job.promptToken,
|
||||
mediaClass: entry.job.mediaClass,
|
||||
kind: "pending",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "failed" && (
|
||||
<FailedCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
refImageUrl={entry.job.refImageUrl}
|
||||
recreateSlot={
|
||||
entry.job.promptToken && renderRecreate
|
||||
? renderRecreate({
|
||||
promptToken: entry.job.promptToken,
|
||||
mediaClass: entry.job.mediaClass,
|
||||
kind: "failed",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onDismiss={onDismissFailed}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "gallery" && (
|
||||
<GalleryCard
|
||||
item={entry.item}
|
||||
onClick={onGalleryItemClick}
|
||||
actionsSlot={renderGalleryActions?.(entry.item, {
|
||||
modelId: entry.item.modelId,
|
||||
promptToken: entry.item.promptToken,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Masonry>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { PendingRow } from "./PendingRow";
|
||||
import { FailedRow } from "./FailedRow";
|
||||
import { GalleryRow } from "./GalleryRow";
|
||||
import {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
import { usePrompts } from "./prompts-cache";
|
||||
import { useMediaPromptTokens } from "./media-prompt-token-cache";
|
||||
import type { FailedJob, GalleryItem, InProgressJob } from "./types";
|
||||
|
||||
// Constrains the feed to the promptbox width (max-w-5xl) and stacks one row
|
||||
// per generation: a single time-sorted merge of the in-progress / failed /
|
||||
// completed streams. Host-specific affordances (recreate, share/download,
|
||||
// toasts) are injected through the render-prop seams so the webapp and the
|
||||
// desktop app can share this view verbatim.
|
||||
|
||||
export interface RecreateSlotContext {
|
||||
promptToken: string;
|
||||
mediaClass: "image" | "video";
|
||||
kind: "pending" | "failed";
|
||||
}
|
||||
|
||||
export interface GenerationListViewProps {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
onDismissFailed: (jobToken: string) => void;
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
hasMore: boolean;
|
||||
isLoading?: boolean;
|
||||
isInitialLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
onGalleryItemClick: (item: GalleryItem) => void;
|
||||
/** Per-row "Recreate" affordance for pending/failed rows. */
|
||||
renderRecreate?: (ctx: RecreateSlotContext) => ReactNode;
|
||||
/** Per-row hover quick-action cluster for completed rows. `modelId` and
|
||||
* `promptToken` come from the prompt record resolved by the list (richer
|
||||
* than the media file's own fields). */
|
||||
renderGalleryActions?: (
|
||||
item: GalleryItem,
|
||||
ctx: { modelId?: string; promptToken?: string },
|
||||
) => ReactNode;
|
||||
/** Copy-prompt feedback (e.g. host toast). */
|
||||
onCopyPromptResult?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export function GenerationListView({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
onDismissFailed,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
renderRecreate,
|
||||
renderGalleryActions,
|
||||
onCopyPromptResult,
|
||||
}: GenerationListViewProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
|
||||
// Show the real prompt text + model on completed rows instead of the
|
||||
// "Image Generation" placeholder. The profile media-list endpoint doesn't
|
||||
// return prompt tokens yet, so for items missing one we resolve it through
|
||||
// the batch media-files endpoint (media token → prompt token), then feed the
|
||||
// prompt token into the prompts cache (prompt token → text + model). Both
|
||||
// steps are batched + cached. Pending/failed entries already carry a prompt.
|
||||
const mediaTokensNeedingPrompt = useMemo(
|
||||
() =>
|
||||
mergedEntries.flatMap((entry) =>
|
||||
entry.kind === "gallery" && !entry.item.promptToken
|
||||
? [entry.item.id]
|
||||
: [],
|
||||
),
|
||||
[mergedEntries],
|
||||
);
|
||||
const resolvedPromptTokens = useMediaPromptTokens(mediaTokensNeedingPrompt);
|
||||
|
||||
// For an item: its (non-empty) prompt token, and whether the media→prompt
|
||||
// lookup has finished. `resolved` lets rows show a skeleton while loading and
|
||||
// fall back to the label once we know there's no prompt.
|
||||
const promptStateFor = useCallback(
|
||||
(item: { id: string; promptToken?: string }) => {
|
||||
const direct = item.promptToken;
|
||||
const resolved = direct != null || resolvedPromptTokens.has(item.id);
|
||||
const promptToken = direct || resolvedPromptTokens.get(item.id) || "";
|
||||
return { promptToken, resolved };
|
||||
},
|
||||
[resolvedPromptTokens],
|
||||
);
|
||||
|
||||
const promptTokens = useMemo(() => {
|
||||
const tokens: string[] = [];
|
||||
for (const entry of mergedEntries) {
|
||||
if (entry.kind !== "gallery") continue;
|
||||
const { promptToken } = promptStateFor(entry.item);
|
||||
if (promptToken) tokens.push(promptToken);
|
||||
}
|
||||
return tokens;
|
||||
}, [mergedEntries, promptStateFor]);
|
||||
const promptsMap = usePrompts(promptTokens);
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl sm:px-4">
|
||||
<div className="flex flex-col divide-y divide-white/[0.04]">
|
||||
{mergedEntries.map((entry) => {
|
||||
if (entry.kind === "pending") {
|
||||
return (
|
||||
<PendingRow
|
||||
key={entry.key}
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
mediaClass={entry.job.mediaClass}
|
||||
recreateSlot={
|
||||
entry.job.promptToken && renderRecreate
|
||||
? renderRecreate({
|
||||
promptToken: entry.job.promptToken,
|
||||
mediaClass: entry.job.mediaClass,
|
||||
kind: "pending",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onCopyPromptResult={onCopyPromptResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "failed") {
|
||||
return (
|
||||
<FailedRow
|
||||
key={entry.key}
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
refImageUrl={entry.job.refImageUrl}
|
||||
recreateSlot={
|
||||
entry.job.promptToken && renderRecreate
|
||||
? renderRecreate({
|
||||
promptToken: entry.job.promptToken,
|
||||
mediaClass: entry.job.mediaClass,
|
||||
kind: "failed",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onDismiss={onDismissFailed}
|
||||
onCopyPromptResult={onCopyPromptResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { promptToken, resolved } = promptStateFor(entry.item);
|
||||
const prompt = promptToken ? promptsMap.get(promptToken) : undefined;
|
||||
// Loading while the media→prompt lookup is pending, or it found a
|
||||
// prompt token whose text hasn't arrived yet.
|
||||
const loading = !resolved || (!!promptToken && !prompt);
|
||||
const modelId = prompt?.maybe_model_type || undefined;
|
||||
return (
|
||||
<GalleryRow
|
||||
key={entry.key}
|
||||
item={entry.item}
|
||||
onClick={onGalleryItemClick}
|
||||
title={prompt?.maybe_positive_prompt?.trim() || undefined}
|
||||
modelId={modelId}
|
||||
loading={loading}
|
||||
actionsSlot={renderGalleryActions?.(entry.item, {
|
||||
modelId,
|
||||
promptToken: promptToken || undefined,
|
||||
})}
|
||||
onCopyPromptResult={onCopyPromptResult}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-35
@@ -1,12 +1,7 @@
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import { derivePendingStatus } from "./pending-status";
|
||||
|
||||
export interface PendingCardProps {
|
||||
@@ -17,9 +12,9 @@ export interface PendingCardProps {
|
||||
progress?: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
batchCount?: number;
|
||||
// Prompt token + media class enable the "Recreate" action mid-generation.
|
||||
promptToken?: string;
|
||||
recreateMediaClass: "image" | "video";
|
||||
mediaClass: "image" | "video";
|
||||
/** Hover-revealed action (e.g. a Recreate button) next to the prompt. */
|
||||
recreateSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({
|
||||
@@ -29,26 +24,24 @@ export const PendingCard = memo(function PendingCard({
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
batchCount,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
mediaClass,
|
||||
recreateSlot,
|
||||
}: PendingCardProps) {
|
||||
const { progressPercent, timeLabel } = derivePendingStatus(
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
);
|
||||
|
||||
const iconPath = getModelCreatorIconPath(modelId);
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
const iconPath = getCreatorIconPathForModelId(modelId);
|
||||
|
||||
return (
|
||||
<div className="group relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
|
||||
<div className="animate-shimmer h-full w-full" />
|
||||
{batchCount != null && batchCount > 1 && (
|
||||
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
|
||||
Generating {batchCount} videos · Results may appear one at a time
|
||||
Generating {batchCount}{" "}
|
||||
{mediaClass === "image" ? "images" : "videos"} · Results may appear
|
||||
one at a time
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
@@ -70,22 +63,7 @@ export const PendingCard = memo(function PendingCard({
|
||||
<p className="line-clamp-3 min-w-0 flex-1 text-xs leading-relaxed text-white/80">
|
||||
{prompt}
|
||||
</p>
|
||||
{promptToken && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recreateSlot}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<img
|
||||
+30
-34
@@ -1,15 +1,23 @@
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import { CopyPromptButton } from "./CopyPromptButton";
|
||||
import { derivePendingStatus } from "./pending-status";
|
||||
import type { PendingCardProps } from "./PendingCard";
|
||||
|
||||
export interface PendingRowProps {
|
||||
id: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
prompt: string;
|
||||
progress?: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
batchCount?: number;
|
||||
mediaClass: "image" | "video";
|
||||
/** Hover-revealed action (e.g. a Recreate button) after the model label. */
|
||||
recreateSlot?: ReactNode;
|
||||
onCopyPromptResult?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export const PendingRow = memo(function PendingRow({
|
||||
modelId,
|
||||
@@ -18,18 +26,15 @@ export const PendingRow = memo(function PendingRow({
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
batchCount,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
}: PendingCardProps) {
|
||||
mediaClass,
|
||||
recreateSlot,
|
||||
onCopyPromptResult,
|
||||
}: PendingRowProps) {
|
||||
const { progressPercent, timeLabel } = derivePendingStatus(
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
);
|
||||
const iconPath = getModelCreatorIconPath(modelId);
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
const iconPath = getCreatorIconPathForModelId(modelId);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-lg px-2.5 py-2">
|
||||
@@ -58,7 +63,9 @@ export const PendingRow = memo(function PendingRow({
|
||||
Generating…
|
||||
</p>
|
||||
)}
|
||||
{prompt.trim() && <CopyPromptButton text={prompt} />}
|
||||
{prompt.trim() && (
|
||||
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-white/45">
|
||||
<img
|
||||
@@ -70,27 +77,16 @@ export const PendingRow = memo(function PendingRow({
|
||||
{batchCount != null && batchCount > 1 && (
|
||||
<>
|
||||
<span className="text-white/25">·</span>
|
||||
<span className="shrink-0">{batchCount} videos</span>
|
||||
<span className="shrink-0">
|
||||
{batchCount} {mediaClass === "image" ? "images" : "videos"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Recreate is hover-revealed on desktop (always visible on mobile),
|
||||
mirroring the completed GalleryRow's quick actions. */}
|
||||
{promptToken && (
|
||||
{recreateSlot && (
|
||||
<div className="flex shrink-0 items-center sm:ms-1 sm:opacity-0 transition-opacity sm:group-hover:opacity-100 sm:focus-within:opacity-100">
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{recreateSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
// Compact relative-time formatter ("just now", "5 minutes ago", "2 months
|
||||
// ago") for gallery list rows. Kept dependency-free — the apps have no date
|
||||
// library, and this avoids pulling one in for a single label.
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = MINUTE * 60;
|
||||
const DAY = HOUR * 24;
|
||||
const WEEK = DAY * 7;
|
||||
const MONTH = DAY * 30;
|
||||
const YEAR = DAY * 365;
|
||||
|
||||
const pluralize = (value: number, unit: string): string =>
|
||||
`${value} ${unit}${value === 1 ? "" : "s"} ago`;
|
||||
|
||||
export function formatTimeAgo(input: string | number | Date): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "just now";
|
||||
if (seconds < 45) return "just now";
|
||||
if (seconds < HOUR) return pluralize(Math.round(seconds / MINUTE), "minute");
|
||||
if (seconds < DAY) return pluralize(Math.round(seconds / HOUR), "hour");
|
||||
if (seconds < WEEK) return pluralize(Math.round(seconds / DAY), "day");
|
||||
if (seconds < MONTH) return pluralize(Math.round(seconds / WEEK), "week");
|
||||
if (seconds < YEAR) return pluralize(Math.round(seconds / MONTH), "month");
|
||||
return pluralize(Math.round(seconds / YEAR), "year");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
// View mode for the create-page generation galleries. Shared between the
|
||||
// create pages and the TopBar toggle, and persisted so the user's preference
|
||||
// survives reloads and carries across pages.
|
||||
|
||||
export type GalleryViewMode = "grid" | "list";
|
||||
|
||||
interface GalleryViewState {
|
||||
viewMode: GalleryViewMode;
|
||||
setViewMode: (mode: GalleryViewMode) => void;
|
||||
}
|
||||
|
||||
export const useGalleryViewStore = create<GalleryViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "grid",
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
}),
|
||||
{ name: "artcraft-gallery-view" },
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
|
||||
// Workaround until the profile media-list endpoint returns prompt tokens:
|
||||
// resolve media token → prompt token via the batch media-files endpoint
|
||||
// (GET /v1/media_files/batch), which does include `maybe_prompt_token`. The
|
||||
// resolved prompt token is then fed into the prompts cache (usePrompts) to get
|
||||
// the prompt text + model. Mirrors prompts-cache.ts: module-level cache,
|
||||
// in-flight dedupe, and a subscribe-on-load hook.
|
||||
|
||||
// mediaToken → promptToken ("" = resolved, but the media has no prompt token).
|
||||
const tokenCache = new Map<string, string>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: MediaFilesApi | null = null;
|
||||
|
||||
// Keep request URLs (one `tokens=` param per token) comfortably under length limits.
|
||||
const CHUNK_SIZE = 50;
|
||||
|
||||
function getApi(): MediaFilesApi {
|
||||
if (!api) api = new MediaFilesApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(mediaTokens: readonly string[]): Promise<void> {
|
||||
const missing = mediaTokens.filter(
|
||||
(t) => t && !tokenCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
|
||||
try {
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
|
||||
chunks.push(missing.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
const responses = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
getApi().ListMediaFilesByTokens({ mediaTokens: chunk }),
|
||||
),
|
||||
);
|
||||
for (const response of responses) {
|
||||
if (!response.success || !response.data) continue;
|
||||
for (const file of response.data) {
|
||||
tokenCache.set(file.token, file.maybe_prompt_token ?? "");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Swallow — the finally block marks the tokens resolved (empty) so the
|
||||
// loading skeleton clears and we don't refetch in a loop.
|
||||
} finally {
|
||||
// Mark every requested token resolved. Tokens that errored or weren't
|
||||
// returned get "" (no prompt) so the row falls back to its label instead
|
||||
// of showing a skeleton forever. Always notify so skeletons clear.
|
||||
for (const t of missing) {
|
||||
if (!tokenCache.has(t)) tokenCache.set(t, "");
|
||||
inFlight.delete(t);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns mediaToken → promptToken for every token whose media→prompt lookup
|
||||
// has completed; the value is "" when the media has no prompt. A token that is
|
||||
// absent from the map is still resolving (use `.has()` to distinguish that
|
||||
// from a resolved-but-promptless item). Triggers a batched fetch for any not
|
||||
// yet resolved.
|
||||
export function useMediaPromptTokens(
|
||||
mediaTokens: readonly (string | null | undefined)[],
|
||||
): Map<string, string> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of mediaTokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list, order-independent.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, string>();
|
||||
for (const t of cleaned) {
|
||||
const promptToken = tokenCache.get(t);
|
||||
// Include "" entries so callers can tell "resolved, no prompt" (present)
|
||||
// apart from "still loading" (absent).
|
||||
if (promptToken !== undefined) result.set(t, promptToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Progress + time-left derivation shared by the pending card and pending row.
|
||||
|
||||
export interface PendingStatus {
|
||||
progressPercent: number | null;
|
||||
timeLabel: string | null;
|
||||
}
|
||||
|
||||
export function derivePendingStatus(
|
||||
progress?: number,
|
||||
estimatedTimeLeftMs?: number,
|
||||
): PendingStatus {
|
||||
const progressPercent =
|
||||
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
|
||||
const isAlmostDone = progress != null && progress >= 95;
|
||||
const timeLabel = isAlmostDone
|
||||
? "Almost done..."
|
||||
: estimatedTimeLeftMs != null && estimatedTimeLeftMs > 0
|
||||
? formatTimeLeft(estimatedTimeLeftMs)
|
||||
: null;
|
||||
return { progressPercent, timeLabel };
|
||||
}
|
||||
|
||||
function formatTimeLeft(ms: number): string {
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0 && minutes > 0) return `~${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `~${hours}h`;
|
||||
if (minutes > 0) return `~${minutes}m`;
|
||||
return `~${seconds}s`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
|
||||
const promptCache = new Map<string, Prompts>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: PromptsApi | null = null;
|
||||
|
||||
function getApi(): PromptsApi {
|
||||
if (!api) api = new PromptsApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(tokens: readonly string[]): Promise<void> {
|
||||
const missing = tokens.filter(
|
||||
(t) => t && !promptCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
try {
|
||||
const response = await getApi().BatchGetPrompts({ tokens: missing });
|
||||
if (response.success && response.data) {
|
||||
for (const p of response.data) {
|
||||
promptCache.set(p.token, p);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
} catch {
|
||||
// Failures will be retried when usePrompts re-runs with the same tokens.
|
||||
} finally {
|
||||
for (const t of missing) inFlight.delete(t);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedPrompt(token: string): Prompts | undefined {
|
||||
return promptCache.get(token);
|
||||
}
|
||||
|
||||
export function usePrompts(
|
||||
tokens: readonly (string | null | undefined)[],
|
||||
): Map<string, Prompts> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list. Sorting keeps it stable when
|
||||
// a poll returns the same set in a different order.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
for (const t of cleaned) {
|
||||
const p = promptCache.get(t);
|
||||
if (p) result.set(t, p);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Canonical data shapes for the merged generation feed (in-progress / failed /
|
||||
// completed). Hosts (webapp, desktop) map their own job sources into these.
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
fullImage: string | null;
|
||||
createdAt: string;
|
||||
mediaClass: string;
|
||||
modelId?: string;
|
||||
batchImageToken?: string;
|
||||
// Token for the generation's prompt record. The list view resolves it
|
||||
// (via the shared prompts cache) to show the real prompt + model.
|
||||
promptToken?: string;
|
||||
}
|
||||
|
||||
export interface InProgressJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
progress: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
createdAt: string;
|
||||
batchCount?: number;
|
||||
// Prompt token + media class enable the "Recreate" action while the job is
|
||||
// still running, mirroring the failed/completed cards.
|
||||
promptToken?: string;
|
||||
mediaClass: "image" | "video";
|
||||
}
|
||||
|
||||
export interface FailedJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
promptToken?: string;
|
||||
refImageUrl?: string;
|
||||
mediaClass: "image" | "video";
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
GalleryModalApi,
|
||||
FilterMediaClasses,
|
||||
FilterMediaType,
|
||||
} from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import type { GalleryItem } from "./types";
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
const getLabel = (item: any) => {
|
||||
if (item.maybe_title) return item.maybe_title;
|
||||
switch (item.media_class) {
|
||||
case "image":
|
||||
return "Image Generation";
|
||||
case "video":
|
||||
return "Video Generation";
|
||||
case "dimensional":
|
||||
return "3D Mesh";
|
||||
default:
|
||||
return "Generation";
|
||||
}
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useGalleryData(options: {
|
||||
username: string | null;
|
||||
filterMediaClasses: FilterMediaClasses[];
|
||||
excludeUploads?: boolean;
|
||||
}) {
|
||||
const { username, filterMediaClasses, excludeUploads } = options;
|
||||
|
||||
const [items, setItems] = useState<GalleryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
const api = useMemo(() => new GalleryModalApi(), []);
|
||||
|
||||
const mapApiItem = useCallback((item: any): GalleryItem => {
|
||||
const isDimensional = item.media_class === "dimensional";
|
||||
const thumbnail = isDimensional
|
||||
? null
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.token,
|
||||
label: getLabel(item),
|
||||
thumbnail,
|
||||
fullImage: item.media_links?.cdn_url || null,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: item.media_class || "image",
|
||||
modelId: item.maybe_model_type || undefined,
|
||||
batchImageToken: item.maybe_batch_token,
|
||||
promptToken: item.maybe_prompt_token || undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadItems = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!username) return;
|
||||
if (isLoadingRef.current) return;
|
||||
isLoadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.listUserMediaFiles({
|
||||
username,
|
||||
filter_media_classes: filterMediaClasses,
|
||||
include_user_uploads: !excludeUploads,
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) =>
|
||||
item.media_type !== FilterMediaType.SCENE_JSON &&
|
||||
!(excludeUploads && item.origin_category === "upload"),
|
||||
)
|
||||
.map(mapApiItem);
|
||||
|
||||
if (reset) {
|
||||
setItems(newItems);
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
}
|
||||
|
||||
const current = response.pagination?.current ?? 0;
|
||||
const total = response.pagination?.total_page_count ?? 1;
|
||||
setPageIndex(current + 1);
|
||||
setHasMore(current + 1 < total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
},
|
||||
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
|
||||
);
|
||||
|
||||
// Initial load + filter change. When logged out (no username), clear the
|
||||
// loading flag so the shell renders the empty state instead of a spinner.
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
isLoadingRef.current = false;
|
||||
if (!username) {
|
||||
setHasMore(false);
|
||||
setIsInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
setHasMore(true);
|
||||
setIsInitialLoading(true);
|
||||
loadItems(true);
|
||||
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasMore && !isLoadingRef.current) {
|
||||
loadItems();
|
||||
}
|
||||
}, [hasMore, loadItems]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
isLoadingRef.current = false;
|
||||
loadItems(true);
|
||||
}, [loadItems]);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh,
|
||||
removeItem,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { FailedJob, GalleryItem, InProgressJob } from "./types";
|
||||
|
||||
// Shared between the grid and list views: both merge the in-progress / failed /
|
||||
// completed streams into a single time-sorted feed and lazy-load more on scroll.
|
||||
|
||||
export type GalleryEntry =
|
||||
| { kind: "pending"; key: string; createdAt: number; job: InProgressJob }
|
||||
| { kind: "failed"; key: string; createdAt: number; job: FailedJob }
|
||||
| { kind: "gallery"; key: string; createdAt: number; item: GalleryItem };
|
||||
|
||||
export interface MergedEntriesInput {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
}
|
||||
|
||||
export function useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
}: MergedEntriesInput): GalleryEntry[] {
|
||||
return useMemo(() => {
|
||||
const entries: GalleryEntry[] = [];
|
||||
|
||||
for (const job of inProgressJobs) {
|
||||
entries.push({
|
||||
kind: "pending",
|
||||
key: job.id,
|
||||
createdAt: new Date(job.createdAt).getTime(),
|
||||
job,
|
||||
});
|
||||
}
|
||||
for (const job of failedJobs) {
|
||||
entries.push({
|
||||
kind: "failed",
|
||||
key: job.id,
|
||||
createdAt: new Date(job.createdAt).getTime(),
|
||||
job,
|
||||
});
|
||||
}
|
||||
for (const item of newlyCompletedItems) {
|
||||
entries.push({
|
||||
kind: "gallery",
|
||||
key: `new-${item.id}`,
|
||||
createdAt: new Date(item.createdAt).getTime(),
|
||||
item,
|
||||
});
|
||||
}
|
||||
for (const item of galleryItems) {
|
||||
if (newlyCompletedTokens.has(item.id)) continue;
|
||||
entries.push({
|
||||
kind: "gallery",
|
||||
key: item.id,
|
||||
createdAt: new Date(item.createdAt).getTime(),
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return entries;
|
||||
}, [
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
]);
|
||||
}
|
||||
|
||||
// Returns a ref to attach to a sentinel element near the end of the feed;
|
||||
// `onLoadMore` fires when it scrolls into view (with a 400px lookahead).
|
||||
export function useInfiniteScrollSentinel(
|
||||
hasMore: boolean,
|
||||
onLoadMore: () => void,
|
||||
) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || !hasMore) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, onLoadMore]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../common"
|
||||
},
|
||||
{
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../../model-list"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
|
||||
},
|
||||
"exclude": [
|
||||
"out-tsc",
|
||||
"dist",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../common/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../model-list/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
import { isExternal } from '../../shared-vite-config';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/components/generation-list',
|
||||
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
|
||||
build: {
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
lib: {
|
||||
entry: 'src/index.ts',
|
||||
name: '@storyteller/ui-generation-list',
|
||||
fileName: 'index',
|
||||
formats: ['es' as const]
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: isExternal
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -2,10 +2,10 @@ import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
faUser,
|
||||
faKey,
|
||||
faEnvelope,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faExclamationTriangle,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
@@ -14,27 +14,35 @@ interface ArtCraftSignUpProps {
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
passwordConfirmation: string
|
||||
passwordConfirmation: string,
|
||||
) => void;
|
||||
isSignUp: boolean;
|
||||
onToggleMode: () => void;
|
||||
formRef?: React.RefObject<HTMLFormElement | null>;
|
||||
errorMessage?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const FIELD_LABEL = "text-xs font-semibold text-white/70 ml-1";
|
||||
const FIELD_INPUT =
|
||||
"w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors";
|
||||
|
||||
export const ArtCraftSignUp = ({
|
||||
onSubmit,
|
||||
isSignUp,
|
||||
onToggleMode,
|
||||
formRef,
|
||||
errorMessage,
|
||||
isLoading = false,
|
||||
}: ArtCraftSignUpProps) => {
|
||||
const [localError, setLocalError] = useState<string | undefined>(undefined);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
setLocalError(
|
||||
errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1)
|
||||
errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1),
|
||||
);
|
||||
} else {
|
||||
setLocalError(undefined);
|
||||
@@ -69,111 +77,128 @@ export const ArtCraftSignUp = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 min-h-0">
|
||||
<h2 className="text-3xl font-bold mb-3 text-center">
|
||||
{isSignUp ? "Sign up for ArtCraft" : "Log in to ArtCraft"}
|
||||
</h2>
|
||||
<form
|
||||
className="flex flex-col gap-3 w-full max-w-md"
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
>
|
||||
{isSignUp ? (
|
||||
<>
|
||||
<form
|
||||
className="flex w-full flex-col gap-2"
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
>
|
||||
{localError && (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSignUp ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className={FIELD_LABEL}>Username</label>
|
||||
<Input
|
||||
label="Username"
|
||||
icon={faUser}
|
||||
name="username"
|
||||
placeholder="Username"
|
||||
required
|
||||
autoComplete="off"
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
inputClassName={FIELD_INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className={FIELD_LABEL}>Email</label>
|
||||
<Input
|
||||
label="Email"
|
||||
icon={faEnvelope}
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
required={isSignUp}
|
||||
style={isSignUp ? {} : { display: "none" }}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="off"
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
inputClassName={FIELD_INPUT}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
label="Email or Username"
|
||||
icon={faUser}
|
||||
name="usernameOrEmail"
|
||||
placeholder="Email or Username"
|
||||
required
|
||||
autoComplete="off"
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
icon={faKey}
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
autoComplete="off"
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
{isSignUp && (
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
icon={faKey}
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm Password"
|
||||
required
|
||||
autoComplete="off"
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
)}
|
||||
<button type="submit" className="hidden" />
|
||||
<div className="relative mt-3 flex flex-col items-center justify-center">
|
||||
<div className="flex gap-2 items-center justify-center p-2 px-3 bg-white/5 rounded-lg">
|
||||
<p className="text-sm opacity-80">
|
||||
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="p-0 bg-transparent hover:bg-transparent border-none shadow-none"
|
||||
type="button"
|
||||
onClick={onToggleMode}
|
||||
>
|
||||
<span className="text-sm text-primary-400 underline hover:text-primary-300 transition-colors duration-200">
|
||||
{isSignUp ? "Log in" : "Sign up"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{localError && (
|
||||
<div className="absolute w-fit -bottom-12 left-1/2 -translate-x-1/2 text-red bg-red/10 border border-red/20 rounded-lg py-1 px-2 justify-center text-sm text-center mb-2 font-semibold flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<label className={FIELD_LABEL}>Email or Username</label>
|
||||
<Input
|
||||
name="usernameOrEmail"
|
||||
placeholder="you@example.com or username"
|
||||
required
|
||||
autoComplete="off"
|
||||
inputClassName={FIELD_INPUT}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className={FIELD_LABEL}>Password</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
autoComplete="off"
|
||||
inputClassName={`${FIELD_INPUT} pr-12`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 transition-colors hover:text-white/60"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
<div className="space-y-1">
|
||||
<label className={FIELD_LABEL}>Confirm Password</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
name="confirmPassword"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Re-enter password"
|
||||
required
|
||||
autoComplete="off"
|
||||
inputClassName={`${FIELD_INPUT} pr-12`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 transition-colors hover:text-white/60"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showConfirm ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-10 w-full justify-center rounded-full border-none bg-primary font-bold text-white hover:bg-primary-600"
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : isSignUp ? (
|
||||
"Sign up"
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-center text-sm text-white/60">
|
||||
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMode}
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
{isSignUp ? "Log in" : "Sign up"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Transition, TransitionChild } from "@headlessui/react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
useSoraLoginListener,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { ArtCraftSignUp } from "./artcraft-signup";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import { useLoginModalStore } from "./useLoginModalStore";
|
||||
|
||||
const SIGNUP_SOURCE_ARTCRAFT = "artcraft";
|
||||
|
||||
// Webapp auth-showcase video (swap by passing `videoUrl`).
|
||||
const DEFAULT_SHOWCASE_VIDEO =
|
||||
"https://player.vimeo.com/video/1169289718?background=1&autoplay=1&loop=1&muted=1";
|
||||
|
||||
interface LoginModalProps {
|
||||
onClose?: () => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
onArtCraftAuthSuccess?: (userInfo: any) => void;
|
||||
isSignUp?: boolean;
|
||||
/** Optional override for the right-pane showcase video. */
|
||||
videoUrl?: string;
|
||||
// Accepted for backwards compatibility with existing call sites (MainApp
|
||||
// passes these); no longer used now that the showcase is a single video.
|
||||
videoSrc2D?: string;
|
||||
videoSrc3D?: string;
|
||||
}
|
||||
|
||||
export function LoginModal({
|
||||
@@ -24,21 +31,16 @@ export function LoginModal({
|
||||
onOpenChange,
|
||||
onArtCraftAuthSuccess,
|
||||
isSignUp: initialIsSignUp = true,
|
||||
videoUrl = DEFAULT_SHOWCASE_VIDEO,
|
||||
}: LoginModalProps) {
|
||||
const { isOpen, recheckTrigger, closeModal } = useLoginModalStore();
|
||||
const [step, setStep] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [_isLoggedInArtCraft, setIsLoggedInArtCraft] = useState(false);
|
||||
const [isSignUp, setIsSignUp] = useState(initialIsSignUp);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const artCraftFormRef = useRef<HTMLFormElement>(null);
|
||||
const [showDiscord, setShowDiscord] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Now we have 3 steps: Welcome, Sign up/Login, and Success
|
||||
const uiTotalSteps = 3;
|
||||
const uiCurrentStep = showSuccess ? 3 : showDiscord ? 2 : step;
|
||||
|
||||
const checkArtCraftLogin = async () => {
|
||||
const usersApi = new UsersApi();
|
||||
const session = await usersApi.GetSession();
|
||||
@@ -46,20 +48,14 @@ export function LoginModal({
|
||||
return loggedIn;
|
||||
};
|
||||
|
||||
useSoraLoginListener((payload: any) => {
|
||||
console.log("Login success!", payload);
|
||||
setStep(4); // Always go to the final step after Sora login
|
||||
});
|
||||
|
||||
// Check session on component mount and when recheckTrigger changes
|
||||
// Check session on mount and when recheckTrigger changes.
|
||||
useEffect(() => {
|
||||
checkArtCraftLogin().then((loggedIn) => {
|
||||
if (loggedIn) {
|
||||
setIsLoggedInArtCraft(true);
|
||||
closeModal();
|
||||
} else {
|
||||
// Reset all modal state to initial values
|
||||
setStep(1);
|
||||
// Reset modal state to initial values.
|
||||
setIsLoading(false);
|
||||
setIsSignUp(initialIsSignUp);
|
||||
setErrorMessage("");
|
||||
@@ -82,55 +78,81 @@ export function LoginModal({
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === 2) {
|
||||
// Trigger the form submit in ArtCraftSignUp
|
||||
if (artCraftFormRef.current) {
|
||||
if (artCraftFormRef.current.requestSubmit) {
|
||||
artCraftFormRef.current.requestSubmit();
|
||||
} else {
|
||||
artCraftFormRef.current.dispatchEvent(
|
||||
new Event('submit', { cancelable: true, bubbles: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStep(step + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) setStep(step - 1);
|
||||
};
|
||||
|
||||
// Progress bar rendering
|
||||
const renderProgress = () => (
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
{[...Array(uiTotalSteps)].map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`h-1.5 rounded transition-all duration-300 w-16 ${idx < uiCurrentStep ? "bg-primary" : "bg-white/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleDiscordJoin = () => {
|
||||
window.open("https://discord.gg/75svZP2Vje", "_blank");
|
||||
setShowDiscord(false);
|
||||
setShowSuccess(true);
|
||||
};
|
||||
|
||||
// Step content rendering
|
||||
const renderStepContent = () => {
|
||||
const handleAuthSubmit = async (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
passwordConfirmation: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
const usersApi = new UsersApi();
|
||||
try {
|
||||
let signupResponse, loginResponse;
|
||||
if (isSignUp) {
|
||||
signupResponse = await usersApi.Signup({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation,
|
||||
signupSource: SIGNUP_SOURCE_ARTCRAFT,
|
||||
});
|
||||
|
||||
if (!signupResponse.success) {
|
||||
setErrorMessage(
|
||||
signupResponse.errorMessage || "Signup failed, please try again."
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
loginResponse = await usersApi.Login({
|
||||
usernameOrEmail: username || email,
|
||||
password,
|
||||
});
|
||||
} else {
|
||||
loginResponse = await usersApi.Login({
|
||||
usernameOrEmail: username || email,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
if (!loginResponse.success) {
|
||||
setErrorMessage(
|
||||
loginResponse.errorMessage || "Login failed, please try again."
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoggedInArtCraft(true);
|
||||
if (onArtCraftAuthSuccess) {
|
||||
const session = await usersApi.GetSession();
|
||||
const userInfo = session.data?.user;
|
||||
if (userInfo) onArtCraftAuthSuccess(userInfo);
|
||||
}
|
||||
setShowDiscord(true); // Onboarding step after successful auth.
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setErrorMessage("An unexpected error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Post-auth onboarding screens (full-width inside the same card) ────────
|
||||
const renderOnboarding = () => {
|
||||
if (showSuccess) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 min-h-0">
|
||||
<h2 className="text-3xl font-bold mb-2 text-center">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center">
|
||||
<h2 className="mb-2 text-3xl font-bold text-white">
|
||||
Thank you for signing in!
|
||||
</h2>
|
||||
<p className="text-white/70 mb-6 text-center">
|
||||
<p className="mb-6 text-white/70">
|
||||
You're all set to start creating amazing content.
|
||||
</p>
|
||||
<Button
|
||||
@@ -146,183 +168,41 @@ export function LoginModal({
|
||||
);
|
||||
}
|
||||
|
||||
if (showDiscord) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 min-h-0">
|
||||
<h2 className="text-3xl font-bold mb-2 text-center">
|
||||
Join Our Community
|
||||
</h2>
|
||||
<p className="text-white/70 mb-6 text-center px-24">
|
||||
Connect with other creators, share your work, and get the latest
|
||||
updates in our Discord community.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowDiscord(false);
|
||||
setShowSuccess(true);
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleDiscordJoin}
|
||||
icon={faDiscord}
|
||||
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]"
|
||||
>
|
||||
Join Discord
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (step) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-end flex-1">
|
||||
<p className="text-xl text-white max-w-lg text-center leading-relaxed drop-shadow-[0_2px_10px_rgba(0,0,0,0.8)]">
|
||||
It's in your toolbox forever now.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ArtCraftSignUp
|
||||
onSubmit={async (
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
const usersApi = new UsersApi();
|
||||
try {
|
||||
let signupResponse, loginResponse;
|
||||
if (isSignUp) {
|
||||
console.log("Sign up!");
|
||||
signupResponse = await usersApi.Signup({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation,
|
||||
signupSource: SIGNUP_SOURCE_ARTCRAFT,
|
||||
});
|
||||
console.log(signupResponse);
|
||||
|
||||
if (!signupResponse.success) {
|
||||
setErrorMessage(
|
||||
signupResponse.errorMessage ||
|
||||
"Signup failed, please try again."
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
console.log("Path 1");
|
||||
loginResponse = await usersApi.Login({
|
||||
usernameOrEmail: username || email,
|
||||
password,
|
||||
});
|
||||
console.log(loginResponse);
|
||||
} else {
|
||||
console.log("Path 2");
|
||||
loginResponse = await usersApi.Login({
|
||||
usernameOrEmail: username || email,
|
||||
password,
|
||||
});
|
||||
console.log(loginResponse);
|
||||
}
|
||||
|
||||
if (!loginResponse.success) {
|
||||
setErrorMessage(
|
||||
loginResponse.errorMessage ||
|
||||
"Login failed, please try again."
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoggedInArtCraft(true);
|
||||
console.log("ARTCRAFT LOGIN SUCCeSS");
|
||||
if (onArtCraftAuthSuccess) {
|
||||
const session = await usersApi.GetSession();
|
||||
const userInfo = session.data?.user;
|
||||
console.log("USERINFO");
|
||||
console.log(userInfo);
|
||||
if (userInfo) onArtCraftAuthSuccess(userInfo);
|
||||
}
|
||||
setShowDiscord(true); // Show Discord step after successful login
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setErrorMessage(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
isSignUp={isSignUp}
|
||||
onToggleMode={() => setIsSignUp((prev) => !prev)}
|
||||
formRef={artCraftFormRef}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooterButtons = () => {
|
||||
if (showSuccess || showDiscord) {
|
||||
return null; // No footer buttons needed in success or discord states
|
||||
}
|
||||
|
||||
// Discord
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2.5 mt-6">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsSignUp(false);
|
||||
setStep(2);
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
<Button
|
||||
icon={faArrowRight}
|
||||
iconFlip={true}
|
||||
onClick={() => {
|
||||
setIsSignUp(true);
|
||||
setStep(2);
|
||||
}}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleBack} disabled={isLoading}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
icon={faArrowRight}
|
||||
iconFlip={true}
|
||||
onClick={handleNext}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isSignUp ? "Sign up" : "Login"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center">
|
||||
<h2 className="mb-2 text-3xl font-bold text-white">
|
||||
Join Our Community
|
||||
</h2>
|
||||
<p className="mb-6 max-w-md text-white/70">
|
||||
Connect with other creators, share your work, and get the latest
|
||||
updates in our Discord community.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowDiscord(false);
|
||||
setShowSuccess(true);
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleDiscordJoin}
|
||||
icon={faDiscord}
|
||||
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]"
|
||||
>
|
||||
Join Discord
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const inOnboarding = showDiscord || showSuccess;
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen}>
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
@@ -346,41 +226,56 @@ export function LoginModal({
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className="relative h-[660px] max-w-4xl w-full rounded-xl overflow-hidden bg-[#2C2C2C] text-white shadow-lg border border-[#222222]/10"
|
||||
className="relative flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] text-white shadow-2xl lg:min-h-[640px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-8 h-full relative overflow-hidden">
|
||||
{step === 1 && !showSuccess && !showDiscord && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
className="w-full h-full object-cover opacity-90"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
>
|
||||
<source
|
||||
src="/resources/videos/artcraft-intro.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#090909] via-transparent to-[#111111]/80" />
|
||||
</div>
|
||||
)}
|
||||
{inOnboarding ? (
|
||||
renderOnboarding()
|
||||
) : (
|
||||
<>
|
||||
{/* ── Form pane ── (no dismiss control — login is required) */}
|
||||
<div className="relative flex w-full flex-col lg:w-1/2">
|
||||
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10">
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<img
|
||||
src="/resources/logo/artcraft-icon.png"
|
||||
alt="ArtCraft"
|
||||
className="pointer-events-none mx-auto mb-6 h-12 w-auto select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
<h1 className="mb-2 text-2xl font-semibold">
|
||||
{isSignUp ? "Create your account" : "Welcome back"}
|
||||
</h1>
|
||||
<p className="text-sm text-white/60">
|
||||
{isSignUp
|
||||
? "Sign up to start creating with ArtCraft"
|
||||
: "Log in to your account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
{!showSuccess && (
|
||||
<>
|
||||
<span className="text-sm text-center opacity-60 font-medium">
|
||||
Step {uiCurrentStep} of {uiTotalSteps}
|
||||
</span>
|
||||
{renderProgress()}
|
||||
</>
|
||||
)}
|
||||
{renderStepContent()}
|
||||
{renderFooterButtons()}
|
||||
</div>
|
||||
</div>
|
||||
<ArtCraftSignUp
|
||||
onSubmit={handleAuthSubmit}
|
||||
isSignUp={isSignUp}
|
||||
onToggleMode={() => setIsSignUp((prev) => !prev)}
|
||||
errorMessage={errorMessage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pb-8 text-center text-xs text-white/20">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights
|
||||
reserved.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Showcase pane (desktop only) ── */}
|
||||
<div className="relative hidden lg:block lg:w-1/2">
|
||||
<LoginShowcase videoUrl={videoUrl} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
@@ -389,4 +284,44 @@ export function LoginModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Right-pane video showcase — Vimeo background embed scaled to cover the pane,
|
||||
// with a legibility gradient + caption. Mirrors the webapp auth-showcase.
|
||||
function LoginShowcase({ videoUrl }: { videoUrl: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-2 overflow-hidden rounded-2xl bg-black"
|
||||
style={{ containerType: "size" }}
|
||||
>
|
||||
<iframe
|
||||
src={videoUrl}
|
||||
title="ArtCraft"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
width: "max(100cqw, calc(100cqh * 16 / 9))",
|
||||
height: "max(100cqh, calc(100cqw * 9 / 16))",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/10 to-black/25"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-300">
|
||||
One of the cheapest
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold leading-tight">
|
||||
Seedance 2.0 Video Generation
|
||||
</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-white/70">
|
||||
Generate jaw-dropping AI videos with Seedance 2.0.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginModal;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Model } from "@storyteller/model-list";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleCheck, faChevronUp } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { defaultModelForPage } from "./defaultModelForPage";
|
||||
|
||||
interface ClassyModelSelectorProps {
|
||||
@@ -25,6 +26,14 @@ interface ClassyModelSelectorProps {
|
||||
providersByModel?: Partial<Record<string, Provider[]>>;
|
||||
providerTooltipDelayMs?: number;
|
||||
maxListHeight?: number | string;
|
||||
/**
|
||||
* "floating": the original standalone selector (large two-line trigger with
|
||||
* model name + provider, hover-to-open list). "embedded": a compact pill
|
||||
* (creator icon + model name) for use inside a promptbox option row; opens
|
||||
* a webapp-style rich list on click. Both share the same store wiring and
|
||||
* provider-selection rows.
|
||||
*/
|
||||
variant?: "floating" | "embedded";
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_OPTIONS: GenerationProvider[] = [GenerationProvider.Artcraft];
|
||||
@@ -103,6 +112,7 @@ export function ClassyModelSelector({
|
||||
providersByModel,
|
||||
providerTooltipDelayMs = 300,
|
||||
maxListHeight = "60vh",
|
||||
variant = "floating",
|
||||
...popoverProps
|
||||
}: ClassyModelSelectorProps) {
|
||||
const { selectedModels, setSelectedModel, setSelectedProvider } =
|
||||
@@ -206,6 +216,31 @@ export function ClassyModelSelector({
|
||||
],
|
||||
);
|
||||
|
||||
if (variant === "embedded") {
|
||||
const selectedIcon = modelList.find((i) => i.selected)?.icon;
|
||||
return (
|
||||
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
|
||||
<PopoverMenu
|
||||
items={modelList}
|
||||
onSelect={handleModelSelect}
|
||||
mode="toggle"
|
||||
richList
|
||||
panelTitle="Select Model"
|
||||
panelClassName="w-[360px]"
|
||||
maxListHeight={maxListHeight}
|
||||
buttonClassName="max-w-48"
|
||||
triggerIcon={
|
||||
selectedIcon ? (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{selectedIcon}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base-fg/90 text-base font-semibold">Model</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PopoverItem } from "@storyteller/ui-popover";
|
||||
import { faClock, faCube, faFilm, faImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faCube, faFilm, faImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
getCreatorIcon,
|
||||
@@ -24,10 +24,6 @@ const buildItems = (
|
||||
label: model.selectorName,
|
||||
icon: withIcon(getCreatorIcon(model.creator), fallbackIcon),
|
||||
description: model.selectorDescription,
|
||||
badges: model.toLegacyBadges()?.map((b) => ({
|
||||
label: b.label,
|
||||
icon: <FontAwesomeIcon icon={faClock} />,
|
||||
})),
|
||||
modelConfig: model.toLegacyModelConfig(), // Access to full object.
|
||||
model: model,
|
||||
}));
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
{
|
||||
"path": "../popover"
|
||||
},
|
||||
{
|
||||
"path": "../tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
{
|
||||
"path": "../popover/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -1189,6 +1189,13 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
selectedImageModel={selectedImageModel}
|
||||
selectedProvider={selectedProvider}
|
||||
uploadImage={adapter.uploadImage}
|
||||
modelSelector={
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={CANVAS_2D_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SideToolbar
|
||||
@@ -1247,18 +1254,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
/>
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={CANVAS_2D_PAGE_MODEL_LIST}
|
||||
page={PAGE_ID}
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-base-fg opacity-80 hover:opacity-100"
|
||||
showIconsInList
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,8 @@ const PromptEditor: React.FC<PromptBox2DProps> = ({
|
||||
onGenerationCountChange,
|
||||
onAspectRatioChange,
|
||||
onFitPressed,
|
||||
usePrompt2DStore
|
||||
usePrompt2DStore,
|
||||
modelSelector,
|
||||
}) => {
|
||||
const [images, setImages] = useState<ImageStyle[]>([]);
|
||||
|
||||
@@ -53,6 +54,7 @@ const PromptEditor: React.FC<PromptBox2DProps> = ({
|
||||
onGenerationCountChange={onGenerationCountChange}
|
||||
onAspectRatioChange={onAspectRatioChange}
|
||||
onFitPressed={onFitPressed}
|
||||
modelSelector={modelSelector}
|
||||
/>
|
||||
</JobProvider>
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ export const Stage3DBody = ({
|
||||
</div>
|
||||
)}
|
||||
{(showCostCalculator || showHelpMenu) && (
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
{showCostCalculator && (
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
)}
|
||||
|
||||
@@ -741,8 +741,17 @@ export const PopoverMenu = ({
|
||||
className="flex flex-col gap-0.5 overflow-y-auto text-sm text-base-fg [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
style={{ maxHeight: maxListHeight ?? "60vh" }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{items.map((item, index) => {
|
||||
const tooltipContent =
|
||||
typeof item.hoverTooltip === "function"
|
||||
? (
|
||||
item.hoverTooltip as (
|
||||
close: () => void,
|
||||
) => ReactNode
|
||||
)(close)
|
||||
: item.hoverTooltip;
|
||||
|
||||
const itemRow = (
|
||||
<div
|
||||
data-selected={
|
||||
item.selected ? "true" : undefined
|
||||
@@ -756,6 +765,9 @@ export const PopoverMenu = ({
|
||||
item.selected
|
||||
? "bg-ui-controls/70"
|
||||
: "hover:bg-ui-controls/50",
|
||||
!item.selected && openTooltipIdx === index
|
||||
? "bg-ui-controls/50"
|
||||
: "",
|
||||
item.disabled
|
||||
? "!cursor-not-allowed opacity-50"
|
||||
: "",
|
||||
@@ -825,11 +837,36 @@ export const PopoverMenu = ({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{item.divider && (
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
{item.hoverTooltip ? (
|
||||
<PortalTooltip
|
||||
content={tooltipContent}
|
||||
delay={item.tooltipDelayMs ?? 300}
|
||||
className="min-w-48"
|
||||
onOpenChange={(open) =>
|
||||
setOpenTooltipIdx((prev) =>
|
||||
open
|
||||
? index
|
||||
: prev === index
|
||||
? null
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
{itemRow}
|
||||
</PortalTooltip>
|
||||
) : (
|
||||
itemRow
|
||||
)}
|
||||
{item.divider && (
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Bottom fade — appears only when there's more below,
|
||||
with a slow bouncing arrow hinting to scroll down. */}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faCoins,
|
||||
faSpinnerThird,
|
||||
faArrowRight,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -17,10 +22,15 @@ interface CreditPack {
|
||||
const creditPacks: CreditPack[] = [
|
||||
{ id: "artcraft_1000", total: 1000, base: 1000, bonus: 0, priceUsd: 10 },
|
||||
{ id: "artcraft_2500", total: 2500, base: 2500, bonus: 0, priceUsd: 25 },
|
||||
{ id: "artcraft_5000", total: 5000, base: 5000, bonus: 0, priceUsd: 50 },
|
||||
{
|
||||
id: "artcraft_5000",
|
||||
total: 5000,
|
||||
base: 5000,
|
||||
bonus: 0,
|
||||
priceUsd: 50,
|
||||
badge: "Popular",
|
||||
},
|
||||
{ id: "artcraft_10000", total: 10000, base: 10000, bonus: 0, priceUsd: 100 },
|
||||
//{ id: "artcraft_25000", total: 25000, base: 25000, bonus: 0, priceUsd: 250 },
|
||||
//{ id: "artcraft_50000", total: 50000, base: 50000, bonus: 0, priceUsd: 500 },
|
||||
];
|
||||
|
||||
interface CreditsModalProps {
|
||||
@@ -34,91 +44,135 @@ export function CreditsModal({
|
||||
onClose,
|
||||
onPurchase,
|
||||
}: CreditsModalProps) {
|
||||
const [purchasingId, setPurchasingId] = useState<string | null>(null);
|
||||
|
||||
const handlePurchase = async (pack: CreditPack) => {
|
||||
if (onPurchase) {
|
||||
onPurchase(pack);
|
||||
return;
|
||||
}
|
||||
|
||||
await invoke("storyteller_open_credits_purchase_command", {
|
||||
request: {
|
||||
credits_pack: pack.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Hook up Stripe/checkout here
|
||||
// Example: redirect to checkout with pack.priceId
|
||||
// await stripe.redirectToCheckout({ lineItems: [{ price: pack.priceId, quantity: 1 }], mode: 'payment', ... })
|
||||
// For now, just log
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Purchasing credits pack", pack);
|
||||
setPurchasingId(pack.id);
|
||||
try {
|
||||
await invoke("storyteller_open_credits_purchase_command", {
|
||||
request: {
|
||||
credits_pack: pack.id,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setPurchasingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cardBase =
|
||||
"relative rounded-xl border p-6 h-full flex flex-col justify-between bg-ui-controls/50 border-ui-controls-border hover:border-base-fg/20 hover:bg-ui-controls/80 transition-all overflow-hidden";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose ?? (() => {})}
|
||||
className="rounded-xl bg-ui-panel border border-ui-panel-border max-h-[90vh] max-w-5xl overflow-y-auto flex flex-col shadow-2xl"
|
||||
className="rounded-2xl w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
|
||||
allowBackgroundInteraction={false}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
resizable={false}
|
||||
childPadding={false}
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<div className="p-8 flex-1 overflow-y-auto min-h-0">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-base-fg mb-4">
|
||||
Buy credits
|
||||
</h1>
|
||||
<p className="text-base-fg/60 text-lg">
|
||||
Choose a one-time credits package. No subscription required.
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Off-center ambient glow, feels designed, not generic */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
|
||||
/>
|
||||
|
||||
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-11 sm:pb-9">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||
Buy <span className="text-primary">credits</span>
|
||||
</h2>
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-white/55">
|
||||
One-time credit packs. No subscription required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{creditPacks.map((pack) => (
|
||||
<div key={pack.id} className={cardBase}>
|
||||
{pack.badge && (
|
||||
<div className="absolute -top-3 right-4 bg-primary text-primary-fg px-3 py-1 rounded-full text-xs font-bold shadow-xl">
|
||||
{pack.badge}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{creditPacks.map((pack) => {
|
||||
const isLoading = purchasingId === pack.id;
|
||||
const isPopular = !!pack.badge;
|
||||
return (
|
||||
<button
|
||||
key={pack.id}
|
||||
type="button"
|
||||
onClick={() => handlePurchase(pack)}
|
||||
disabled={purchasingId !== null}
|
||||
className={twMerge(
|
||||
"group relative flex flex-col gap-6 rounded-2xl border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
|
||||
isPopular
|
||||
? "border-primary/50 bg-primary/[0.07] hover:border-primary"
|
||||
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{pack.badge && (
|
||||
<span className="absolute -top-2.5 right-3 rounded-full bg-primary px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-lg">
|
||||
{pack.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-base-fg text-5xl font-bold tracking-tight flex items-center gap-2.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-3xl"
|
||||
/>
|
||||
{pack.total.toLocaleString()}
|
||||
</div>
|
||||
{pack.bonus > 0 && (
|
||||
<div className="text-base-fg/60 text-sm mt-2">
|
||||
Total: {pack.base.toLocaleString()} {" + "}
|
||||
<span className="text-primary font-bold">
|
||||
{pack.bonus.toLocaleString()} Bonus
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
<FontAwesomeIcon
|
||||
icon={faCoins}
|
||||
className="text-primary text-lg"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-4xl font-bold leading-none tracking-tight text-white">
|
||||
{pack.total.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0 text-sm text-white/40">
|
||||
{pack.bonus > 0 ? (
|
||||
<>
|
||||
{pack.base.toLocaleString()} +{" "}
|
||||
<span className="text-primary font-semibold">
|
||||
{pack.bonus.toLocaleString()} bonus
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"credits"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-semibold text-white/80">
|
||||
${pack.priceUsd}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-base font-semibold text-primary-400">
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
Buy
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-xs transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="text-base-fg text-2xl font-bold opacity-60">
|
||||
${pack.priceUsd}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handlePurchase(pack)}
|
||||
className="px-6 h-10 rounded-xl font-bold shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
Purchase
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="mt-6 text-center text-xs text-white/35">
|
||||
Secure checkout via Stripe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faStar,
|
||||
faGem,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { usePricingModalStore } from "./pricing-modal-store";
|
||||
import { TabSelector } from "@storyteller/ui-tab-selector";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -25,6 +31,13 @@ const pricingConfig = {
|
||||
yearlyDiscount: 20,
|
||||
};
|
||||
|
||||
// Plans that get an eye-catching gradient frame breaking out of the grid.
|
||||
type HighlightKind = "popular" | "best";
|
||||
const HIGHLIGHTS: Record<string, HighlightKind> = {
|
||||
artcraft_pro: "popular",
|
||||
artcraft_max: "best",
|
||||
};
|
||||
|
||||
interface PricingContentProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -38,6 +51,7 @@ export function PricingContent({ title, subtitle }: PricingContentProps) {
|
||||
const [billingType, setBillingType] = useState("yearly");
|
||||
const isYearly = billingType === "yearly";
|
||||
|
||||
// ── Billing actions (Tauri portal/checkout — unchanged) ──────────────────
|
||||
const handleUnsubscribe = async () => {
|
||||
await invoke("storyteller_open_customer_portal_cancel_plan_command");
|
||||
};
|
||||
@@ -116,59 +130,11 @@ export function PricingContent({ title, subtitle }: PricingContentProps) {
|
||||
return "Upgrade Plan";
|
||||
};
|
||||
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: SubscriptionPlanDetails["colorScheme"],
|
||||
isCurrent: boolean
|
||||
) => {
|
||||
const baseClasses =
|
||||
"relative rounded-xl border p-8 h-full flex flex-col transition-all duration-300 ring-[6px] ring-transparent";
|
||||
|
||||
switch (colorScheme) {
|
||||
case "dark":
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-[#2C2C2C] border-gray-600",
|
||||
isCurrent && "ring-white/70"
|
||||
);
|
||||
case "green":
|
||||
// Basic
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#002D23] via-[#006B54] to-[#00D28B] border-[#00a873]",
|
||||
"hover:shadow-[0_0_30px_rgba(0,210,139,0.4)]",
|
||||
isCurrent && "ring-[#00D28B] shadow-[0_0_30px_rgba(0,210,139,0.4)]"
|
||||
);
|
||||
case "purple":
|
||||
// Pro
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#2D004D] via-[#6400A8] to-[#C03FFF] border-[#9D4CFF]",
|
||||
"hover:shadow-[0_0_30px_rgba(192,63,255,0.4)]",
|
||||
isCurrent && "ring-[#C03FFF] shadow-[0_0_30px_rgba(192,63,255,0.4)]"
|
||||
);
|
||||
case "orange":
|
||||
// Max
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-gradient-to-b from-[#332100] via-[#B35C00] to-[#FFB347] border-[#FF8C00]",
|
||||
"hover:shadow-[0_0_30px_rgba(255,179,71,0.4)]",
|
||||
isCurrent && "ring-[#FFB347] shadow-[0_0_30px_rgba(255,179,71,0.4)]"
|
||||
);
|
||||
default:
|
||||
return twMerge(
|
||||
baseClasses,
|
||||
"bg-[#2C2C2C] border-gray-600",
|
||||
"hover:shadow-[0_0_30px_rgba(255,255,255,0.3)]",
|
||||
isCurrent && "ring-white/70"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (plan: SubscriptionPlanDetails) => {
|
||||
if (plan.monthlyPrice === 0) {
|
||||
return {
|
||||
current: "$0",
|
||||
original: null,
|
||||
original: null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,143 +153,165 @@ export function PricingContent({ title, subtitle }: PricingContentProps) {
|
||||
|
||||
return {
|
||||
current: `$${monthlyPrice}`,
|
||||
original: null,
|
||||
original: null as string | null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 md:p-10 flex-1 overflow-y-auto min-h-0 text-base-fg">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-base-fg mb-4">
|
||||
<div className="flex-1 overflow-y-auto p-8 md:p-10 min-h-0 text-white">
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="mb-4 text-3xl font-semibold tracking-tight text-white md:text-5xl">
|
||||
{title || pricingConfig.header.title}
|
||||
</h1>
|
||||
<p className="text-base-fg/60 text-md md:text-lg mb-6 max-w-3xl mx-auto">
|
||||
<p className="mx-auto mb-6 max-w-3xl text-md leading-relaxed text-white/60 md:text-lg">
|
||||
{subtitle || pricingConfig.header.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8 relative w-fit mx-auto">
|
||||
<div className="relative mx-auto mb-8 flex w-fit items-center justify-center gap-4">
|
||||
<TabSelector
|
||||
tabs={billingTabs}
|
||||
activeTab={billingType}
|
||||
onTabChange={setBillingType}
|
||||
className="w-fit border border-base-fg/20 rounded-lg"
|
||||
className="w-fit rounded-lg border border-white/20 bg-white/5"
|
||||
tabClassName="w-24 text-md"
|
||||
indicatorClassName="bg-primary/30 border border-primary"
|
||||
selectedTabClassName="text-base-fg"
|
||||
selectedTabClassName="text-white"
|
||||
/>
|
||||
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -left-6 absolute pointer-events-none">
|
||||
<span className="pointer-events-none absolute -left-6 -top-3 rounded-full bg-primary px-3 py-0.5 text-sm font-medium text-white">
|
||||
-{pricingConfig.yearlyDiscount}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Tiers */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-10">
|
||||
<div className="mx-auto grid max-w-6xl grid-cols-1 items-stretch gap-x-4 gap-y-10 md:grid-cols-2 md:gap-6 lg:grid-cols-4">
|
||||
{SUBSCRIPTION_PLANS.map((plan) => {
|
||||
const highlight = HIGHLIGHTS[plan.slug] ?? null;
|
||||
const isCurrent = isCurrentPlan(plan.slug);
|
||||
const pricing = formatPrice(plan);
|
||||
const frame = highlight ? frameClasses(plan.colorScheme) : null;
|
||||
|
||||
return (
|
||||
// Inner card body, shared between framed and un-framed plans.
|
||||
const cardBody = (
|
||||
<div
|
||||
key={plan.slug}
|
||||
className={getColorSchemeClasses(
|
||||
plan.colorScheme,
|
||||
isCurrentPlan(plan.slug)
|
||||
className={twMerge(
|
||||
getColorSchemeClasses(plan.colorScheme),
|
||||
frame
|
||||
? `relative z-10 h-full w-full border-2 bg-[#101014] shadow-2xl ${frame.border}`
|
||||
: "",
|
||||
isCurrent ? "ring-2 ring-white/50" : "",
|
||||
)}
|
||||
>
|
||||
{/* Current Plan Badge */}
|
||||
{isCurrentPlan(plan.slug) && (
|
||||
<div className="absolute -top-4 right-5 bg-white text-black px-3 py-1 rounded-full text-md font-semibold shadow-xl">
|
||||
Active
|
||||
{isCurrent && (
|
||||
<div className="absolute right-3 top-3 whitespace-nowrap rounded-full bg-white px-3 py-0.5 text-xs font-bold text-black shadow-lg">
|
||||
CURRENT
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tier Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl md:text-4xl font-bold text-white mb-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold text-white md:text-2xl">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline justify-center gap-2">
|
||||
{pricing.original && (
|
||||
<span className="text-white/60 line-through text-xl md:text-2xl">
|
||||
{pricing.original}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-2xl md:text-4xl font-bold text-white">
|
||||
{pricing.current}
|
||||
{pricing.original && (
|
||||
<span className="inline-flex items-center rounded-md border border-primary/30 bg-primary/80 px-1.5 py-0.5 text-xs font-bold uppercase tracking-wide text-white">
|
||||
-{pricingConfig.yearlyDiscount}%
|
||||
</span>
|
||||
<span className="text-white/60 text-sm">/month</span>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 text-xs mt-1">
|
||||
{isYearly ? "billed yearly" : "billed monthly"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex-1 space-y-3 mb-6">
|
||||
{plan.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
|
||||
feature.included
|
||||
? "bg-white text-black"
|
||||
: "bg-transparent border border-gray-400"
|
||||
)}
|
||||
>
|
||||
{feature.included && (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm mt-0.5",
|
||||
feature.included ? "text-white" : "text-white/60"
|
||||
)}
|
||||
>
|
||||
{feature.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
{pricing.original && (
|
||||
<span className="text-lg text-[#f05951]/80 line-through decoration-[#f05951]/80 md:text-xl">
|
||||
{pricing.original}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xl font-bold md:text-4xl">
|
||||
{pricing.current}
|
||||
</span>
|
||||
<span className="text-white/60">/month</span>
|
||||
</div>
|
||||
<div className="mb-4 min-h-[1rem] text-xs font-semibold uppercase tracking-wider text-white/40 md:mb-6">
|
||||
{plan.monthlyPrice === 0
|
||||
? "Free forever"
|
||||
: isYearly
|
||||
? "Billed yearly"
|
||||
: "Billed monthly"}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
className={twMerge(
|
||||
"mb-6 h-11 w-full justify-center rounded-xl border-transparent md:mb-8",
|
||||
isCurrent
|
||||
? "cursor-default bg-white/20"
|
||||
: frame
|
||||
? `${frame.button} text-white`
|
||||
: "bg-white text-black hover:bg-white/80",
|
||||
)}
|
||||
onClick={() => handleSetPlan(plan.slug)}
|
||||
disabled={isCurrentPlan(plan.slug)}
|
||||
className="w-full h-12 rounded-xl bg-white text-black border hover:bg-white/90"
|
||||
disabled={isCurrent}
|
||||
>
|
||||
{getButtonText(plan)}
|
||||
</Button>
|
||||
|
||||
<ul className="flex-1 space-y-3 md:space-y-4">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<Feature
|
||||
key={idx}
|
||||
text={feature.text}
|
||||
included={feature.included}
|
||||
highlighted={!!highlight}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Un-highlighted plans render the card directly into the grid.
|
||||
if (!highlight) {
|
||||
return (
|
||||
<div key={plan.slug} className="contents">
|
||||
{cardBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Highlighted plans keep the same footprint; the solid label tab sits
|
||||
// ABOVE the card and tucks behind the card top so it reads as one
|
||||
// continuous frame.
|
||||
return (
|
||||
<div key={plan.slug} className="relative">
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute inset-x-0 bottom-[calc(100%_-_1.75rem)] z-0 flex items-center justify-center gap-1.5 rounded-t-2xl pb-8 pt-2 text-xs font-bold uppercase tracking-[0.1em] text-white sm:rounded-t-[28px]",
|
||||
frame?.tab,
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={highlight === "popular" ? faStar : faGem}
|
||||
className="pb-0.5 text-xs"
|
||||
/>
|
||||
{highlight === "popular" ? "Most Popular" : "Best Value"}
|
||||
</div>
|
||||
{cardBody}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Manage Subscription Button - only show if user has a paid plan */}
|
||||
{/* Manage Subscription — only when the user has a paid plan */}
|
||||
{hasActiveSub && activePlanId !== "free" && (
|
||||
<div className="flex flex-wrap justify-center mt-8 gap-4">
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<Button
|
||||
onClick={handleUpdatePaymentMethod}
|
||||
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 rounded-xl"
|
||||
className="rounded-xl border border-white/25 bg-transparent px-8 py-3 text-white hover:bg-white/10"
|
||||
>
|
||||
Update your payment method
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManageSubscription}
|
||||
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 rounded-xl"
|
||||
className="rounded-xl border border-white/25 bg-transparent px-8 py-3 text-white hover:bg-white/10"
|
||||
>
|
||||
Manage your subscription
|
||||
</Button>
|
||||
@@ -333,21 +321,126 @@ export function PricingContent({ title, subtitle }: PricingContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface PricingModalProps { }
|
||||
// Per-plan colored card scheme (mirrors the webapp pricing-table's
|
||||
// non-unified theme: green/purple/orange gradient tints + matching borders).
|
||||
// All hexes are arbitrary values, so they don't depend on the Tailwind config.
|
||||
const getColorSchemeClasses = (
|
||||
colorScheme: SubscriptionPlanDetails["colorScheme"],
|
||||
) => {
|
||||
const base =
|
||||
"relative flex flex-col rounded-3xl border p-6 transition-all duration-300 backdrop-blur-md md:p-8";
|
||||
|
||||
export function PricingModal({ }: PricingModalProps = {}) {
|
||||
switch (colorScheme) {
|
||||
case "green":
|
||||
return twMerge(
|
||||
base,
|
||||
"bg-gradient-to-b from-[#002D23]/80 via-[#006B54]/50 to-[#00D28B]/10 border-[#00a873]/50",
|
||||
"hover:border-[#00a873] hover:shadow-[0_0_30px_rgba(0,210,139,0.2)]",
|
||||
);
|
||||
case "purple":
|
||||
return twMerge(
|
||||
base,
|
||||
"bg-gradient-to-b from-[#2D004D]/80 via-[#6400A8]/50 to-[#C03FFF]/10 border-[#9D4CFF]/50",
|
||||
"hover:border-[#9D4CFF] hover:shadow-[0_0_30px_rgba(192,63,255,0.2)]",
|
||||
);
|
||||
case "orange":
|
||||
return twMerge(
|
||||
base,
|
||||
"bg-gradient-to-b from-[#332100]/80 via-[#B35C00]/50 to-[#FFB347]/10 border-[#FF8C00]/50",
|
||||
"hover:border-[#FF8C00] hover:shadow-[0_0_30px_rgba(255,179,71,0.2)]",
|
||||
);
|
||||
case "dark":
|
||||
return twMerge(
|
||||
base,
|
||||
"bg-[#1C1C20] border-white/10 hover:border-white/20",
|
||||
);
|
||||
default:
|
||||
return twMerge(base, "bg-white/5 border-white/10 hover:border-white/20");
|
||||
}
|
||||
};
|
||||
|
||||
// Solid frame color for highlighted plans, keyed to each plan's color scheme.
|
||||
const frameClasses = (colorScheme: SubscriptionPlanDetails["colorScheme"]) => {
|
||||
switch (colorScheme) {
|
||||
case "green":
|
||||
return {
|
||||
tab: "bg-[#00a873]",
|
||||
border: "border-[#00a873]",
|
||||
button: "bg-[#00a873] hover:bg-[#008a5e]",
|
||||
};
|
||||
case "purple":
|
||||
return {
|
||||
tab: "bg-[#9D4CFF]",
|
||||
border: "border-[#9D4CFF]",
|
||||
button: "bg-[#9D4CFF] hover:bg-[#8633f2]",
|
||||
};
|
||||
case "orange":
|
||||
return {
|
||||
tab: "bg-[#D97700]",
|
||||
border: "border-[#D97700]",
|
||||
button: "bg-[#D97700] hover:bg-[#b86400]",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
tab: "bg-primary",
|
||||
border: "border-primary",
|
||||
button: "bg-primary hover:bg-primary-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Feature = ({
|
||||
text,
|
||||
included,
|
||||
highlighted = false,
|
||||
}: {
|
||||
text: string;
|
||||
included: boolean;
|
||||
highlighted?: boolean;
|
||||
}) => (
|
||||
<li className="flex items-start gap-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
|
||||
included
|
||||
? highlighted
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-white/10 text-white/70"
|
||||
: "border border-white/20 text-transparent",
|
||||
)}
|
||||
>
|
||||
{included && <FontAwesomeIcon icon={faCheck} className="text-xs" />}
|
||||
</div>
|
||||
<span
|
||||
className={twMerge(
|
||||
"mt-[3px] text-sm",
|
||||
included
|
||||
? highlighted
|
||||
? "text-white"
|
||||
: "text-white/80"
|
||||
: "text-white/40",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
interface PricingModalProps {}
|
||||
|
||||
export function PricingModal({}: PricingModalProps = {}) {
|
||||
const { isOpen, closeModal, title, subtitle } = usePricingModalStore();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className="rounded-xl bg-ui-panel max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col border border-ui-panel-border"
|
||||
className="flex max-h-[90vh] max-w-screen-2xl flex-col overflow-y-auto rounded-2xl border border-white/5 bg-[#101014]"
|
||||
allowBackgroundInteraction={false}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
resizable={false}
|
||||
backdropClassName=""
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<PricingContent title={title} subtitle={subtitle} />
|
||||
</Modal>
|
||||
|
||||
@@ -371,9 +371,7 @@ export const ImagePromptRow = ({
|
||||
|
||||
const getVideoDuration = (file: File): Promise<number> => {
|
||||
const src = URL.createObjectURL(file);
|
||||
return getVideoDurationFromSrc(src).finally(() =>
|
||||
URL.revokeObjectURL(src),
|
||||
);
|
||||
return getVideoDurationFromSrc(src).finally(() => URL.revokeObjectURL(src));
|
||||
};
|
||||
|
||||
const totalVideoDuration = useMemo(
|
||||
@@ -720,10 +718,7 @@ export const ImagePromptRow = ({
|
||||
);
|
||||
|
||||
const newVideos: RefVideo[] = [];
|
||||
let currentTotal = baseVideos.reduce(
|
||||
(sum, v) => sum + v.duration,
|
||||
0,
|
||||
);
|
||||
let currentTotal = baseVideos.reduce((sum, v) => sum + v.duration, 0);
|
||||
let exceeded = false;
|
||||
for (let i = 0; i < itemsToProcess.length; i++) {
|
||||
const item = itemsToProcess[i]!;
|
||||
@@ -832,7 +827,7 @@ export const ImagePromptRow = ({
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute left-0 glass w-[860px] rounded-t-xl flex",
|
||||
"absolute left-0 glass w-full rounded-t-2xl flex",
|
||||
showVideoReferenceSection ? "-top-[144px]" : "-top-[72px]",
|
||||
className,
|
||||
)}
|
||||
@@ -961,56 +956,56 @@ export const ImagePromptRow = ({
|
||||
})}
|
||||
{referenceImages.length + uploadingImages.length <
|
||||
maxImagePromptCount && (
|
||||
<Tooltip
|
||||
interactive={true}
|
||||
position="top"
|
||||
delay={100}
|
||||
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
|
||||
closeOnClick={true}
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{allowUpload && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUploadClickStart}
|
||||
icon={faPlus}
|
||||
className="w-full"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip
|
||||
interactive={true}
|
||||
position="top"
|
||||
delay={100}
|
||||
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
|
||||
closeOnClick={true}
|
||||
content={
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{allowUpload && (
|
||||
<Button
|
||||
variant="action"
|
||||
onClick={() => {
|
||||
setGalleryTarget("start");
|
||||
setIsGalleryModalOpen(true);
|
||||
}}
|
||||
icon={faImages}
|
||||
className="w-full bg-base-fg/10 hover:bg-base-fg/20"
|
||||
variant="primary"
|
||||
onClick={handleUploadClickStart}
|
||||
icon={faPlus}
|
||||
className="w-full"
|
||||
>
|
||||
Pick from library
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="action"
|
||||
className="bg-ui-controls/40 hover:bg-ui-controls/60 aspect-square w-full overflow-hidden rounded-lg w-14 border-dashed border-2 border-black/5 dark:border-white/25 transition-all"
|
||||
onClick={() => {
|
||||
if (allowUpload) handleUploadClickStart();
|
||||
else {
|
||||
)}
|
||||
<Button
|
||||
variant="action"
|
||||
onClick={() => {
|
||||
setGalleryTarget("start");
|
||||
setIsGalleryModalOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
className="text-2xl opacity-80 text-base-fg"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
}}
|
||||
icon={faImages}
|
||||
className="w-full bg-base-fg/10 hover:bg-base-fg/20"
|
||||
>
|
||||
Pick from library
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="action"
|
||||
className="bg-ui-controls/40 hover:bg-ui-controls/60 aspect-square w-full overflow-hidden rounded-lg w-14 border-dashed border-2 border-black/5 dark:border-white/25 transition-all"
|
||||
onClick={() => {
|
||||
if (allowUpload) handleUploadClickStart();
|
||||
else {
|
||||
setGalleryTarget("start");
|
||||
setIsGalleryModalOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlus}
|
||||
className="text-2xl opacity-80 text-base-fg"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isVideo && showEndFrameSection && (
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
import { Prompt2DStore, RefImage, useEnterToGenerateStore } from "./promptStore";
|
||||
import {
|
||||
Prompt2DStore,
|
||||
RefImage,
|
||||
useEnterToGenerateStore,
|
||||
} from "./promptStore";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { ImagePromptRow, UploadImageFn } from "./ImagePromptRow";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -50,6 +54,9 @@ export interface PromptBox2DProps {
|
||||
onAspectRatioChange?: (ratio: AspectRatio) => void;
|
||||
onFitPressed?: () => void | Promise<void>;
|
||||
usePrompt2DStore: UseBoundStore<StoreApi<Prompt2DStore>>;
|
||||
/** Optional model-picker slot rendered at the start of the toolbar
|
||||
* (left of the aspect-ratio picker). */
|
||||
modelSelector?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PromptBox2D = ({
|
||||
@@ -65,6 +72,7 @@ export const PromptBox2D = ({
|
||||
onAspectRatioChange,
|
||||
onFitPressed,
|
||||
usePrompt2DStore,
|
||||
modelSelector,
|
||||
}: PromptBox2DProps) => {
|
||||
useSignals();
|
||||
|
||||
@@ -376,7 +384,7 @@ export const PromptBox2D = ({
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
"glass relative w-[860px] rounded-2xl p-4",
|
||||
selectedImageModel?.canUseImagePrompt &&
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
@@ -438,6 +446,7 @@ export const PromptBox2D = ({
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{modelSelector}
|
||||
{selectedImageModel?.canChangeAspectRatio && (
|
||||
<Tooltip
|
||||
content="Aspect ratio"
|
||||
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
} from "@storyteller/tauri-api";
|
||||
import { usePrompt3DStore, useEnterToGenerateStore } from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { CommonAspectRatio, CommonResolution, ImageModel } from "@storyteller/model-list";
|
||||
import {
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
ImageModel,
|
||||
} from "@storyteller/model-list";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
import { AspectRatioPicker } from "./common/AspectRatioPicker";
|
||||
@@ -69,11 +73,11 @@ interface PromptBox3DProps {
|
||||
selectedImageModel?: ImageModel;
|
||||
selectedProvider?: GenerationProvider;
|
||||
snapshotCurrentFrame:
|
||||
| ((shouldDownload?: boolean) => {
|
||||
base64Snapshot: string;
|
||||
file: File;
|
||||
} | null)
|
||||
| undefined;
|
||||
| ((shouldDownload?: boolean) => {
|
||||
base64Snapshot: string;
|
||||
file: File;
|
||||
} | null)
|
||||
| undefined;
|
||||
credits?: number | null;
|
||||
/** Optional model-picker slot rendered at the start of the toolbar
|
||||
* (left of the aspect-ratio picker). Tauri leaves this unset and
|
||||
@@ -298,7 +302,9 @@ export const PromptBox3D = ({
|
||||
return;
|
||||
}
|
||||
if (isFinite(maxLen) && prompt.length > maxLen) {
|
||||
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxLen} character limit for this model`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
gtagEvent("enqueue_3d");
|
||||
@@ -479,8 +485,7 @@ export const PromptBox3D = ({
|
||||
?.map((image) => image.url)
|
||||
.filter(Boolean),
|
||||
modelType:
|
||||
(selectedImageModel as any)?.tauriId ||
|
||||
String(selectedImageModel),
|
||||
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
@@ -628,11 +633,11 @@ export const PromptBox3D = ({
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
"glass relative w-[860px] rounded-2xl p-4",
|
||||
isPromptBoxFocused ? "!border !border-primary" : "",
|
||||
selectedImageModel?.canUseImagePrompt &&
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
isImageRowVisible &&
|
||||
"rounded-t-none",
|
||||
)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -693,7 +698,9 @@ export const PromptBox3D = ({
|
||||
setIsPromptBoxFocused(false);
|
||||
}}
|
||||
/>
|
||||
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
{prompt.length} / {isFinite(maxLen) ? maxLen : "∞"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -836,13 +843,20 @@ export const PromptBox3D = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
|
||||
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
|
||||
<Tooltip
|
||||
content={isExpanded ? "Collapse" : "Expand"}
|
||||
position="top"
|
||||
className="-mb-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
|
||||
>
|
||||
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-xs"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,7 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { Button, ToggleButton, GenerateButton } from "@storyteller/ui-button";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import {
|
||||
GenerateImage,
|
||||
GenerateImageRequest,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { GenerateImage, GenerateImageRequest } from "@storyteller/tauri-api";
|
||||
import {
|
||||
faMessageXmark,
|
||||
faMessageCheck,
|
||||
@@ -19,7 +16,11 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
|
||||
import { usePromptImageStore, RefImage, useEnterToGenerateStore } from "./promptStore";
|
||||
import {
|
||||
usePromptImageStore,
|
||||
RefImage,
|
||||
useEnterToGenerateStore,
|
||||
} from "./promptStore";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ImagePromptRow } from "./ImagePromptRow";
|
||||
@@ -53,6 +54,9 @@ interface PromptBoxImageProps {
|
||||
url?: string;
|
||||
onImageRowVisibilityChange?: (visible: boolean) => void;
|
||||
credits?: number | null;
|
||||
/** Optional model-picker slot rendered at the start of the toolbar
|
||||
* (left of the aspect-ratio picker). */
|
||||
modelSelector?: ReactNode;
|
||||
}
|
||||
|
||||
export const PromptBoxImage = ({
|
||||
@@ -65,10 +69,15 @@ export const PromptBoxImage = ({
|
||||
url,
|
||||
onImageRowVisibilityChange,
|
||||
credits,
|
||||
modelSelector,
|
||||
}: PromptBoxImageProps) => {
|
||||
useSignals();
|
||||
|
||||
console.debug("Selected model and provider:", selectedModel, selectedProvider);
|
||||
console.debug(
|
||||
"Selected model and provider:",
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (imageMediaId && url) {
|
||||
@@ -333,6 +342,7 @@ export const PromptBoxImage = ({
|
||||
refImageUrls: referenceImages?.map((img) => img.url).filter(Boolean),
|
||||
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
|
||||
timestamp: Date.now(),
|
||||
batchCount: generationCount,
|
||||
});
|
||||
|
||||
console.debug("Image Generation Request", request);
|
||||
@@ -414,7 +424,7 @@ export const PromptBoxImage = ({
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
"glass relative w-full rounded-2xl p-4",
|
||||
isImageRowVisible &&
|
||||
selectedModel?.canUseImagePrompt &&
|
||||
"rounded-t-none",
|
||||
@@ -479,6 +489,7 @@ export const PromptBoxImage = ({
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{modelSelector}
|
||||
{selectedModel?.supportsNewAspectRatio() && (
|
||||
<AspectRatioPicker
|
||||
model={selectedModel}
|
||||
|
||||
@@ -51,6 +51,7 @@ declare global {
|
||||
refImageUrls?: string[];
|
||||
modelType?: string;
|
||||
timestamp: number;
|
||||
batchCount?: number;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
@@ -108,6 +109,9 @@ interface PromptBoxVideoProps {
|
||||
uploadVideo?: UploadImageFn;
|
||||
uploadAudio?: UploadImageFn;
|
||||
credits?: number | null;
|
||||
/** Optional model-picker slot rendered at the start of the toolbar
|
||||
* (left of the aspect-ratio picker). */
|
||||
modelSelector?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PromptBoxVideo = ({
|
||||
@@ -122,6 +126,7 @@ export const PromptBoxVideo = ({
|
||||
uploadVideo,
|
||||
uploadAudio,
|
||||
credits,
|
||||
modelSelector,
|
||||
}: PromptBoxVideoProps) => {
|
||||
useSignals();
|
||||
|
||||
@@ -193,6 +198,19 @@ export const PromptBoxVideo = ({
|
||||
// Model / Costs / Help row at the bottom of the page.
|
||||
const BOTTOM_SAFE_AREA_PX = 160;
|
||||
|
||||
// Viewport-relative expansion ceiling — mirrors PromptBoxImage's
|
||||
// `clamp(120px, calc(100vh - 700px), 500px)` so the editor can stretch the
|
||||
// same amount no matter where the (now bottom-fixed) prompt box sits on
|
||||
// screen. Using the element's live top position made the box shrink as it
|
||||
// moved down the page; this keeps a generous, position-independent ceiling.
|
||||
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
|
||||
|
||||
const computeExpandedEditorHeight = (): number => {
|
||||
return Math.max(120, Math.min(window.innerHeight - 700, 500));
|
||||
};
|
||||
|
||||
// Collapsed cap stays element-relative so a long unexpanded prompt never
|
||||
// pushes the box under the fixed bottom action row.
|
||||
const computeAvailableEditorHeight = (el: HTMLElement): number => {
|
||||
const topFromViewport = el.getBoundingClientRect().top;
|
||||
return Math.max(
|
||||
@@ -207,9 +225,7 @@ export const PromptBoxVideo = ({
|
||||
const el = (mentionEditorRef.current ??
|
||||
textareaRef.current) as HTMLElement | null;
|
||||
if (el) {
|
||||
el.style.height = next
|
||||
? `${computeAvailableEditorHeight(el)}px`
|
||||
: "auto";
|
||||
el.style.height = next ? EXPANDED_HEIGHT : "auto";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -271,8 +287,9 @@ export const PromptBoxVideo = ({
|
||||
const el = (mentionEditorRef.current ??
|
||||
textareaRef.current) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const available = computeAvailableEditorHeight(el);
|
||||
const maxH = isExpanded ? available : Math.min(available, 500);
|
||||
const maxH = isExpanded
|
||||
? computeExpandedEditorHeight()
|
||||
: Math.min(computeAvailableEditorHeight(el), 500);
|
||||
el.style.maxHeight = `${maxH}px`;
|
||||
el.style.minHeight = "0";
|
||||
if (!isExpanded) {
|
||||
@@ -307,8 +324,9 @@ export const PromptBoxVideo = ({
|
||||
const el = (mentionEditorRef.current ??
|
||||
textareaRef.current) as HTMLElement | null;
|
||||
if (el) {
|
||||
const available = computeAvailableEditorHeight(el);
|
||||
const maxH = isExpanded ? available : Math.min(available, 500);
|
||||
const maxH = isExpanded
|
||||
? computeExpandedEditorHeight()
|
||||
: Math.min(computeAvailableEditorHeight(el), 500);
|
||||
el.style.maxHeight = `${maxH}px`;
|
||||
el.style.minHeight = "0";
|
||||
if (!isExpanded) {
|
||||
@@ -997,7 +1015,7 @@ export const PromptBoxVideo = ({
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass relative w-[860px] rounded-xl p-4",
|
||||
"glass relative w-full rounded-2xl p-4",
|
||||
isImageRowVisible && "rounded-t-none",
|
||||
isFocused
|
||||
? "ring-1 ring-primary border-primary"
|
||||
@@ -1071,6 +1089,7 @@ export const PromptBoxVideo = ({
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{modelSelector}
|
||||
<Tooltip
|
||||
content="Aspect Ratio"
|
||||
position="top"
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useEditor } from "./editor/use-editor";
|
||||
import { useEditorActions } from "./actions/use-editor-actions";
|
||||
import { useKeybindingsListener } from "./actions/use-keybindings";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
@@ -57,24 +58,32 @@ export function VideoEditor({
|
||||
}: VideoEditorProps) {
|
||||
return (
|
||||
<EditorProvider adapters={adapters}>
|
||||
<MobileGate>
|
||||
{/* Boundary contains any descendant throw — without this, an
|
||||
unhandled exception in a panel or renderer node propagates
|
||||
to the host shell and the user sees a blank app. The
|
||||
boundary's recovery button remounts the editor subtree
|
||||
(key bump); EditorCore + the host's project state stay
|
||||
alive across the recovery. */}
|
||||
<EditorErrorBoundary>
|
||||
<div className="bg-background flex h-full w-full flex-col overflow-hidden">
|
||||
<DegradedRendererBanner />
|
||||
<EditorHeader endSlot={headerEndSlot} />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
{/* Shell-wide Radix tooltip provider so every descendant tooltip has a
|
||||
provider ancestor regardless of which panel wraps its own. Without
|
||||
this, hosts that don't mount an app-level provider (the Tauri app)
|
||||
crash with "Tooltip must be used within TooltipProvider" the moment a
|
||||
panel renders a tooltip outside its local provider. Nested providers
|
||||
are valid in Radix. */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<MobileGate>
|
||||
{/* Boundary contains any descendant throw — without this, an
|
||||
unhandled exception in a panel or renderer node propagates
|
||||
to the host shell and the user sees a blank app. The
|
||||
boundary's recovery button remounts the editor subtree
|
||||
(key bump); EditorCore + the host's project state stay
|
||||
alive across the recovery. */}
|
||||
<EditorErrorBoundary>
|
||||
<div className="bg-background flex h-full w-full flex-col overflow-hidden">
|
||||
<DegradedRendererBanner />
|
||||
<EditorHeader endSlot={headerEndSlot} />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
</EditorErrorBoundary>
|
||||
</MobileGate>
|
||||
</EditorErrorBoundary>
|
||||
</MobileGate>
|
||||
</TooltipProvider>
|
||||
</EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./lib/classes/properties/CommonResolution.js";
|
||||
export * from "./lib/classes/properties/CommonQuality.js";
|
||||
export * from "./lib/classes/metadata/ModelCreator.js";
|
||||
export * from "./lib/classes/metadata/ModelCreatorIcons.js";
|
||||
export * from "./lib/classes/metadata/ModelCreatorIconForId.js";
|
||||
export * from "./lib/classes/metadata/ModelMapping.js";
|
||||
export * from "./lib/classes/metadata/PromptFormatters.js";
|
||||
export * from "./lib/classes/metadata/ModelTag.js";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ModelCreator } from "./ModelCreator.js";
|
||||
import {
|
||||
getCreatorIconPath,
|
||||
getServicesBasePath,
|
||||
} from "./ModelCreatorIcons.js";
|
||||
import { ALL_MODELS_LIST } from "../../lists/AllModels.js";
|
||||
|
||||
// Fallback prefix → creator map for model ids that aren't in ALL_MODELS_LIST
|
||||
// (legacy / API-only ids). Mirrors the webapp's omni-gen icon mapping.
|
||||
const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [
|
||||
["flux", ModelCreator.BlackForestLabs],
|
||||
["nano_banana", ModelCreator.Google],
|
||||
["gpt_image", ModelCreator.OpenAi],
|
||||
["midjourney", ModelCreator.Midjourney],
|
||||
["seedream", ModelCreator.Bytedance],
|
||||
["seedance", ModelCreator.Bytedance],
|
||||
["kling", ModelCreator.Kling],
|
||||
["sora", ModelCreator.OpenAi],
|
||||
["veo", ModelCreator.Google],
|
||||
["grok", ModelCreator.Grok],
|
||||
["happy_horse", ModelCreator.Alibaba],
|
||||
// Beeble SwitchX (background change) has no provider icon — use the ArtCraft
|
||||
// mark since it's surfaced as an ArtCraft feature.
|
||||
["switch_x", ModelCreator.ArtCraft],
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve a model id (canonical or tauri) to its creator's icon path.
|
||||
* Falls back to a prefix match, then to the generic icon.
|
||||
*/
|
||||
export const getCreatorIconPathForModelId = (modelId: string): string => {
|
||||
const model = ALL_MODELS_LIST.find(
|
||||
(m) => m.id === modelId || m.tauriId === modelId,
|
||||
);
|
||||
if (model) return getCreatorIconPath(model.creator);
|
||||
for (const [prefix, creator] of MODEL_ID_PREFIX_CREATORS) {
|
||||
if (modelId.startsWith(prefix)) return getCreatorIconPath(creator);
|
||||
}
|
||||
return `${getServicesBasePath()}/generic.svg`;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import React, { ReactNode } from "react";
|
||||
import { ModelCreator } from "./ModelCreator.js";
|
||||
import { IsDesktopApp } from "@storyteller/tauri-utils";
|
||||
|
||||
const getServicesBasePath = (): string => {
|
||||
export const getServicesBasePath = (): string => {
|
||||
return IsDesktopApp() ? "/resources/images/services" : "/images/services";
|
||||
};
|
||||
|
||||
|
||||
Generated
+310
-98
File diff suppressed because it is too large
Load Diff
@@ -203,6 +203,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/video-editor"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/generation-list"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user