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).
This commit is contained in:
bflatastic committed 2026-06-13 21:08:07 +07:00
1 parent 5b3327f919
commit 0354b68f88
17 files changed
+1159 -685

No files matched your search

@@ -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

@@ -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,
@@ -97,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,
@@ -107,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>("");
@@ -368,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:
@@ -82,9 +82,111 @@ 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",
@@ -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",
},
@@ -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>
);
}
@@ -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
+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"
},
@@ -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);
@@ -1048,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) {
@@ -2318,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}
@@ -2867,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,
)}
</>
);
},
@@ -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;
@@ -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(
getUnifiedCardClasses(),
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,95 @@ export function PricingContent({ title, subtitle }: PricingContentProps) {
);
}
interface PricingModalProps { }
// Unified monochrome card base (mirrors the webapp pricing-table unifiedTheme).
const getUnifiedCardClasses = () =>
twMerge(
"relative flex flex-col rounded-2xl border p-5 transition-all duration-300 sm:rounded-[28px] md:p-6",
"bg-white/5 border-white/10 hover:border-white/20",
);
export function PricingModal({ }: PricingModalProps = {}) {
// 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>
@@ -198,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(
@@ -212,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;
});
@@ -276,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) {
@@ -312,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) {
@@ -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
@@ -611,6 +611,7 @@
"version": "0.0.1"
},
"libs/components/generation-list": {
"name": "@storyteller/ui-generation-list",
"version": "0.0.1"
},
"libs/components/gravatar": {