Compare commits

...
Author SHA1 Message Date
bflatastic 2029524c77 updates to pricing color and button text 2026-06-13 21:19:54 +07:00
bflatastic 0354b68f88 Desktop: marquee select, login/pricing/credits restyle, apps landing, video editor fix
- Gallery modal: marquee drag-rectangle selection in view mode (feeds the
  existing bulk selection; starts on blank space so it never conflicts with
  drag-to-folder/canvas).
- Login modal: webapp-style 2-column layout (form left, Vimeo right), no
  Google button, artcraft-icon logo; removed the dismiss button so login is
  required; kept the Discord/Success onboarding steps and Tauri auth flow.
- Pricing + credits modals: restyled to match the webapp (unified monochrome
  plan cards with Most Popular/Best Value frames; #161618 credits cards), all
  Tauri invoke billing kept as-is.
- Apps page is now the first tab and default landing (Home icon), restyled to
  the webapp home cards ("What will you craft today?", Create/Edit sections,
  gradient-hover cards, full-width scrollbar); quick menu header Generate→Create.
- Video promptbox now expands as tall as the image promptbox (viewport-relative
  clamp instead of element-position-relative).
- Fix Video Editor crash on the Tauri app: wrap the editor shell in a Radix
  TooltipProvider so panel tooltips have a provider ancestor (the webapp got
  one app-wide via SidebarProvider; the desktop had none).
2026-06-13 21:08:07 +07:00
bflatastic 5b3327f919 tauri events for desktop app 2026-06-12 22:28:04 +07:00
bflatastic 34300089c8 Desktop create pages redesign: shared generation feed, embedded model selector, gallery folders sidebar 2026-06-12 21:57:24 +07:00
bflatastic 9950dfebf0 folders in gallery modal on desktop 2026-06-12 18:24:33 +07:00
88 changed files with 5372 additions and 3088 deletions
@@ -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>
@@ -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";
@@ -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";
@@ -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>
)}
</>
);
@@ -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";
@@ -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";
@@ -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}
+1 -1
View File
@@ -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"
},
+11 -8
View File
@@ -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>
);
}
@@ -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 });
}
@@ -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;
}
}
+16 -16
View File
@@ -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";
@@ -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 (
@@ -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)}
@@ -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>
@@ -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>
);
@@ -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>
);
}
@@ -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
@@ -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">
&copy; {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>
);
}
+1
View File
@@ -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";
};
+310 -98
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -203,6 +203,9 @@
},
{
"path": "./libs/components/video-editor"
},
{
"path": "./libs/components/generation-list"
}
]
}