mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 02:00:46 +00:00
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:
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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+1
@@ -611,6 +611,7 @@
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/generation-list": {
|
||||
"name": "@storyteller/ui-generation-list",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/components/gravatar": {
|
||||
|
||||
Reference in new issue
Block a user