diff --git a/frontend/apps/artcraft-webapp/tsconfig.app.json b/frontend/apps/artcraft-webapp/tsconfig.app.json index 640cb27f10..15daebf644 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.app.json +++ b/frontend/apps/artcraft-webapp/tsconfig.app.json @@ -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" }, diff --git a/frontend/apps/artcraft-webapp/tsconfig.json b/frontend/apps/artcraft-webapp/tsconfig.json index d15cb9bc05..23a7c50fd2 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.json +++ b/frontend/apps/artcraft-webapp/tsconfig.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" }, diff --git a/frontend/apps/artcraft/app/public/resources/logo/artcraft-icon.png b/frontend/apps/artcraft/app/public/resources/logo/artcraft-icon.png new file mode 100644 index 0000000000..ded60e7ef4 Binary files /dev/null and b/frontend/apps/artcraft/app/public/resources/logo/artcraft-icon.png differ diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/AppsQuickMenu.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/AppsQuickMenu.tsx index 98da07bbbd..17a5519570 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/AppsQuickMenu.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/AppsQuickMenu.tsx @@ -13,7 +13,7 @@ export const AppsQuickMenu = () => { return (
-

Generate

+

Create

{generateApps.map((app) => ( + ))}
@@ -85,3 +49,72 @@ export const AppsIndexPage = () => {
); }; + +function AppCard({ app }: { app: FullAppItem }) { + const palette = getAppCardPalette(app.id); + const enabled = !!app.action; + + return ( + + ); +} diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts index 3e470ac1b9..c4b8fcfd36 100644 --- a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts +++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts @@ -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 diff --git a/frontend/apps/artcraft/tsconfig.json b/frontend/apps/artcraft/tsconfig.json index e9ad9b4136..b047dce4da 100644 --- a/frontend/apps/artcraft/tsconfig.json +++ b/frontend/apps/artcraft/tsconfig.json @@ -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" }, diff --git a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx index 568ab143dc..93e37d719b 100644 --- a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx +++ b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx @@ -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>(new Set()); const scrollContainerRef = useRef(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(null); + const marqueeRaf = useRef(0); const [username, setUsername] = useState(""); 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) => { + 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("[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 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( +
, + document.body, + )} ); }, diff --git a/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx b/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx index 9c4f1503d3..86f842e173 100644 --- a/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx +++ b/frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx @@ -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; 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(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 ( -
-

- {isSignUp ? "Sign up for ArtCraft" : "Log in to ArtCraft"} -

-
- {isSignUp ? ( - <> + + {localError && ( +
+ + {localError} +
+ )} + + {isSignUp ? ( + <> +
+ +
+
+ - - ) : ( - - )} - - - {isSignUp && ( - - )} -
- {localError && ( -
- - {localError} -
- )} + + ) : ( +
+ +
-
-
+ )} + +
+ +
+ + +
+
+ + {isSignUp && ( +
+ +
+ + +
+
+ )} + +
+ +
+ +
+ {isSignUp ? "Already have an account?" : "Don't have an account?"}{" "} + +
+ ); }; diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.tsx index d320f5812d..977441452f 100644 --- a/frontend/libs/components/login-modal/src/lib/login-modal.tsx +++ b/frontend/libs/components/login-modal/src/lib/login-modal.tsx @@ -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(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 = () => ( -
- {[...Array(uiTotalSteps)].map((_, idx) => ( -
- ))} -
- ); - 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 ( -
-

+
+

Thank you for signing in!

-

+

You're all set to start creating amazing content.

- -
-

- ); - } - - switch (step) { - case 1: - return ( -
-

- It's in your toolbox forever now. -

-
- ); - case 2: - return ( - { - 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 ( -
- {step === 1 ? ( - <> - - - - ) : ( - <> - - - - )} +
+

+ Join Our Community +

+

+ Connect with other creators, share your work, and get the latest + updates in our Discord community. +

+
+ + +
); }; + const inOnboarding = showDiscord || showSuccess; + return (
@@ -346,41 +226,56 @@ export function LoginModal({ leaveTo="opacity-0 scale-95" >
e.stopPropagation()} > -
- {step === 1 && !showSuccess && !showDiscord && ( -
- -
-
- )} + {inOnboarding ? ( + renderOnboarding() + ) : ( + <> + {/* ── Form pane ── (no dismiss control — login is required) */} +
+
+
+
+ ArtCraft +

+ {isSignUp ? "Create your account" : "Welcome back"} +

+

+ {isSignUp + ? "Sign up to start creating with ArtCraft" + : "Log in to your account"} +

+
-
- {!showSuccess && ( - <> - - Step {uiCurrentStep} of {uiTotalSteps} - - {renderProgress()} - - )} - {renderStepContent()} - {renderFooterButtons()} -
-
+ setIsSignUp((prev) => !prev)} + errorMessage={errorMessage} + isLoading={isLoading} + /> +
+
+ +
+ © {new Date().getFullYear()} ArtCraft. All rights + reserved. +
+
+ + {/* ── Showcase pane (desktop only) ── */} +
+ +
+ + )}
@@ -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 ( +
+