mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-07 02:21:23 +00:00
Webapp stylings (#1916)
This commit is contained in:
1 parent
d2ad5820e0
commit
60bcfffc8d
156 files changed
+2506
-2747
No files matched your search
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { CheckIcon, MinusIcon } from "lucide-react"
|
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Checkbox({
|
function Checkbox({
|
||||||
className,
|
className,
|
||||||
@@ -14,8 +14,8 @@ function Checkbox({
|
|||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
|
"peer size-4 shrink-0 rounded-[3px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -30,7 +30,7 @@ function Checkbox({
|
|||||||
)}
|
)}
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Checkbox }
|
export { Checkbox };
|
||||||
@@ -59,7 +59,9 @@ export function UserSignups() {
|
|||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
if (resp.success && resp.data) {
|
if (resp.success && resp.data) {
|
||||||
setUsers((prev) => (append ? [...prev, ...resp.data!.users] : resp.data!.users));
|
setUsers((prev) =>
|
||||||
|
append ? [...prev, ...resp.data!.users] : resp.data!.users,
|
||||||
|
);
|
||||||
setNextCursor(resp.data.next_cursor);
|
setNextCursor(resp.data.next_cursor);
|
||||||
} else {
|
} else {
|
||||||
setError(resp.errorMessage || "Failed to load users");
|
setError(resp.errorMessage || "Failed to load users");
|
||||||
@@ -169,7 +171,9 @@ export function UserSignups() {
|
|||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
|
<IconRefresh
|
||||||
|
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +192,8 @@ export function UserSignups() {
|
|||||||
All Users
|
All Users
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||||
({users.length}{nextCursor != null ? "+" : ""})
|
({users.length}
|
||||||
|
{nextCursor != null ? "+" : ""})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -234,15 +239,33 @@ export function UserSignups() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
<TableCell><Skeleton className="size-4 rounded-[4px]" /></TableCell>
|
<TableCell>
|
||||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
<Skeleton className="size-4 rounded-[3px]" />
|
||||||
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
|
</TableCell>
|
||||||
<TableCell><Skeleton className="h-4 w-36" /></TableCell>
|
<TableCell>
|
||||||
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
|
<Skeleton className="h-4 w-24" />
|
||||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
</TableCell>
|
||||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
<TableCell>
|
||||||
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
|
<Skeleton className="h-4 w-28" />
|
||||||
<TableCell><Skeleton className="h-6 w-20 rounded" /></TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-5 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-6 w-20 rounded" />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -305,12 +328,21 @@ export function UserSignups() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{user.display_name || <span className="text-muted-foreground/30">—</span>}
|
{user.display_name || (
|
||||||
|
<span className="text-muted-foreground/30">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
<span className="text-muted-foreground">{user.email_address}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{user.email_address}
|
||||||
|
</span>
|
||||||
{!user.email_confirmed && (
|
{!user.email_confirmed && (
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] text-amber-400 border-amber-400/30">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ml-2 text-[10px] text-amber-400 border-amber-400/30"
|
||||||
|
>
|
||||||
Unconfirmed
|
Unconfirmed
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -330,40 +362,65 @@ export function UserSignups() {
|
|||||||
{user.maybe_source}
|
{user.maybe_source}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/30">—</span>
|
<span className="text-muted-foreground/30">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{user.maybe_signup_method ? (
|
{user.maybe_signup_method ? (
|
||||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
{user.maybe_signup_method}
|
{user.maybe_signup_method}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/30">—</span>
|
<span className="text-muted-foreground/30">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{user.is_temporary && (
|
{user.is_temporary && (
|
||||||
<Badge variant="secondary" className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent"
|
||||||
|
>
|
||||||
Temp
|
Temp
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{user.is_without_password && (
|
{user.is_without_password && (
|
||||||
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-400 border-transparent">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] bg-orange-500/10 text-orange-400 border-transparent"
|
||||||
|
>
|
||||||
No Password
|
No Password
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!user.is_temporary && !user.is_without_password && (
|
{!user.is_temporary && !user.is_without_password && (
|
||||||
<Badge variant="secondary" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent"
|
||||||
|
>
|
||||||
Active
|
Active
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
<Button variant="ghost" size="sm" asChild className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
|
<Button
|
||||||
<a href={`/user/profile/${user.username}`} target="_blank" rel="noopener noreferrer">
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/user/profile/${user.username}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<IconExternalLink className="size-3.5" />
|
<IconExternalLink className="size-3.5" />
|
||||||
View Profile
|
View Profile
|
||||||
</a>
|
</a>
|
||||||
@@ -380,7 +437,9 @@ export function UserSignups() {
|
|||||||
Loading more...
|
Loading more...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground/50">Scroll for more</span>
|
<span className="text-muted-foreground/50">
|
||||||
|
Scroll for more
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Serif:ital@0;1&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -61,10 +61,10 @@ function ScrollToTop() {
|
|||||||
|
|
||||||
function AuthCheckSpinner() {
|
function AuthCheckSpinner() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-[#101014]">
|
<div className="flex h-screen items-center justify-center bg-ui-background">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon
|
||||||
|
|
||||||
className="animate-spin text-4xl text-primary/80" />
|
className="animate-spin text-4xl text-white/70" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ function ProtectedContent() {
|
|||||||
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
|
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{!hideTopBar && <TopBar />}
|
{!hideTopBar && <TopBar />}
|
||||||
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
|
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-ui-background">
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ export function GoogleLoginButton({
|
|||||||
className={`group relative w-full h-10 transition-transform duration-150 active:scale-95 ${className}`}
|
className={`group relative w-full h-10 transition-transform duration-150 active:scale-95 ${className}`}
|
||||||
>
|
>
|
||||||
{/* Custom visual button - shown behind */}
|
{/* Custom visual button - shown behind */}
|
||||||
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-full font-medium border border-gray-200 group-hover:border-white/80 group-hover:bg-white/80 transition-colors">
|
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 rounded-[3px] bg-white text-black font-medium group-hover:bg-white/80 transition-colors">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin text-lg text-black/60" />
|
||||||
|
|
||||||
className="animate-spin text-lg text-gray-600" />
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GoogleIcon />
|
<GoogleIcon />
|
||||||
<span className="text-sm font-semibold">Continue with Google</span>
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
Continue with Google
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const AuthLayout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#101014] p-4 text-white">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ui-background p-4 text-white">
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-0 z-0"
|
className="pointer-events-none absolute inset-0 z-0"
|
||||||
@@ -48,16 +48,7 @@ export const AuthLayout = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden border border-white/15 bg-[#101014] lg:min-h-[640px]">
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-0 z-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] shadow-2xl lg:min-h-[640px]">
|
|
||||||
{/* Form pane — only the inner content swaps per route (via Outlet), so
|
{/* Form pane — only the inner content swaps per route (via Outlet), so
|
||||||
this pane and the showcase beside it stay mounted across the
|
this pane and the showcase beside it stay mounted across the
|
||||||
login/signup toggle. */}
|
login/signup toggle. */}
|
||||||
@@ -66,7 +57,7 @@ export const AuthLayout = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
aria-label="Go back"
|
aria-label="Go back"
|
||||||
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center rounded-full text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon />
|
<ArrowLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const FEATURED_VIDEO_URL =
|
|||||||
export const AuthShowcase = () => {
|
export const AuthShowcase = () => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-2 overflow-hidden bg-black rounded-2xl"
|
className="absolute inset-2 overflow-hidden bg-black"
|
||||||
style={{ containerType: "size" }}
|
style={{ containerType: "size" }}
|
||||||
>
|
>
|
||||||
{/* Vimeo background embed (no controls) scaled to cover the pane. The
|
{/* Vimeo background embed (no controls) scaled to cover the pane. The
|
||||||
@@ -30,7 +30,7 @@ export const AuthShowcase = () => {
|
|||||||
|
|
||||||
{/* Caption */}
|
{/* Caption */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8">
|
<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">
|
<p className="mb-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
|
||||||
One of the cheapest
|
One of the cheapest
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-2xl font-bold leading-tight">
|
<h2 className="text-2xl font-bold leading-tight">
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const SignupForm = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -123,7 +123,7 @@ export const SignupForm = ({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<Reveal className="space-y-2">
|
<Reveal className="space-y-2">
|
||||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -132,13 +132,13 @@ export const SignupForm = ({
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
inputClassName="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"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal className="space-y-2">
|
<Reveal className="space-y-2">
|
||||||
<div className="flex justify-between items-center ml-1">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-xs font-semibold text-white/70">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +148,7 @@ export const SignupForm = ({
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Min. 8 characters"
|
placeholder="Min. 8 characters"
|
||||||
inputClassName="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 pr-12"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -163,12 +163,12 @@ export const SignupForm = ({
|
|||||||
|
|
||||||
<Reveal className="pt-2">
|
<Reveal className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="w-full justify-center h-10"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Create account"
|
"Create account"
|
||||||
)}
|
)}
|
||||||
@@ -190,7 +190,7 @@ export const SignupForm = ({
|
|||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-white/10" />
|
<div className="w-full border-t border-white/10" />
|
||||||
</div>
|
</div>
|
||||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
<span className="relative bg-[#101014] px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
|
||||||
or
|
or
|
||||||
</span>
|
</span>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
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)]"
|
className="w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/15 bg-ui-modal p-0"
|
||||||
allowBackgroundInteraction={false}
|
allowBackgroundInteraction={false}
|
||||||
showClose={true}
|
showClose={true}
|
||||||
closeOnOutsideClick={true}
|
closeOnOutsideClick={true}
|
||||||
@@ -60,16 +60,6 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
|||||||
backdropClassName="bg-black/80"
|
backdropClassName="bg-black/80"
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<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">
|
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-11 sm:pb-9">
|
||||||
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||||
Buy <span className="text-primary">credits</span>
|
Buy <span className="text-primary">credits</span>
|
||||||
@@ -89,23 +79,21 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
|||||||
onClick={() => handlePurchase(pack)}
|
onClick={() => handlePurchase(pack)}
|
||||||
disabled={purchasingId !== null}
|
disabled={purchasingId !== null}
|
||||||
className={twMerge(
|
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",
|
"group relative flex flex-col gap-6 border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
isPopular
|
isPopular
|
||||||
? "border-primary/50 bg-primary/[0.07] hover:border-primary"
|
? "border-white/60 bg-white/[0.06] hover:border-white"
|
||||||
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]",
|
: "border-white/15 bg-white/[0.02] hover:border-white/30 hover:bg-white/[0.04]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pack.badge && (
|
{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">
|
<span className="absolute -top-2.5 right-3 bg-white px-2.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-black">
|
||||||
{pack.badge}
|
{pack.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center bg-white/10">
|
||||||
<CoinsIcon
|
<CoinsIcon className="text-white text-lg" />
|
||||||
|
|
||||||
className="text-primary text-lg" />
|
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-4xl font-bold leading-none tracking-tight text-white">
|
<div className="text-4xl font-bold leading-none tracking-tight text-white">
|
||||||
@@ -119,17 +107,13 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
|||||||
<span className="text-2xl font-semibold text-white/80">
|
<span className="text-2xl font-semibold text-white/80">
|
||||||
${pack.priceUsd}
|
${pack.priceUsd}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 text-base font-semibold text-primary-400">
|
<span className="flex items-center gap-1.5 text-base font-semibold text-white">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
|
||||||
className="animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Buy
|
Buy
|
||||||
<ArrowRightIcon
|
<ArrowRightIcon className="text-xs transition-transform group-hover:translate-x-0.5" />
|
||||||
|
|
||||||
className="text-xs transition-transform group-hover:translate-x-0.5" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+14
-21
@@ -80,10 +80,10 @@ export function CreateMediaPageShell({
|
|||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-[#101014]">
|
<div className="flex h-full items-center justify-center bg-ui-background">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon
|
||||||
|
|
||||||
className="animate-spin text-4xl text-primary/80" />
|
className="animate-spin text-4xl text-white/60" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ export function CreateMediaPageShell({
|
|||||||
goToHistory: () => setMobileTab("history"),
|
goToHistory: () => setMobileTab("history"),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
|
<div className="flex h-full w-full flex-col bg-ui-background text-white">
|
||||||
<Seo title={title} description={description} />
|
<Seo title={title} description={description} />
|
||||||
|
|
||||||
{/* Flex (not absolute) keeps the toggle cluster from overlapping
|
{/* Flex (not absolute) keeps the toggle cluster from overlapping
|
||||||
@@ -150,27 +150,20 @@ export function CreateMediaPageShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full bg-[#101014] text-white">
|
<div className="flex h-full w-full bg-ui-background text-white">
|
||||||
<Seo title={title} description={description} />
|
<Seo title={title} description={description} />
|
||||||
|
|
||||||
{/* Decorative background, empty state only. Not rendered on mobile
|
{/* Decorative background, empty state only. Not rendered on mobile
|
||||||
devices: the glow orbs use filter: blur(120px) and the truchet uses
|
devices: the truchet uses mask-image, a full-screen GPU layer that
|
||||||
mask-image, both of which are full-screen GPU layers that iOS Safari
|
iOS Safari must re-rasterize when an overlay (mobile menu, modal)
|
||||||
must re-rasterize when an overlay (mobile menu, modal) composites over
|
composites over it, causing multi-second hangs. (This is distinct
|
||||||
them, causing multi-second hangs. (This is distinct from
|
from backdrop-filter.) We gate on the actual device
|
||||||
backdrop-filter.) We gate on the actual device (react-device-detect's
|
(react-device-detect's user-agent check) rather than a viewport
|
||||||
user-agent check) rather than a viewport breakpoint so the layers
|
breakpoint so the layers never mount on a real phone/tablet, and a
|
||||||
never mount on a real phone/tablet, and a narrow desktop window still
|
narrow desktop window still gets the decoration. */}
|
||||||
gets the decoration. */}
|
|
||||||
{!hasContent && !isMobile && (
|
{!hasContent && !isMobile && (
|
||||||
<>
|
<>
|
||||||
{glowOrbs ?? (
|
{glowOrbs}
|
||||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
|
||||||
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
|
|
||||||
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
|
|
||||||
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none fixed inset-0 z-0"
|
className="pointer-events-none fixed inset-0 z-0"
|
||||||
@@ -193,7 +186,7 @@ export function CreateMediaPageShell({
|
|||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
{!hasContent && (
|
{!hasContent && (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center drop-shadow-xl">
|
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center">
|
||||||
<h1 className="text-5xl font-semibold text-white md:text-7xl">
|
<h1 className="text-5xl font-semibold text-white md:text-7xl">
|
||||||
{emptyStateTitle}
|
{emptyStateTitle}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -219,7 +212,7 @@ export function CreateMediaPageShell({
|
|||||||
{hasContent && (
|
{hasContent && (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
|
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-ui-background to-transparent"
|
||||||
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ export function GallerySelectionDownloadBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={isDownloading || selectedItems.length === 0}
|
disabled={isDownloading || selectedItems.length === 0}
|
||||||
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
|
className="flex items-center gap-2 border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
|
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
|
||||||
|
|||||||
+12
-5
@@ -1,4 +1,11 @@
|
|||||||
import { ArrowDownToLineIcon, CheckIcon, LinkIcon, LoaderCircleIcon, RotateCwIcon, VideoIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowDownToLineIcon,
|
||||||
|
CheckIcon,
|
||||||
|
LinkIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
RotateCwIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||||
import {
|
import {
|
||||||
@@ -85,7 +92,7 @@ function CardRecreateButton({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleRecreate}
|
onClick={handleRecreate}
|
||||||
disabled={isRecreating}
|
disabled={isRecreating}
|
||||||
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
className="flex items-center gap-1.5 border border-white/15 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
|
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
|
||||||
@@ -103,7 +110,7 @@ function CardRecreateButton({
|
|||||||
onClick={handleRecreate}
|
onClick={handleRecreate}
|
||||||
disabled={isRecreating}
|
disabled={isRecreating}
|
||||||
aria-label="Recreate"
|
aria-label="Recreate"
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
|
className="flex h-7 w-7 shrink-0 items-center justify-center text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
|
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
|
||||||
@@ -136,7 +143,7 @@ function CardActions({
|
|||||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||||
|
|
||||||
const buttonClass =
|
const buttonClass =
|
||||||
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
|
"flex h-7 w-7 items-center justify-center text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -164,7 +171,7 @@ function CardActions({
|
|||||||
onClick={handleMakeVideo}
|
onClick={handleMakeVideo}
|
||||||
aria-label="Make Video"
|
aria-label="Make Video"
|
||||||
>
|
>
|
||||||
<VideoIcon className="text-sm" />
|
<VideoIcon className="text-sm" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+3
-3
@@ -103,8 +103,8 @@ function RowRecreateButton({
|
|||||||
aria-label="Recreate"
|
aria-label="Recreate"
|
||||||
className={
|
className={
|
||||||
kind === "pending"
|
kind === "pending"
|
||||||
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
? "flex h-7 w-7 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||||
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
: "flex h-7 w-7 shrink-0 items-center justify-center text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
@@ -152,7 +152,7 @@ function GalleryRowActions({
|
|||||||
} = actions;
|
} = actions;
|
||||||
|
|
||||||
const buttonClass =
|
const buttonClass =
|
||||||
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
"flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function InsufficientCreditsModal() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
|
className="w-full max-w-md overflow-hidden border border-white/15 bg-ui-modal p-0"
|
||||||
allowBackgroundInteraction={false}
|
allowBackgroundInteraction={false}
|
||||||
showClose={true}
|
showClose={true}
|
||||||
closeOnOutsideClick={true}
|
closeOnOutsideClick={true}
|
||||||
@@ -137,16 +137,6 @@ export function InsufficientCreditsModal() {
|
|||||||
backdropClassName="bg-black/80"
|
backdropClassName="bg-black/80"
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<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-12 sm:pb-10">
|
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
|
||||||
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||||
{copy.heading}
|
{copy.heading}
|
||||||
@@ -161,10 +151,8 @@ export function InsufficientCreditsModal() {
|
|||||||
key={perk}
|
key={perk}
|
||||||
className="flex items-start gap-3 text-[14px] text-white/75"
|
className="flex items-start gap-3 text-[14px] text-white/75"
|
||||||
>
|
>
|
||||||
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
|
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
|
||||||
<CheckIcon
|
<CheckIcon className="text-[9px] text-white" />
|
||||||
|
|
||||||
className="text-[9px] text-primary" />
|
|
||||||
</span>
|
</span>
|
||||||
<span>{perk}</span>
|
<span>{perk}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -176,10 +164,7 @@ export function InsufficientCreditsModal() {
|
|||||||
<>
|
<>
|
||||||
{!isOnBiggestPlan && (
|
{!isOnBiggestPlan && (
|
||||||
<Link to="/pricing" onClick={close} className="block">
|
<Link to="/pricing" onClick={close} className="block">
|
||||||
<Button
|
<Button variant="primary" className="w-full h-10">
|
||||||
variant="primary"
|
|
||||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
|
||||||
>
|
|
||||||
Upgrade plan
|
Upgrade plan
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -187,17 +172,14 @@ export function InsufficientCreditsModal() {
|
|||||||
<Button
|
<Button
|
||||||
variant={isOnBiggestPlan ? "primary" : "secondary"}
|
variant={isOnBiggestPlan ? "primary" : "secondary"}
|
||||||
onClick={handleBuyCredits}
|
onClick={handleBuyCredits}
|
||||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
className="w-full h-10"
|
||||||
>
|
>
|
||||||
Buy more credits
|
Buy more credits
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link to="/pricing" onClick={close} className="block">
|
<Link to="/pricing" onClick={close} className="block">
|
||||||
<Button
|
<Button variant="primary" className="w-full h-10">
|
||||||
variant="primary"
|
|
||||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
|
||||||
>
|
|
||||||
Upgrade plan
|
Upgrade plan
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,12 +4,26 @@ import { Button } from "@storyteller/ui-button";
|
|||||||
import { toast } from "../toast/toast";
|
import { toast } from "../toast/toast";
|
||||||
import { Gravatar } from "@storyteller/ui-gravatar";
|
import { Gravatar } from "@storyteller/ui-gravatar";
|
||||||
import type { UserInfo } from "@storyteller/api";
|
import type { UserInfo } from "@storyteller/api";
|
||||||
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ImageIcon, ImagesIcon, InfoIcon, LinkIcon, LoaderCircleIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, Trash2Icon, UserIcon, VideoIcon, XIcon } from "lucide-react";
|
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
|
||||||
import {
|
import {
|
||||||
getContextImageThumbnail,
|
ArrowDownToLineIcon,
|
||||||
THUMBNAIL_SIZES,
|
CheckIcon,
|
||||||
} from "@storyteller/common";
|
CopyIcon,
|
||||||
|
ImageIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LinkIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PauseIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlayIcon,
|
||||||
|
RotateCwIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UserIcon,
|
||||||
|
VideoIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
|
import { getContextImageThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||||
import {
|
import {
|
||||||
getCreatorIconPathForModelId,
|
getCreatorIconPathForModelId,
|
||||||
getModelDisplayName,
|
getModelDisplayName,
|
||||||
@@ -29,16 +43,12 @@ import {
|
|||||||
|
|
||||||
// ── InfoRow (private to this file; other consumers do not need it) ─────────
|
// ── InfoRow (private to this file; other consumers do not need it) ─────────
|
||||||
|
|
||||||
function InfoRow({
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 last:border-0">
|
||||||
<span className="text-sm text-white/60 font-medium">{label}</span>
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span className="text-sm text-white font-medium flex items-center gap-2">
|
<span className="text-sm text-white font-medium flex items-center gap-2">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
@@ -84,7 +94,9 @@ export function LightboxDetails({
|
|||||||
const promptCopy = useCopyFeedback();
|
const promptCopy = useCopyFeedback();
|
||||||
const shareCopy = useCopyFeedback();
|
const shareCopy = useCopyFeedback();
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(null);
|
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
const stopAudio = useCallback(() => {
|
const stopAudio = useCallback(() => {
|
||||||
@@ -174,14 +186,14 @@ export function LightboxDetails({
|
|||||||
!!createdAt;
|
!!createdAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
|
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
|
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -189,20 +201,20 @@ export function LightboxDetails({
|
|||||||
{promptData.loading ? (
|
{promptData.loading ? (
|
||||||
<div className="space-y-6 animate-pulse">
|
<div className="space-y-6 animate-pulse">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-4 w-20 bg-white/10 rounded" />
|
<div className="h-4 w-20 bg-white/10" />
|
||||||
<div className="h-20 w-full bg-white/10 rounded-lg" />
|
<div className="h-20 w-full bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-4 w-32 bg-white/10 rounded" />
|
<div className="h-4 w-32 bg-white/10" />
|
||||||
<div className="h-10 w-full bg-white/10 rounded-lg" />
|
<div className="h-10 w-full bg-white/10" />
|
||||||
<div className="h-10 w-full bg-white/10 rounded-lg" />
|
<div className="h-10 w-full bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{creator && (
|
{creator && (
|
||||||
<div
|
<div
|
||||||
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/5 ${onClose ? "pr-10" : ""}`}
|
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/10 ${onClose ? "pr-10" : ""}`}
|
||||||
>
|
>
|
||||||
{creator.core_info ? (
|
{creator.core_info ? (
|
||||||
<Gravatar
|
<Gravatar
|
||||||
@@ -213,10 +225,10 @@ export function LightboxDetails({
|
|||||||
backgroundIndex={
|
backgroundIndex={
|
||||||
creator.core_info.default_avatar.color_index
|
creator.core_info.default_avatar.color_index
|
||||||
}
|
}
|
||||||
className="rounded-xl border-white/10"
|
className="rounded-none border-white/15"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-9 w-9 shrink-0 flex items-center justify-center rounded-xl bg-white/10 text-white/50 border border-white/5">
|
<div className="h-9 w-9 shrink-0 flex items-center justify-center bg-white/10 text-white/50 border border-white/15">
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -224,7 +236,7 @@ export function LightboxDetails({
|
|||||||
<span className="text-white text-sm font-semibold leading-none">
|
<span className="text-white text-sm font-semibold leading-none">
|
||||||
{creator.display_name}
|
{creator.display_name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-white/60 text-xs font-medium">
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
Author
|
Author
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +246,7 @@ export function LightboxDetails({
|
|||||||
{promptData.hasToken && (
|
{promptData.hasToken && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
<span>Prompt</span>
|
<span>Prompt</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +264,7 @@ export function LightboxDetails({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
|
<div className="text-sm text-white/90 break-words px-4 py-3 bg-black/20 leading-relaxed border border-white/10">
|
||||||
<div
|
<div
|
||||||
ref={promptRef}
|
ref={promptRef}
|
||||||
className={!isPromptExpanded ? "line-clamp-4" : ""}
|
className={!isPromptExpanded ? "line-clamp-4" : ""}
|
||||||
@@ -274,73 +286,78 @@ export function LightboxDetails({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{promptData.contextImages && promptData.contextImages.length > 0 && (
|
{promptData.contextImages &&
|
||||||
<div className="space-y-2">
|
promptData.contextImages.length > 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
<div className="space-y-2">
|
||||||
<ImageIcon />
|
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
<span>Reference Media</span>
|
<ImageIcon />
|
||||||
</div>
|
<span>Reference Media</span>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
</div>
|
||||||
{promptData.contextImages.map((contextImage, index) => {
|
<div className="grid grid-cols-5 gap-2">
|
||||||
const isAudio = contextImage.semantic === "audioref";
|
{promptData.contextImages.map((contextImage, index) => {
|
||||||
const isVideoRef = contextImage.semantic === "vid_ref";
|
const isAudio = contextImage.semantic === "audioref";
|
||||||
const { thumbnail } = getContextImageThumbnail(
|
const isVideoRef = contextImage.semantic === "vid_ref";
|
||||||
contextImage,
|
const { thumbnail } = getContextImageThumbnail(
|
||||||
{ size: THUMBNAIL_SIZES.SMALL },
|
contextImage,
|
||||||
);
|
{ size: THUMBNAIL_SIZES.SMALL },
|
||||||
const isPlayingThis = playingAudioToken === contextImage.media_token;
|
|
||||||
|
|
||||||
if (isAudio) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={contextImage.media_token}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url)}
|
|
||||||
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
|
|
||||||
>
|
|
||||||
<DynamicIcon
|
|
||||||
icon={isPlayingThis ? PauseIcon : PlayIcon}
|
|
||||||
className="text-white/70 text-lg"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
const isPlayingThis =
|
||||||
|
playingAudioToken === contextImage.media_token;
|
||||||
|
|
||||||
return (
|
if (isAudio) {
|
||||||
<a
|
return (
|
||||||
key={contextImage.media_token}
|
<button
|
||||||
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
|
key={contextImage.media_token}
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
onClick={() =>
|
||||||
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
|
handleAudioToggle(
|
||||||
>
|
contextImage.media_token,
|
||||||
<img
|
contextImage.media_links.cdn_url,
|
||||||
src={thumbnail}
|
)
|
||||||
alt={`Reference ${index + 1}`}
|
}
|
||||||
className="h-full w-full object-cover"
|
className="relative aspect-square overflow-hidden border border-white/15 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
|
||||||
/>
|
>
|
||||||
{isVideoRef && (
|
<DynamicIcon
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
icon={isPlayingThis ? PauseIcon : PlayIcon}
|
||||||
<VideoIcon
|
className="text-white/70 text-lg"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
className="text-white/80 text-sm drop-shadow-lg" />
|
return (
|
||||||
</div>
|
<a
|
||||||
)}
|
key={contextImage.media_token}
|
||||||
</a>
|
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
|
||||||
);
|
target="_blank"
|
||||||
})}
|
rel="noopener noreferrer"
|
||||||
|
className="relative aspect-square overflow-hidden border border-white/15 hover:border-white/40 transition-colors block"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={`Reference ${index + 1}`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{isVideoRef && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<VideoIcon className="text-white/80 text-sm drop-shadow-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{hasInfoSection && (
|
{hasInfoSection && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<span>Information</span>
|
<span>Information</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
|
<div className="flex flex-col bg-black/20 border border-white/10 overflow-hidden">
|
||||||
{promptData.modelType && (
|
{promptData.modelType && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="Model"
|
label="Model"
|
||||||
@@ -421,7 +438,7 @@ export function LightboxDetails({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-2 border-t border-white/5">
|
<div className="p-4 space-y-2 border-t border-white/10">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
|
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
|
||||||
@@ -446,7 +463,7 @@ export function LightboxDetails({
|
|||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
|
className={`w-full inline-flex items-center justify-center gap-2 px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors border border-ui-panel-border ${
|
||||||
mediaUrl
|
mediaUrl
|
||||||
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
|
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
|
||||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
|
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
|
||||||
@@ -462,7 +479,7 @@ export function LightboxDetails({
|
|||||||
{onMakeVideo && (
|
{onMakeVideo && (
|
||||||
<Button
|
<Button
|
||||||
icon={VideoIcon}
|
icon={VideoIcon}
|
||||||
className="w-full shadow-lg shadow-brand-primary/20"
|
className="w-full"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onMakeVideo}
|
onClick={onMakeVideo}
|
||||||
>
|
>
|
||||||
@@ -512,7 +529,7 @@ export function LightboxDetails({
|
|||||||
{showDownloadAppCta && (
|
{showDownloadAppCta && (
|
||||||
<Button
|
<Button
|
||||||
icon={ArrowDownToLineIcon}
|
icon={ArrowDownToLineIcon}
|
||||||
className="w-full shadow-lg shadow-brand-primary/20"
|
className="w-full"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = "/download";
|
window.location.href = "/download";
|
||||||
|
|||||||
@@ -357,13 +357,13 @@ export function Lightbox({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="rounded-xl h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/5 shadow-2xl"
|
className="h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/15"
|
||||||
backdropClassName="!bg-black/80"
|
backdropClassName="!bg-black/80"
|
||||||
showClose={false}
|
showClose={false}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row h-full">
|
<div className="flex flex-col sm:flex-row h-full">
|
||||||
{/* Media preview panel */}
|
{/* Media preview panel */}
|
||||||
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden rounded-t-xl sm:rounded-l-xl sm:rounded-tr-none bg-black">
|
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden bg-black">
|
||||||
{!selectedImageUrl ? (
|
{!selectedImageUrl ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<span className="text-base-fg/60">Media not available</span>
|
<span className="text-base-fg/60">Media not available</span>
|
||||||
@@ -419,8 +419,8 @@ export function Lightbox({
|
|||||||
</video>
|
</video>
|
||||||
) : isAudio ? (
|
) : isAudio ? (
|
||||||
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8">
|
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8">
|
||||||
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] px-4 py-10 sm:px-6">
|
<div className="w-full max-w-xl border border-white/15 bg-ui-controls px-4 py-10 sm:px-6">
|
||||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
|
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center border border-white/15 bg-white/10">
|
||||||
<MusicIcon
|
<MusicIcon
|
||||||
|
|
||||||
className="text-2xl text-white/70" />
|
className="text-2xl text-white/70" />
|
||||||
@@ -485,9 +485,9 @@ export function Lightbox({
|
|||||||
key={`${url}-thumb-${idx}`}
|
key={`${url}-thumb-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onThumbClick(idx)}
|
onClick={() => onThumbClick(idx)}
|
||||||
className={`relative h-16 w-16 flex-[0_0_4rem] overflow-hidden rounded-md border-2 transition-all ${
|
className={`relative h-16 w-16 flex-[0_0_4rem] overflow-hidden border transition-all ${
|
||||||
idx === selectedIndex
|
idx === selectedIndex
|
||||||
? "border-primary-400 opacity-100"
|
? "border-white opacity-100"
|
||||||
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100"
|
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -518,7 +518,7 @@ export function Lightbox({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onNavigatePrev();
|
onNavigatePrev();
|
||||||
}}
|
}}
|
||||||
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
|
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
|
||||||
aria-label="Previous item"
|
aria-label="Previous item"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="text-lg" />
|
<ChevronLeftIcon className="text-lg" />
|
||||||
@@ -530,7 +530,7 @@ export function Lightbox({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onNavigateNext();
|
onNavigateNext();
|
||||||
}}
|
}}
|
||||||
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
|
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
|
||||||
aria-label="Next item"
|
aria-label="Next item"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="text-lg" />
|
<ChevronRightIcon className="text-lg" />
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { Modal } from "@storyteller/ui-modal";
|
import { Modal } from "@storyteller/ui-modal";
|
||||||
import { ArrowLeftIcon, EyeIcon, LoaderCircleIcon, PenIcon, PlusIcon, Trash2Icon, UploadIcon, UsersIcon, XIcon } from "lucide-react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import {
|
import {
|
||||||
CharactersApi,
|
ArrowLeftIcon,
|
||||||
Character,
|
EyeIcon,
|
||||||
MediaUploadApi,
|
LoaderCircleIcon,
|
||||||
} from "@storyteller/api";
|
PenIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UploadIcon,
|
||||||
|
UsersIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { CharactersApi, Character, MediaUploadApi } from "@storyteller/api";
|
||||||
import { toast } from "../toast/toast";
|
import { toast } from "../toast/toast";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { useCharactersStore, DeckPreviewModal } from "@storyteller/ui-promptbox";
|
import {
|
||||||
|
useCharactersStore,
|
||||||
|
DeckPreviewModal,
|
||||||
|
} from "@storyteller/ui-promptbox";
|
||||||
import { useIsMobile } from "../ui/use-mobile";
|
import { useIsMobile } from "../ui/use-mobile";
|
||||||
import { SettingsDrawer } from "./mobile/SettingsDrawer";
|
import { SettingsDrawer } from "./mobile/SettingsDrawer";
|
||||||
|
|
||||||
@@ -324,7 +333,7 @@ const CharacterListView = ({
|
|||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
|
className="flex flex-col overflow-hidden border border-transparent bg-white/[0.05]"
|
||||||
>
|
>
|
||||||
<div className="aspect-square w-full overflow-hidden">
|
<div className="aspect-square w-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -336,7 +345,7 @@ const CharacterListView = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
|
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-2/3 rounded bg-white/[0.08]"
|
className="h-3 w-2/3 bg-white/[0.08]"
|
||||||
style={{
|
style={{
|
||||||
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
|
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
|
||||||
}}
|
}}
|
||||||
@@ -356,10 +365,10 @@ const CharacterListView = ({
|
|||||||
{/* Create New card */}
|
{/* Create New card */}
|
||||||
<button
|
<button
|
||||||
onClick={onCreateClick}
|
onClick={onCreateClick}
|
||||||
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-white/[0.05] text-white/60 transition-colors hover:border-white/25 hover:text-white/80"
|
className="flex flex-col items-center justify-center gap-2 overflow-hidden border-2 border-dashed border-white/15 bg-white/[0.05] text-white/60 transition-colors hover:border-white/30 hover:text-white/80"
|
||||||
>
|
>
|
||||||
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
|
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
|
||||||
<PlusIcon className="text-lg" />
|
<PlusIcon className="text-lg" />
|
||||||
<span className="text-sm font-medium">Create New</span>
|
<span className="text-sm font-medium">Create New</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -368,7 +377,7 @@ const CharacterListView = ({
|
|||||||
{pendingCharacters.map((pending) => (
|
{pendingCharacters.map((pending) => (
|
||||||
<div
|
<div
|
||||||
key={`pending-${pending.name}`}
|
key={`pending-${pending.name}`}
|
||||||
className="relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
|
className="relative flex flex-col overflow-hidden border border-transparent bg-white/[0.05]"
|
||||||
>
|
>
|
||||||
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
|
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
|
||||||
{pending.previewUrl ? (
|
{pending.previewUrl ? (
|
||||||
@@ -379,13 +388,11 @@ const CharacterListView = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-white/20">
|
<div className="flex h-full w-full items-center justify-center text-white/20">
|
||||||
<UsersIcon className="text-2xl" />
|
<UsersIcon className="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="text-lg text-white/80 animate-spin" />
|
||||||
|
|
||||||
className="text-lg text-white/80 animate-spin" />
|
|
||||||
<span className="text-xs font-medium text-white/80">
|
<span className="text-xs font-medium text-white/80">
|
||||||
Creating...
|
Creating...
|
||||||
</span>
|
</span>
|
||||||
@@ -405,7 +412,7 @@ const CharacterListView = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={character.token}
|
key={character.token}
|
||||||
className="group relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05] transition-colors hover:border-white/25 hover:bg-white/10"
|
className="group relative flex flex-col overflow-hidden border border-transparent bg-white/[0.05] transition-colors hover:border-white/30 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectCharacter?.(character)}
|
onClick={() => onSelectCharacter?.(character)}
|
||||||
@@ -421,9 +428,7 @@ const CharacterListView = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-white/20">
|
<div className="flex h-full w-full items-center justify-center text-white/20">
|
||||||
<UsersIcon
|
<UsersIcon className="text-2xl" />
|
||||||
|
|
||||||
className="text-2xl" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -445,9 +450,9 @@ const CharacterListView = ({
|
|||||||
setPreviewCharacter(character);
|
setPreviewCharacter(character);
|
||||||
}}
|
}}
|
||||||
title="View full size"
|
title="View full size"
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
|
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-black/80"
|
||||||
>
|
>
|
||||||
<EyeIcon className="text-[10px]" />
|
<EyeIcon className="text-[10px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isUserCreated && (
|
{isUserCreated && (
|
||||||
@@ -457,22 +462,18 @@ const CharacterListView = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEditCharacter(character);
|
onEditCharacter(character);
|
||||||
}}
|
}}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
|
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-black/80"
|
||||||
>
|
>
|
||||||
<PenIcon
|
<PenIcon className="text-[10px]" />
|
||||||
|
|
||||||
className="text-[10px]" />
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setConfirmDelete(character);
|
setConfirmDelete(character);
|
||||||
}}
|
}}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
|
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-red-500"
|
||||||
>
|
>
|
||||||
<Trash2Icon
|
<Trash2Icon className="text-[10px]" />
|
||||||
|
|
||||||
className="text-[10px]" />
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -486,28 +487,25 @@ const CharacterListView = ({
|
|||||||
|
|
||||||
{/* Delete confirmation overlay */}
|
{/* Delete confirmation overlay */}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60">
|
||||||
<div className="mx-4 w-full max-w-sm rounded-xl border border-white/10 bg-[#1e1e22] p-6 shadow-2xl">
|
<div className="mx-4 w-full max-w-sm border border-white/15 bg-ui-modal p-6">
|
||||||
<h3 className="mb-2 text-lg font-semibold text-white">
|
<h3 className="mb-2 text-lg font-semibold text-white">
|
||||||
Delete character?
|
Delete character?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-5 text-sm text-white/70">
|
<p className="mb-5 text-sm text-white/70">
|
||||||
This will permanently delete{" "}
|
This will permanently delete <strong>{confirmDelete.name}</strong>
|
||||||
<strong>{confirmDelete.name}</strong>. This action cannot be
|
. This action cannot be undone.
|
||||||
undone.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="border-none"
|
|
||||||
onClick={() => setConfirmDelete(null)}
|
onClick={() => setConfirmDelete(null)}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="destructive"
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => handleDelete(confirmDelete)}
|
onClick={() => handleDelete(confirmDelete)}
|
||||||
loading={isDeleting}
|
loading={isDeleting}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
@@ -608,7 +606,7 @@ const EditCharacterView = ({
|
|||||||
|
|
||||||
{/* Avatar preview */}
|
{/* Avatar preview */}
|
||||||
{character.maybe_avatar?.cdn_url && (
|
{character.maybe_avatar?.cdn_url && (
|
||||||
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/[0.05]">
|
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden bg-white/[0.05]">
|
||||||
<img
|
<img
|
||||||
src={character.maybe_avatar.cdn_url}
|
src={character.maybe_avatar.cdn_url}
|
||||||
alt={character.name}
|
alt={character.name}
|
||||||
@@ -619,7 +617,10 @@ const EditCharacterView = ({
|
|||||||
|
|
||||||
{/* Name input */}
|
{/* Name input */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="edit-character-name" className="text-sm font-medium text-white/80">
|
<label
|
||||||
|
htmlFor="edit-character-name"
|
||||||
|
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
|
||||||
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -628,14 +629,18 @@ const EditCharacterView = ({
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Character name"
|
placeholder="Character name"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
className="w-full border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description input */}
|
{/* Description input */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="edit-character-description" className="text-sm font-medium text-white/80">
|
<label
|
||||||
Description <span className="font-normal text-white/40">(optional)</span>
|
htmlFor="edit-character-description"
|
||||||
|
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
|
||||||
|
>
|
||||||
|
Description{" "}
|
||||||
|
<span className="font-normal text-white/40">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-character-description"
|
id="edit-character-description"
|
||||||
@@ -644,14 +649,14 @@ const EditCharacterView = ({
|
|||||||
placeholder="Description..."
|
placeholder="Description..."
|
||||||
rows={3}
|
rows={3}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
className="w-full resize-none border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
<Button variant="secondary" onClick={onBack}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -859,8 +864,8 @@ const NewCharacterView = ({
|
|||||||
<div
|
<div
|
||||||
ref={dropZoneRef}
|
ref={dropZoneRef}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/20 bg-white/[0.05] transition-colors overflow-hidden",
|
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[3px] border-2 border-dashed border-white/15 bg-white/[0.05] transition-colors overflow-hidden",
|
||||||
isDragging && "border-blue-400 bg-blue-500/10",
|
isDragging && "border-white/60 bg-white/10",
|
||||||
)}
|
)}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -880,9 +885,7 @@ const NewCharacterView = ({
|
|||||||
/>
|
/>
|
||||||
{!images[0]!.mediaToken && (
|
{!images[0]!.mediaToken && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="text-white animate-spin" />
|
||||||
|
|
||||||
className="text-white animate-spin" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -890,16 +893,14 @@ const NewCharacterView = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeImage(0);
|
removeImage(0);
|
||||||
}}
|
}}
|
||||||
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
|
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
|
||||||
>
|
>
|
||||||
<XIcon className="text-sm" />
|
<XIcon className="text-sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
|
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
|
||||||
<UploadIcon
|
<UploadIcon className="mb-2 text-xl text-white/40" />
|
||||||
|
|
||||||
className="mb-2 text-xl text-white/40" />
|
|
||||||
<p className="text-sm">Upload reference image</p>
|
<p className="text-sm">Upload reference image</p>
|
||||||
<p className="mb-3 text-xs text-white/40">
|
<p className="mb-3 text-xs text-white/40">
|
||||||
Click or drag an image here
|
Click or drag an image here
|
||||||
@@ -918,7 +919,10 @@ const NewCharacterView = ({
|
|||||||
|
|
||||||
{/* Name input */}
|
{/* Name input */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="character-name" className="text-sm font-medium text-white/80">
|
<label
|
||||||
|
htmlFor="character-name"
|
||||||
|
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
|
||||||
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -927,14 +931,18 @@ const NewCharacterView = ({
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Character name"
|
placeholder="Character name"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
className="w-full border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description input */}
|
{/* Description input */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="character-description" className="text-sm font-medium text-white/80">
|
<label
|
||||||
Description <span className="font-normal text-white/40">(optional)</span>
|
htmlFor="character-description"
|
||||||
|
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
|
||||||
|
>
|
||||||
|
Description{" "}
|
||||||
|
<span className="font-normal text-white/40">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="character-description"
|
id="character-description"
|
||||||
@@ -943,14 +951,14 @@ const NewCharacterView = ({
|
|||||||
placeholder="Description..."
|
placeholder="Description..."
|
||||||
rows={3}
|
rows={3}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
|
className="w-full resize-none border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
<Button variant="secondary" onClick={onBack}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type SyntheticEvent,
|
type SyntheticEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ImageIcon, ImagesIcon, LoaderCircleIcon, PlusIcon, XIcon } from "lucide-react";
|
import {
|
||||||
|
ImageIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||||
import { Modal } from "@storyteller/ui-modal";
|
import { Modal } from "@storyteller/ui-modal";
|
||||||
@@ -234,10 +240,7 @@ export const ImagePromptRow = ({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={twMerge(
|
className={twMerge("glass flex flex-col sm:flex-row", className)}
|
||||||
"glass flex flex-col sm:flex-row rounded-2xl sm:rounded-t-2xl sm:rounded-b-none",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onMouseDown={stopIfInside}
|
onMouseDown={stopIfInside}
|
||||||
onClick={stopIfInside}
|
onClick={stopIfInside}
|
||||||
onPointerDown={stopIfInside}
|
onPointerDown={stopIfInside}
|
||||||
@@ -245,8 +248,8 @@ export const ImagePromptRow = ({
|
|||||||
<div className="flex min-w-0 flex-1 gap-2 px-3 py-2">
|
<div className="flex min-w-0 flex-1 gap-2 px-3 py-2">
|
||||||
<div className="flex grow flex-col gap-1 min-w-32">
|
<div className="flex grow flex-col gap-1 min-w-32">
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<ImageIcon className="h-3.5 w-3.5" />
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
{sectionLabel}
|
{sectionLabel}
|
||||||
{showCount && (
|
{showCount && (
|
||||||
<span className="font-semibold text-white/60">
|
<span className="font-semibold text-white/60">
|
||||||
@@ -322,10 +325,9 @@ export const ImagePromptRow = ({
|
|||||||
<div className="hidden sm:block w-[1px] bg-white/10" />
|
<div className="hidden sm:block w-[1px] bg-white/10" />
|
||||||
<div className="flex grow flex-col gap-1 sm:p-2">
|
<div className="flex grow flex-col gap-1 sm:p-2">
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<ImageIcon className="h-3.5 w-3.5" />
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
End Frame{" "}
|
End Frame <span className="text-white/60">(optional)</span>
|
||||||
<span className="text-xs text-white/60">(optional)</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[13px] text-white/60">
|
<span className="text-[13px] text-white/60">
|
||||||
@@ -336,7 +338,7 @@ export const ImagePromptRow = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{endFrameImage ? (
|
{endFrameImage ? (
|
||||||
<div
|
<div
|
||||||
className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
|
className="group relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all cursor-pointer hover:border-white/40"
|
||||||
onClick={() => setPreviewImage(endFrameImage)}
|
onClick={() => setPreviewImage(endFrameImage)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -349,17 +351,14 @@ export const ImagePromptRow = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEndFrameImage?.(undefined);
|
setEndFrameImage?.(undefined);
|
||||||
}}
|
}}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : uploadingEndFrame ? (
|
) : uploadingEndFrame ? (
|
||||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
|
||||||
|
|
||||||
spin
|
|
||||||
className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AddButton
|
<AddButton
|
||||||
@@ -385,7 +384,7 @@ export const ImagePromptRow = ({
|
|||||||
<img
|
<img
|
||||||
src={previewImage.fullUrl || previewImage.url}
|
src={previewImage.fullUrl || previewImage.url}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
className="max-w-[90vw] max-h-[90vh] object-contain drop-shadow-2xl rounded-lg"
|
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -397,7 +396,7 @@ export const ImagePromptRow = ({
|
|||||||
// ── Sub-components ───────────────────────────────────────────────────────
|
// ── Sub-components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ADD_BUTTON_CLASS =
|
const ADD_BUTTON_CLASS =
|
||||||
"flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
|
"flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
|
||||||
|
|
||||||
// Upload / Pick-from-library affordance. On desktop it's a hover tooltip; on
|
// Upload / Pick-from-library affordance. On desktop it's a hover tooltip; on
|
||||||
// mobile that tooltip auto-opens from the emulated mouseenter fired on
|
// mobile that tooltip auto-opens from the emulated mouseenter fired on
|
||||||
@@ -414,9 +413,7 @@ export const AddButton = ({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const plus = (
|
const plus = <PlusIcon className="text-2xl text-white/80" />;
|
||||||
<PlusIcon className="text-2xl text-white/80" />
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!onPickFromLibrary) {
|
if (!onPickFromLibrary) {
|
||||||
return (
|
return (
|
||||||
@@ -460,7 +457,7 @@ export const AddButton = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="action"
|
variant="action"
|
||||||
icon={ImagesIcon}
|
icon={ImagesIcon}
|
||||||
className="w-full bg-white/15 hover:bg-white/20"
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
onPickFromLibrary();
|
onPickFromLibrary();
|
||||||
@@ -495,7 +492,7 @@ export const AddButton = ({
|
|||||||
variant="action"
|
variant="action"
|
||||||
onClick={onPickFromLibrary}
|
onClick={onPickFromLibrary}
|
||||||
icon={ImagesIcon}
|
icon={ImagesIcon}
|
||||||
className="w-full bg-white/15 hover:bg-white/20"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Pick from library
|
Pick from library
|
||||||
</Button>
|
</Button>
|
||||||
@@ -519,7 +516,7 @@ const ImageThumbnail = ({
|
|||||||
onPreview?: (image: RefImage) => void;
|
onPreview?: (image: RefImage) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
|
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all cursor-pointer hover:border-white/40"
|
||||||
onClick={() => onPreview?.(image)}
|
onClick={() => onPreview?.(image)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -532,9 +529,9 @@ const ImageThumbnail = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(image.id);
|
onRemove(image.id);
|
||||||
}}
|
}}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -572,11 +569,11 @@ const SortableImage = ({
|
|||||||
{...(allowReorder ? { ...attributes, ...listeners } : {})}
|
{...(allowReorder ? { ...attributes, ...listeners } : {})}
|
||||||
onClick={() => onPreview?.(image)}
|
onClick={() => onPreview?.(image)}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
|
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-opacity",
|
||||||
allowReorder
|
allowReorder
|
||||||
? "cursor-move hover:border-white/80"
|
? "cursor-move hover:border-white/40"
|
||||||
: "cursor-pointer hover:border-white/80",
|
: "cursor-pointer hover:border-white/40",
|
||||||
isDragging && "opacity-50 shadow-lg",
|
isDragging && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -591,9 +588,9 @@ const SortableImage = ({
|
|||||||
}}
|
}}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -603,7 +600,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
|
|||||||
const previewUrl = useMemo(() => URL.createObjectURL(file), [file]);
|
const previewUrl = useMemo(() => URL.createObjectURL(file), [file]);
|
||||||
useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]);
|
useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]);
|
||||||
return (
|
return (
|
||||||
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30">
|
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
@@ -612,10 +609,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon spin className="h-6 w-6 text-white" />
|
||||||
|
|
||||||
spin
|
|
||||||
className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { LoaderCircleIcon, MusicIcon, PlayIcon, SquareIcon, VideoIcon, XIcon } from "lucide-react";
|
import {
|
||||||
|
LoaderCircleIcon,
|
||||||
|
MusicIcon,
|
||||||
|
PlayIcon,
|
||||||
|
SquareIcon,
|
||||||
|
VideoIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { UploaderStates } from "@storyteller/common";
|
import { UploaderStates } from "@storyteller/common";
|
||||||
@@ -214,10 +221,7 @@ export const MediaReferenceRow = ({
|
|||||||
onChange={handleAudioUpload}
|
onChange={handleAudioUpload}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge("glass flex flex-col sm:flex-row", className)}
|
||||||
"glass flex flex-col sm:flex-row rounded-2xl",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@@ -227,8 +231,8 @@ export const MediaReferenceRow = ({
|
|||||||
<div className="flex grow gap-2 px-3 py-2">
|
<div className="flex grow gap-2 px-3 py-2">
|
||||||
<div className="flex grow flex-col gap-1">
|
<div className="flex grow flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<VideoIcon className="h-3.5 w-3.5" />
|
<VideoIcon className="h-3.5 w-3.5" />
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
Video Ref
|
Video Ref
|
||||||
<span className="font-semibold text-white/60">
|
<span className="font-semibold text-white/60">
|
||||||
({referenceVideos.length}/{maxVideoCount})
|
({referenceVideos.length}/{maxVideoCount})
|
||||||
@@ -248,11 +252,8 @@ export const MediaReferenceRow = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{uploadingVideo && (
|
{uploadingVideo && (
|
||||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon spin className="h-5 w-5 text-white/60" />
|
||||||
|
|
||||||
spin
|
|
||||||
className="h-5 w-5 text-white/60" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canAddVideo && (
|
{canAddVideo && (
|
||||||
@@ -275,8 +276,8 @@ export const MediaReferenceRow = ({
|
|||||||
<div className="flex grow gap-2 px-3 py-2">
|
<div className="flex grow gap-2 px-3 py-2">
|
||||||
<div className="flex grow flex-col gap-1">
|
<div className="flex grow flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<MusicIcon className="h-3.5 w-3.5" />
|
<MusicIcon className="h-3.5 w-3.5" />
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
Audio Ref
|
Audio Ref
|
||||||
<span className="font-semibold text-white/60">
|
<span className="font-semibold text-white/60">
|
||||||
({referenceAudios.length}/{maxAudioCount})
|
({referenceAudios.length}/{maxAudioCount})
|
||||||
@@ -297,11 +298,8 @@ export const MediaReferenceRow = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{uploadingAudio && (
|
{uploadingAudio && (
|
||||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
|
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon spin className="h-5 w-5 text-white/60" />
|
||||||
|
|
||||||
spin
|
|
||||||
className="h-5 w-5 text-white/60" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canAddAudio && (
|
{canAddAudio && (
|
||||||
@@ -328,7 +326,7 @@ const VideoRefTile = ({
|
|||||||
video: RefVideo;
|
video: RefVideo;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all hover:border-white/40">
|
||||||
<video
|
<video
|
||||||
src={video.url}
|
src={video.url}
|
||||||
muted
|
muted
|
||||||
@@ -343,9 +341,9 @@ const VideoRefTile = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(video.id);
|
onRemove(video.id);
|
||||||
}}
|
}}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -382,7 +380,7 @@ const AudioRefTile = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
|
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden border border-white/15 transition-all hover:border-white/40">
|
||||||
<button
|
<button
|
||||||
onClick={handleTogglePlay}
|
onClick={handleTogglePlay}
|
||||||
className="flex h-full w-full items-center justify-center"
|
className="flex h-full w-full items-center justify-center"
|
||||||
@@ -403,9 +401,9 @@ const AudioRefTile = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(audio.id);
|
onRemove(audio.id);
|
||||||
}}
|
}}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, MusicIcon, UsersIcon, VideoIcon } from "lucide-react";
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
MusicIcon,
|
||||||
|
UsersIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { GenerateIconButton } from "@storyteller/ui-button";
|
import { GenerateIconButton } from "@storyteller/ui-button";
|
||||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||||
@@ -231,8 +237,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
|||||||
// mode renders no video/audio deck, so those kinds only land in
|
// mode renders no video/audio deck, so those kinds only land in
|
||||||
// reference mode where the user can see (and remove) them.
|
// reference mode where the user can see (and remove) them.
|
||||||
const isKeyframeMode = !!isVideo && !isReferenceMode;
|
const isKeyframeMode = !!isVideo && !isReferenceMode;
|
||||||
const dropAcceptsImages =
|
const dropAcceptsImages = !!supportsImagePrompts && maxImagePromptCount > 0;
|
||||||
!!supportsImagePrompts && maxImagePromptCount > 0;
|
|
||||||
const dropAcceptsVideos =
|
const dropAcceptsVideos =
|
||||||
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
|
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
|
||||||
const dropAcceptsAudio =
|
const dropAcceptsAudio =
|
||||||
@@ -344,8 +349,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
|||||||
const deckAddActions: DeckAddAction[] = [];
|
const deckAddActions: DeckAddAction[] = [];
|
||||||
if (
|
if (
|
||||||
supportsImagePrompts &&
|
supportsImagePrompts &&
|
||||||
referenceImages.length + deck.uploadingImages.length <
|
referenceImages.length + deck.uploadingImages.length < maxImagePromptCount
|
||||||
maxImagePromptCount
|
|
||||||
) {
|
) {
|
||||||
deckAddActions.push({
|
deckAddActions.push({
|
||||||
key: "upload-image",
|
key: "upload-image",
|
||||||
@@ -769,8 +773,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"glass rounded-2xl p-3 sm:p-4 !transition-all duration-200",
|
"glass p-3 sm:p-4 !transition-all duration-200",
|
||||||
isFocused && "ring-1 ring-primary",
|
isFocused && "border-primary",
|
||||||
)}
|
)}
|
||||||
{...drop.dropZoneProps}
|
{...drop.dropZoneProps}
|
||||||
>
|
>
|
||||||
@@ -849,8 +853,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{mentionOpen && filteredMentionItems.length > 0 && (
|
{mentionOpen && filteredMentionItems.length > 0 && (
|
||||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-ui-panel-border bg-ui-controls shadow-lg backdrop-blur-xl">
|
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-[3px] border border-ui-panel-border bg-ui-controls">
|
||||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
<div className="px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
|
||||||
Mentions
|
Mentions
|
||||||
</div>
|
</div>
|
||||||
{filteredMentionItems.map((item, i) => (
|
{filteredMentionItems.map((item, i) => (
|
||||||
@@ -868,7 +872,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={() => setMentionIndex(i)}
|
onMouseEnter={() => setMentionIndex(i)}
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden border border-white/15 bg-black/20">
|
||||||
{(item.type === "image" ||
|
{(item.type === "image" ||
|
||||||
item.type === "character") &&
|
item.type === "character") &&
|
||||||
item.preview ? (
|
item.preview ? (
|
||||||
@@ -885,13 +889,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : item.type === "character" ? (
|
) : item.type === "character" ? (
|
||||||
<UsersIcon
|
<UsersIcon className="h-3.5 w-3.5 text-white/60" />
|
||||||
|
|
||||||
className="h-3.5 w-3.5 text-white/60" />
|
|
||||||
) : (
|
) : (
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={
|
icon={
|
||||||
item.type === "video" ? VideoIcon : MusicIcon
|
item.type === "video"
|
||||||
|
? VideoIcon
|
||||||
|
: MusicIcon
|
||||||
}
|
}
|
||||||
className="h-3.5 w-3.5 text-white/60"
|
className="h-3.5 w-3.5 text-white/60"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -245,15 +245,15 @@ export function PromptBoxDropOverlay({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
|
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 border-2 border-dashed bg-ui-controls/85",
|
||||||
rejected ? "border-red-400/80" : "border-primary",
|
rejected ? "border-red-400/80" : "border-white/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{kinds.map((kind, i) => (
|
{kinds.map((kind, i) => (
|
||||||
<div
|
<div
|
||||||
key={kind.label}
|
key={kind.label}
|
||||||
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
|
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center bg-white/10 text-base-fg/90"
|
||||||
style={{ animationDelay: `${i * 50}ms` }}
|
style={{ animationDelay: `${i * 50}ms` }}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={kind.icon} className="text-sm" />
|
<DynamicIcon icon={kind.icon} className="text-sm" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Maximize2Icon } from "lucide-react";
|
|||||||
// Webapp "focus mode" for the prompt editor. Mirrors the desktop lib's
|
// Webapp "focus mode" for the prompt editor. Mirrors the desktop lib's
|
||||||
// (@storyteller/ui-promptbox) fullscreen feature but lives here because the
|
// (@storyteller/ui-promptbox) fullscreen feature but lives here because the
|
||||||
// webapp has its own PromptBox. Reuses the shared @storyteller/ui-modal for a
|
// webapp has its own PromptBox. Reuses the shared @storyteller/ui-modal for a
|
||||||
// Trello-card-style centered panel + blurred backdrop. The overlay renders a
|
// Trello-card-style centered panel. The overlay renders a
|
||||||
// fresh editor bound to the SAME value + onChange, so text/mentions stay
|
// fresh editor bound to the SAME value + onChange, so text/mentions stay
|
||||||
// continuous across open/close.
|
// continuous across open/close.
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export const PromptFullscreenButton = ({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Expand prompt to focus mode"
|
aria-label="Expand prompt to focus mode"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
|
className="flex h-6 w-6 items-center justify-center bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
|
||||||
>
|
>
|
||||||
<Maximize2Icon
|
<Maximize2Icon
|
||||||
|
|
||||||
@@ -96,7 +96,6 @@ export const PromptFullscreenModal = ({
|
|||||||
closeOnOutsideClick
|
closeOnOutsideClick
|
||||||
closeOnEsc
|
closeOnEsc
|
||||||
className="w-full max-w-4xl"
|
className="w-full max-w-4xl"
|
||||||
backdropClassName="backdrop-blur-md"
|
|
||||||
>
|
>
|
||||||
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
|
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
|
||||||
is reliably bounded across the modal's nested wrappers — that's what
|
is reliably bounded across the modal's nested wrappers — that's what
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
|
|||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => !item.disabled && onSelect(item)}
|
onClick={() => !item.disabled && onSelect(item)}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors",
|
"flex w-full items-center gap-3 px-3 py-3 text-left transition-colors",
|
||||||
item.selected ? "bg-primary/15" : "hover:bg-white/5",
|
item.selected ? "bg-white/10" : "hover:bg-white/5",
|
||||||
item.disabled && "cursor-not-allowed opacity-50",
|
item.disabled && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -43,7 +43,7 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
|
|||||||
{item.selected && (
|
{item.selected && (
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
|
|
||||||
className="h-3.5 w-3.5 shrink-0 text-primary" />
|
className="h-3.5 w-3.5 shrink-0 text-white" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface DrawerSectionProps {
|
|||||||
export function DrawerSection({ label, children }: DrawerSectionProps) {
|
export function DrawerSection({ label, children }: DrawerSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<span className="px-3 text-[11px] font-semibold uppercase tracking-wider text-base-fg/45">
|
<span className="px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/45">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+5
-5
@@ -33,14 +33,14 @@ export function MobileCountStepper({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-11 shrink-0 items-center rounded-xl border border-ui-panel-border bg-ui-controls px-1">
|
<div className="flex h-11 shrink-0 items-center rounded-[3px] border border-ui-panel-border bg-ui-controls px-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => step(-1)}
|
onClick={() => step(-1)}
|
||||||
disabled={index <= 0}
|
disabled={index <= 0}
|
||||||
className="flex h-9 w-8 items-center justify-center rounded-lg text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
|
className="flex h-9 w-8 items-center justify-center text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<MinusIcon className="h-3 w-3" />
|
<MinusIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-10 text-center text-sm font-semibold tabular-nums text-base-fg">
|
<span className="min-w-10 text-center text-sm font-semibold tabular-nums text-base-fg">
|
||||||
{value}/{maxValue}
|
{value}/{maxValue}
|
||||||
@@ -49,9 +49,9 @@ export function MobileCountStepper({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => step(1)}
|
onClick={() => step(1)}
|
||||||
disabled={index === allowed.length - 1}
|
disabled={index === allowed.length - 1}
|
||||||
className="flex h-9 w-8 items-center justify-center rounded-lg text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
|
className="flex h-9 w-8 items-center justify-center text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ export function MobileFieldButton({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex w-full items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
|
"flex w-full items-center gap-3 border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
|
||||||
"hover:bg-ui-controls/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50",
|
"hover:bg-ui-controls/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function MobilePromptForm({
|
|||||||
{frames}
|
{frames}
|
||||||
{mediaRefs}
|
{mediaRefs}
|
||||||
|
|
||||||
<div className="rounded-xl border border-ui-panel-border bg-ui-controls p-3">
|
<div className="border border-ui-panel-border bg-ui-controls p-3">
|
||||||
<MentionTextarea
|
<MentionTextarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={onPromptChange}
|
onChange={onPromptChange}
|
||||||
@@ -100,7 +100,7 @@ export function MobilePromptForm({
|
|||||||
<div className="flex items-center gap-2 border-t border-ui-panel-border bg-ui-panel px-3 py-3">
|
<div className="flex items-center gap-2 border-t border-ui-panel-border bg-ui-panel px-3 py-3">
|
||||||
{countField}
|
{countField}
|
||||||
<GenerateButton
|
<GenerateButton
|
||||||
className="h-11 rounded-xl flex-1 items-center justify-center border-none bg-primary text-base text-white disabled:cursor-not-allowed disabled:opacity-50"
|
className="h-11 flex-1 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={!prompt.trim() || isSubmitting}
|
disabled={!prompt.trim() || isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
|
|||||||
@@ -64,13 +64,13 @@ export function SettingsDrawer({
|
|||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
modal={modal}
|
modal={modal}
|
||||||
open={open}
|
open={open}
|
||||||
className="ac-drawer-content max-h-[80vh] rounded-t-2xl bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
|
className="ac-drawer-content max-h-[80vh] bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
|
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
>
|
>
|
||||||
<XIcon className="text-sm" />
|
<XIcon className="text-sm" />
|
||||||
</button>
|
</button>
|
||||||
@@ -104,7 +104,7 @@ export function SettingsDrawer({
|
|||||||
onPointerUp={endDrag}
|
onPointerUp={endDrag}
|
||||||
onPointerCancel={endDrag}
|
onPointerCancel={endDrag}
|
||||||
>
|
>
|
||||||
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-white/20" />
|
<div className="mx-auto mt-2 h-1 w-10 shrink-0 bg-white/20" />
|
||||||
<SheetHeader className={hideHeader ? "sr-only" : "select-none pb-2 pr-12"}>
|
<SheetHeader className={hideHeader ? "sr-only" : "select-none pb-2 pr-12"}>
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
|||||||
maxLength={USERNAME_MAX}
|
maxLength={USERNAME_MAX}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
autoFocus
|
autoFocus
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -164,7 +164,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
|||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
placeholder="Confirm password"
|
placeholder="Confirm password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
{error && <FormError message={error} />}
|
{error && <FormError message={error} />}
|
||||||
<FormActions
|
<FormActions
|
||||||
@@ -262,7 +262,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
|||||||
maxLength={EMAIL_MAX}
|
maxLength={EMAIL_MAX}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoFocus
|
autoFocus
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -270,7 +270,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
|||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
placeholder="Confirm password"
|
placeholder="Confirm password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
{error && <FormError message={error} />}
|
{error && <FormError message={error} />}
|
||||||
<FormActions
|
<FormActions
|
||||||
@@ -388,7 +388,7 @@ function PasswordForm({
|
|||||||
placeholder="Current password"
|
placeholder="Current password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
autoFocus
|
autoFocus
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -400,7 +400,7 @@ function PasswordForm({
|
|||||||
minLength={PASSWORD_MIN}
|
minLength={PASSWORD_MIN}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
autoFocus={passwordNotSet}
|
autoFocus={passwordNotSet}
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -411,7 +411,7 @@ function PasswordForm({
|
|||||||
}
|
}
|
||||||
minLength={PASSWORD_MIN}
|
minLength={PASSWORD_MIN}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <FormError message={error} />}
|
{error && <FormError message={error} />}
|
||||||
@@ -450,7 +450,9 @@ function SectionShell({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-sm font-medium">{title}</p>
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
<p className="text-xs opacity-70">
|
<p className="text-xs opacity-70">
|
||||||
{isEditing ? editingDescription : collapsedDescription}
|
{isEditing ? editingDescription : collapsedDescription}
|
||||||
</p>
|
</p>
|
||||||
@@ -480,7 +482,7 @@ function SectionShell({
|
|||||||
function CurrentValue({ label, value }: { label: string; value: string }) {
|
function CurrentValue({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/40">
|
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-white truncate">{value}</span>
|
<span className="text-sm font-medium text-white truncate">{value}</span>
|
||||||
@@ -514,11 +516,7 @@ function FormActions({
|
|||||||
className="h-9 px-4"
|
className="h-9 px-4"
|
||||||
disabled={submitting || disabled}
|
disabled={submitting || disabled}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? <LoaderCircleIcon className="animate-spin" /> : "Save"}
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save"
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { Input } from "@storyteller/ui-input";
|
import { Input } from "@storyteller/ui-input";
|
||||||
import { Modal } from "@storyteller/ui-modal";
|
import { Modal } from "@storyteller/ui-modal";
|
||||||
import { CheckIcon, CopyIcon, KeyIcon, LoaderCircleIcon, TrashIcon } from "lucide-react";
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
KeyIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import {
|
import {
|
||||||
UserApiKeysApi,
|
UserApiKeysApi,
|
||||||
@@ -17,7 +23,7 @@ const DESCRIPTION_MAX = 512;
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const INPUT_CLASS =
|
const INPUT_CLASS =
|
||||||
"w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
|
"w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
|
||||||
|
|
||||||
interface ApiKeySectionProps {
|
interface ApiKeySectionProps {
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
@@ -97,7 +103,9 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-sm font-medium">API keys</p>
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
API keys
|
||||||
|
</p>
|
||||||
<p className="text-xs opacity-70">
|
<p className="text-xs opacity-70">
|
||||||
Create and manage keys to access the API programmatically.
|
Create and manage keys to access the API programmatically.
|
||||||
</p>
|
</p>
|
||||||
@@ -131,7 +139,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-6 text-center text-xs opacity-60">
|
<div className="py-6 text-center text-xs opacity-60">
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : keys.length === 0 ? (
|
) : keys.length === 0 ? (
|
||||||
<div className="py-6 text-center text-xs opacity-60">
|
<div className="py-6 text-center text-xs opacity-60">
|
||||||
@@ -169,7 +177,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
|
|||||||
onClick={() => load(false)}
|
onClick={() => load(false)}
|
||||||
>
|
>
|
||||||
{loadingMore ? (
|
{loadingMore ? (
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Load more"
|
"Load more"
|
||||||
)}
|
)}
|
||||||
@@ -279,16 +287,16 @@ function NewKeyReveal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
|
<div className="flex flex-col gap-2 border border-white/30 bg-white/[0.06] p-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<KeyIcon className="opacity-80" />
|
<KeyIcon className="opacity-80" />
|
||||||
<span>API key created</span>
|
<span>API key created</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs opacity-80">
|
<p className="text-xs opacity-80">
|
||||||
Copy your key now. For security, you won't be able to see it again.
|
Copy your key now. For security, you won't be able to see it again.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
|
<code className="min-w-0 flex-1 truncate border border-white/15 bg-ui-controls px-3 py-2 font-mono text-xs text-white">
|
||||||
{created.api_key}
|
{created.api_key}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
@@ -334,7 +342,7 @@ function ApiKeyRow({
|
|||||||
onRequestDelete: () => void;
|
onRequestDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.03] p-3 transition-colors hover:border-white/[0.14]">
|
<div className="flex flex-col gap-2 border border-white/15 bg-white/[0.03] p-3 transition-colors hover:border-white/30">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<p className="truncate text-sm font-medium">{item.name}</p>
|
<p className="truncate text-sm font-medium">{item.name}</p>
|
||||||
@@ -346,10 +354,10 @@ function ApiKeyRow({
|
|||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
{/* Truncated prefix, display-only — the full secret is only shown on
|
{/* Truncated prefix, display-only — the full secret is only shown on
|
||||||
create. The trailing ellipsis signals it's not the whole value. */}
|
create. The trailing ellipsis signals it's not the whole value. */}
|
||||||
<code className="min-w-0 truncate rounded bg-black/40 px-2 py-1 font-mono text-[11px] text-white/70">
|
<code className="min-w-0 truncate bg-ui-controls px-2 py-1 font-mono text-[11px] text-white/70">
|
||||||
{item.truncated_api_key}…
|
{item.truncated_api_key}…
|
||||||
</code>
|
</code>
|
||||||
<span className="text-[10px] uppercase tracking-wider text-white/40">
|
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-white/40">
|
||||||
{formatDate(item.created_at)}
|
{formatDate(item.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,13 +505,13 @@ function DeleteConfirmModal({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="destructive"
|
||||||
className="h-9 px-4 bg-red-500 hover:bg-red-600"
|
className="h-9 px-4"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
{deleting ? (
|
{deleting ? (
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Delete"
|
"Delete"
|
||||||
)}
|
)}
|
||||||
@@ -543,7 +551,7 @@ function FormActions({
|
|||||||
disabled={submitting || disabled}
|
disabled={submitting || disabled}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
submitLabel
|
submitLabel
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
|
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
|
||||||
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
|
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
|
||||||
<div className="hidden items-center gap-2.5 py-0.5 sm:flex">
|
<div className="hidden items-center gap-2.5 py-0.5 sm:flex">
|
||||||
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
|
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
|
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
|
||||||
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
|
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
|
||||||
@@ -53,8 +55,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"h-9 shrink-0 rounded-lg px-3 text-left transition-colors sm:w-full sm:px-2",
|
"h-9 shrink-0 px-3 text-left transition-colors sm:w-full sm:px-2",
|
||||||
tab === t.id ? "bg-[#63636B]/20" : "hover:bg-white/[0.04]",
|
tab === t.id
|
||||||
|
? "bg-white/10 text-white"
|
||||||
|
: "text-white/70 hover:bg-white/[0.06] hover:text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 whitespace-nowrap text-sm">
|
<div className="flex items-center gap-2.5 whitespace-nowrap text-sm">
|
||||||
@@ -68,7 +72,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto sm:col-span-8 sm:h-full">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto sm:col-span-8 sm:h-full">
|
||||||
<div className="w-full border-b border-ui-panel-border px-4 py-2.5 sm:px-0">
|
<div className="w-full border-b border-ui-panel-border px-4 py-2.5 sm:px-0">
|
||||||
<h2 className="text-[18px] font-semibold">{activeLabel}</h2>
|
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80">
|
||||||
|
{activeLabel}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
|
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
|
||||||
{tab === "general" && <GeneralPanel />}
|
{tab === "general" && <GeneralPanel />}
|
||||||
@@ -94,7 +100,9 @@ function GeneralPanel() {
|
|||||||
<div className="space-y-4 text-base-fg">
|
<div className="space-y-4 text-base-fg">
|
||||||
<div className="flex flex-col gap-2 pt-3">
|
<div className="flex flex-col gap-2 pt-3">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-sm font-medium">Enter to generate</p>
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
Enter to generate
|
||||||
|
</p>
|
||||||
<p className="text-xs opacity-70">
|
<p className="text-xs opacity-70">
|
||||||
When on, pressing Enter submits the prompt and Shift+Enter adds a
|
When on, pressing Enter submits the prompt and Shift+Enter adds a
|
||||||
new line. When off (default), both Enter and Shift+Enter add a new
|
new line. When off (default), both Enter and Shift+Enter add a new
|
||||||
@@ -109,7 +117,9 @@ function GeneralPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-sm font-medium">Play videos with sound</p>
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
Play videos with sound
|
||||||
|
</p>
|
||||||
<p className="text-xs opacity-70">
|
<p className="text-xs opacity-70">
|
||||||
When on (default), videos in the media viewer start unmuted so you
|
When on (default), videos in the media viewer start unmuted so you
|
||||||
don't have to click unmute every time. When off, they start muted.
|
don't have to click unmute every time. When off, they start muted.
|
||||||
@@ -123,7 +133,9 @@ function GeneralPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-sm font-medium">Group models by family</p>
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
Group models by family
|
||||||
|
</p>
|
||||||
<p className="text-xs opacity-70">
|
<p className="text-xs opacity-70">
|
||||||
When on (default), the model picker groups models into submenus by
|
When on (default), the model picker groups models into submenus by
|
||||||
family, like Seedance or Veo. When off, every model shows in one
|
family, like Seedance or Veo. When off, every model shows in one
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
import { BoxIcon, CircleHelpIcon, DownloadIcon, FilmIcon, GiftIcon, GlobeIcon, GraduationCapIcon, GroupIcon, HouseIcon, ImageIcon, ImagesIcon, MusicIcon, NewspaperIcon, PencilIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
|
import {
|
||||||
|
BoxIcon,
|
||||||
|
CircleHelpIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FilmIcon,
|
||||||
|
GiftIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
GraduationCapIcon,
|
||||||
|
GroupIcon,
|
||||||
|
HouseIcon,
|
||||||
|
ImageIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
MusicIcon,
|
||||||
|
NewspaperIcon,
|
||||||
|
PencilIcon,
|
||||||
|
VideoIcon,
|
||||||
|
WandSparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
|
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
@@ -33,7 +50,9 @@ type NavItem = {
|
|||||||
badge?: string;
|
badge?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: HouseIcon }];
|
const PRIMARY_ITEMS: NavItem[] = [
|
||||||
|
{ label: "Home", href: "/", icon: HouseIcon },
|
||||||
|
];
|
||||||
|
|
||||||
// Generation entries — make something from a prompt.
|
// Generation entries — make something from a prompt.
|
||||||
const CREATE_ITEMS_STATIC: NavItem[] = [
|
const CREATE_ITEMS_STATIC: NavItem[] = [
|
||||||
@@ -150,13 +169,15 @@ function NavMenuItem({
|
|||||||
interactive, kept subtle to stay within the "restrained chrome" lane. */}
|
interactive, kept subtle to stay within the "restrained chrome" lane. */}
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
className="transition-transform duration-200 ease-out group-hover/menu-item:scale-110"
|
className="transition-colors duration-150"
|
||||||
/>
|
/>
|
||||||
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
|
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span
|
<span
|
||||||
className={`ml-auto px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full ${
|
className={`ml-auto border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none group-data-[collapsible=icon]:hidden ${
|
||||||
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
|
item.badge === "NEW"
|
||||||
|
? "border-purple-400/40 text-purple-300"
|
||||||
|
: "border-amber-400/40 text-amber-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.badge}
|
{item.badge}
|
||||||
@@ -173,7 +194,7 @@ function NavMenuItem({
|
|||||||
{active && (
|
{active && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="sidebar-active-indicator"
|
layoutId="sidebar-active-indicator"
|
||||||
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-1 rounded-full bg-primary"
|
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-0.5 bg-white"
|
||||||
transition={
|
transition={
|
||||||
reduceMotion
|
reduceMotion
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
@@ -259,7 +280,7 @@ export function AppSidebar() {
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/images/artcraft-logo.png"
|
src="/images/artcraft-logo-2.png"
|
||||||
alt="ArtCraft"
|
alt="ArtCraft"
|
||||||
className="h-5 w-auto shrink-0"
|
className="h-5 w-auto shrink-0"
|
||||||
/>
|
/>
|
||||||
@@ -318,7 +339,7 @@ export function AppSidebar() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(DOWNLOAD_URL, "_blank", "noopener,noreferrer")
|
window.open(DOWNLOAD_URL, "_blank", "noopener,noreferrer")
|
||||||
}
|
}
|
||||||
className="w-full justify-center h-9 text-sm font-semibold rounded-full"
|
className="w-full justify-center h-9"
|
||||||
>
|
>
|
||||||
Download ArtCraft
|
Download ArtCraft
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function LibraryFoldersNav({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => openNewFolderModal(null)}
|
onClick={() => openNewFolderModal(null)}
|
||||||
aria-label="New folder"
|
aria-label="New folder"
|
||||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
className="mr-1 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
||||||
>
|
>
|
||||||
<PlusIcon className="text-xs" />
|
<PlusIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
@@ -125,7 +125,7 @@ export function LibraryFoldersNav({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
aria-label={expanded ? "Collapse folders" : "Expand folders"}
|
aria-label={expanded ? "Collapse folders" : "Expand folders"}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function LibraryTagsNav({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
aria-label={expanded ? "Collapse tags" : "Expand tags"}
|
aria-label={expanded ? "Collapse tags" : "Expand tags"}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export function LibraryTagsNav({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
aria-label={`Options for tag "${tag.value}"`}
|
aria-label={`Options for tag "${tag.value}"`}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||||
>
|
>
|
||||||
<EllipsisIcon className="text-xs" />
|
<EllipsisIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ export function MobileBottomNav() {
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors active:scale-95",
|
"flex flex-1 flex-col items-center justify-center gap-1 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors",
|
||||||
active
|
active ? "text-white" : "text-base-fg/55 hover:text-base-fg/80",
|
||||||
? "text-primary"
|
|
||||||
: "text-base-fg/55 hover:text-base-fg/80",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={item.icon} className="h-5 w-5" />
|
<DynamicIcon icon={item.icon} className="h-5 w-5" />
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function SignupCtaModal() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/[5%] bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
|
className="w-full max-w-md overflow-hidden border border-white/15 bg-ui-modal p-0"
|
||||||
allowBackgroundInteraction={false}
|
allowBackgroundInteraction={false}
|
||||||
showClose={true}
|
showClose={true}
|
||||||
closeOnOutsideClick={true}
|
closeOnOutsideClick={true}
|
||||||
@@ -81,16 +81,6 @@ export function SignupCtaModal() {
|
|||||||
backdropClassName="bg-black/80"
|
backdropClassName="bg-black/80"
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<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-12 sm:pb-10">
|
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
|
||||||
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||||
Start <span className="text-primary">crafting</span> in seconds.
|
Start <span className="text-primary">crafting</span> in seconds.
|
||||||
@@ -105,10 +95,8 @@ export function SignupCtaModal() {
|
|||||||
key={perk}
|
key={perk}
|
||||||
className="flex items-start gap-3 text-[14px] text-white/75"
|
className="flex items-start gap-3 text-[14px] text-white/75"
|
||||||
>
|
>
|
||||||
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
|
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
|
||||||
<CheckIcon
|
<CheckIcon className="text-[9px] text-white" />
|
||||||
|
|
||||||
className="text-[9px] text-primary" />
|
|
||||||
</span>
|
</span>
|
||||||
<span>{perk}</span>
|
<span>{perk}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -117,16 +105,13 @@ export function SignupCtaModal() {
|
|||||||
|
|
||||||
<div className="mt-8 flex flex-col gap-3">
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
|
<div className="border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to={`/signup?from=${from}`} onClick={close} className="block">
|
<Link to={`/signup?from=${from}`} onClick={close} className="block">
|
||||||
<Button
|
<Button variant="primary" className="w-full h-10">
|
||||||
variant="primary"
|
|
||||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
|
||||||
>
|
|
||||||
Create account with email
|
Create account with email
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -135,7 +120,7 @@ export function SignupCtaModal() {
|
|||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-white/10" />
|
<div className="w-full border-t border-white/10" />
|
||||||
</div>
|
</div>
|
||||||
<span className="relative bg-[#161618] px-4 text-xs uppercase tracking-widest text-white/40">
|
<span className="relative bg-ui-modal px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
|
||||||
or
|
or
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ export const ToastContainer = () => {
|
|||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className="glass flex max-w-full items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-white animate-toast-in sm:max-w-md"
|
className="glass flex max-w-full items-center gap-3 rounded-[3px] px-4 py-2.5 text-sm text-white animate-toast-in sm:max-w-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full ${
|
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center ${
|
||||||
toast.type === "success" ? "bg-green-500" : "bg-red-500"
|
toast.type === "success" ? "bg-green-500" : "bg-red-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -107,7 +107,7 @@ export const ToastContainer = () => {
|
|||||||
onClick={() => dismiss(toast.id)}
|
onClick={() => dismiss(toast.id)}
|
||||||
className="ml-1 text-white/40 transition-colors hover:text-white/80"
|
className="ml-1 text-white/40 transition-colors hover:text-white/80"
|
||||||
>
|
>
|
||||||
<XIcon className="h-3 w-3" />
|
<XIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function ActiveSceneTitle() {
|
|||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
title="Rename scene"
|
title="Rename scene"
|
||||||
aria-label="Rename scene"
|
aria-label="Rename scene"
|
||||||
className="flex min-w-0 max-w-[280px] items-center rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-all hover:cursor-text hover:bg-white/[0.08]"
|
className="flex min-w-0 max-w-[280px] items-center border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:cursor-text hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
<PencilIcon
|
<PencilIcon
|
||||||
@@ -129,7 +129,7 @@ export function ActiveSceneTitle() {
|
|||||||
className="ml-2 shrink-0 text-sm opacity-50" />
|
className="ml-2 shrink-0 text-sm opacity-50" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-[280px] truncate rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
|
<div className="max-w-[280px] truncate border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -157,8 +157,8 @@ export function ActiveSceneTitle() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"h-[34px] w-fit max-w-full rounded-lg border border-white/[0.12] bg-white/[0.04] px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-2 focus:outline-brand-primary",
|
"h-[34px] w-fit max-w-full border border-white/15 bg-ui-controls px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-1 focus:outline-white/60",
|
||||||
isSubmitting && "outline outline-2 outline-brand-secondary",
|
isSubmitting && "outline outline-1 outline-white/30",
|
||||||
!isValid && "border-danger focus:outline-danger focus:outline-2",
|
!isValid && "border-danger focus:outline-danger focus:outline-2",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ import {
|
|||||||
Transition,
|
Transition,
|
||||||
} from "@headlessui/react";
|
} from "@headlessui/react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { CoinsIcon, GemIcon, GiftIcon, ImagesIcon, LifeBuoyIcon, SettingsIcon } from "lucide-react";
|
import {
|
||||||
|
CoinsIcon,
|
||||||
|
GemIcon,
|
||||||
|
GiftIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
LifeBuoyIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DiscordIcon } from "@storyteller/icons";
|
import { DiscordIcon } from "@storyteller/icons";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||||
@@ -99,9 +106,9 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/pricing"
|
to="/pricing"
|
||||||
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
|
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<GemIcon className="text-[11px]" />
|
<GemIcon className="text-[11px]" />
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -118,7 +125,7 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
icon={GemIcon}
|
icon={GemIcon}
|
||||||
onClick={() => navigate("/pricing")}
|
onClick={() => navigate("/pricing")}
|
||||||
className="h-8 px-3 text-sm font-semibold rounded-lg"
|
className="h-8 px-3"
|
||||||
>
|
>
|
||||||
Upgrade
|
Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
@@ -128,14 +135,14 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/library"
|
to="/library"
|
||||||
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-sm font-medium text-white/80 transition-colors"
|
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-[3px] border border-white/15 hover:bg-white/10 hover:border-white/30 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 transition-colors"
|
||||||
>
|
>
|
||||||
<ImagesIcon className="text-[11px]" />
|
<ImagesIcon className="text-[11px]" />
|
||||||
My Library
|
My Library
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Menu as="div" className="relative ml-1">
|
<Menu as="div" className="relative ml-1">
|
||||||
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
|
<MenuButton className="flex h-8 w-8 overflow-hidden border border-white/15 focus:outline-none focus:ring-2 focus:ring-white/60 ring-offset-2 ring-offset-[#0b0b0c]">
|
||||||
<span className="sr-only">Open user menu</span>
|
<span className="sr-only">Open user menu</span>
|
||||||
<img
|
<img
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
@@ -154,9 +161,9 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
>
|
>
|
||||||
<MenuItems
|
<MenuItems
|
||||||
modal={false}
|
modal={false}
|
||||||
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
|
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-[3px] bg-[#101014] border border-white/15 focus:outline-none overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-4 py-3 border-b border-white/[0.06]">
|
<div className="px-4 py-3 border-b border-white/15">
|
||||||
<p className="text-sm font-medium text-white truncate">
|
<p className="text-sm font-medium text-white truncate">
|
||||||
{user.display_name || user.username}
|
{user.display_name || user.username}
|
||||||
</p>
|
</p>
|
||||||
@@ -166,13 +173,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
active ? "bg-white/[0.04]" : "",
|
active ? "bg-white/10" : "",
|
||||||
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SettingsIcon
|
<SettingsIcon className="text-[11px] text-white/50" />
|
||||||
|
|
||||||
className="text-[11px] text-white/50" />
|
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -182,13 +187,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate("/support")}
|
onClick={() => navigate("/support")}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
active ? "bg-white/[0.04]" : "",
|
active ? "bg-white/10" : "",
|
||||||
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LifeBuoyIcon
|
<LifeBuoyIcon className="text-[11px] text-white/50" />
|
||||||
|
|
||||||
className="text-[11px] text-white/50" />
|
|
||||||
Support
|
Support
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -201,13 +204,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate("/referrals")}
|
onClick={() => navigate("/referrals")}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
active ? "bg-white/[0.04]" : "",
|
active ? "bg-white/10" : "",
|
||||||
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GiftIcon
|
<GiftIcon className="text-[11px] text-white/50" />
|
||||||
|
|
||||||
className="text-[11px] text-white/50" />
|
|
||||||
Referrals
|
Referrals
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -220,13 +221,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
active ? "bg-white/[0.04]" : "",
|
active ? "bg-white/10" : "",
|
||||||
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DiscordIcon
|
<DiscordIcon className="text-[11px] text-white/50" />
|
||||||
|
|
||||||
className="text-[11px] text-white/50" />
|
|
||||||
Join Discord
|
Join Discord
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -252,20 +251,20 @@ export function TopBarActions({ className }: { className?: string }) {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/pricing"
|
to="/pricing"
|
||||||
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
|
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<GemIcon className="text-[11px]" />
|
<GemIcon className="text-[11px]" />
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="h-8 flex items-center px-3 rounded-lg text-sm font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
|
className="h-8 flex items-center px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/signup"
|
to="/signup"
|
||||||
className="h-8 flex items-center gap-1.5 px-3.5 rounded-full text-sm font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
|
className="h-8 flex items-center gap-1.5 px-3.5 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black bg-white hover:bg-white/80 transition-colors"
|
||||||
>
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
@@ -297,16 +296,14 @@ function CreditsChip({
|
|||||||
<PopoverMenu
|
<PopoverMenu
|
||||||
position="bottom"
|
position="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
triggerIcon={
|
triggerIcon={<CoinsIcon className="text-primary text-[11px]" />}
|
||||||
<CoinsIcon className="text-primary text-[11px]" />
|
|
||||||
}
|
|
||||||
triggerLabel={
|
triggerLabel={
|
||||||
<span className="whitespace-nowrap text-sm font-medium">
|
<span className="whitespace-nowrap text-sm font-medium">
|
||||||
{credits.toLocaleString()}
|
{credits.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
|
buttonClassName="h-8 px-3 ps-2.5 bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 shadow-none text-white/80 rounded-[3px] gap-1.5"
|
||||||
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
|
panelClassName="mt-2 bg-[#101014] border border-white/15 text-white rounded-[3px]"
|
||||||
>
|
>
|
||||||
{(close) => (
|
{(close) => (
|
||||||
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
|
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
|
||||||
@@ -325,7 +322,7 @@ function CreditsChip({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
|
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
|
||||||
<CoinsIcon className="text-xl text-primary" />
|
<CoinsIcon className="text-xl text-primary" />
|
||||||
{credits.toLocaleString()}
|
{credits.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function Breadcrumbs() {
|
|||||||
if (crumbs.length === 0) return null;
|
if (crumbs.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="hidden md:flex items-center gap-1.5 text-sm text-white/45 min-w-0">
|
<nav className="hidden md:flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/45 min-w-0">
|
||||||
{crumbs.map((crumb, i) => {
|
{crumbs.map((crumb, i) => {
|
||||||
const isLast = i === crumbs.length - 1;
|
const isLast = i === crumbs.length - 1;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||||
import { BombIcon, BrushIcon, CheckIcon, CircleAlertIcon, CopyIcon, ListChecksIcon, LoaderCircleIcon, RotateCwIcon, Trash2Icon, XIcon } from "lucide-react";
|
import {
|
||||||
|
BombIcon,
|
||||||
|
BrushIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CircleAlertIcon,
|
||||||
|
CopyIcon,
|
||||||
|
ListChecksIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
RotateCwIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { Modal } from "@storyteller/ui-modal";
|
import { Modal } from "@storyteller/ui-modal";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -180,7 +191,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
|
|||||||
delay={300}
|
delay={300}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
|
||||||
aria-label="Copy prompt"
|
aria-label="Copy prompt"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -220,7 +231,7 @@ const RecreateTaskButton = ({
|
|||||||
delay={300}
|
delay={300}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls disabled:opacity-60"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls disabled:opacity-60"
|
||||||
aria-label="Recreate"
|
aria-label="Recreate"
|
||||||
disabled={isRecreating}
|
disabled={isRecreating}
|
||||||
onClick={handleRecreate}
|
onClick={handleRecreate}
|
||||||
@@ -255,13 +266,13 @@ const InProgressCard = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
|
<div className="p-2 transition-colors hover:bg-ui-controls/40">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
|
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-ui-controls">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon
|
||||||
|
|
||||||
className="animate-spin text-base-fg/60"
|
className="animate-spin text-base-fg/60"
|
||||||
size="lg" />
|
size="lg"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
@@ -285,9 +296,9 @@ const InProgressCard = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
<div className="h-1.5 min-w-0 flex-1 rounded bg-ui-controls">
|
<div className="h-1.5 min-w-0 flex-1 bg-ui-controls">
|
||||||
<div
|
<div
|
||||||
className="h-1.5 rounded bg-primary-400"
|
className="h-1.5 bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.max(0, Math.min(100, task.progress))}%`,
|
width: `${Math.max(0, Math.min(100, task.progress))}%`,
|
||||||
}}
|
}}
|
||||||
@@ -307,7 +318,7 @@ const InProgressCard = ({
|
|||||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||||
{onDismiss && (
|
{onDismiss && (
|
||||||
<button
|
<button
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -334,12 +345,12 @@ const CompletedCard = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-md p-2 transition-colors hover:bg-ui-controls/40"
|
className="flex cursor-pointer items-center gap-2.5 p-2 transition-colors hover:bg-ui-controls/40"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
role={onClick ? "button" : undefined}
|
role={onClick ? "button" : undefined}
|
||||||
tabIndex={onClick ? 0 : -1}
|
tabIndex={onClick ? 0 : -1}
|
||||||
>
|
>
|
||||||
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
|
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden bg-ui-controls">
|
||||||
{task.thumbnailUrl ? (
|
{task.thumbnailUrl ? (
|
||||||
<img
|
<img
|
||||||
src={task.thumbnailUrl}
|
src={task.thumbnailUrl}
|
||||||
@@ -379,7 +390,7 @@ const CompletedCard = ({
|
|||||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||||
{onDismiss && (
|
{onDismiss && (
|
||||||
<button
|
<button
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -403,13 +414,10 @@ const FailedCard = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
|
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
|
<div className="p-2 transition-colors hover:bg-ui-controls/40">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
|
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10">
|
||||||
<CircleAlertIcon
|
<CircleAlertIcon className="text-red-400" size="lg" />
|
||||||
|
|
||||||
className="text-red-400"
|
|
||||||
size="lg" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
@@ -423,7 +431,7 @@ const FailedCard = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
|
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||||
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
|
<span className="shrink-0 bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
{task.failureReason && (
|
{task.failureReason && (
|
||||||
@@ -471,7 +479,7 @@ const FailedCard = ({
|
|||||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||||
{onDismiss && (
|
{onDismiss && (
|
||||||
<button
|
<button
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1042,7 +1050,7 @@ export const TaskQueue = () => {
|
|||||||
<div>
|
<div>
|
||||||
{inProgress.length > 0 && (
|
{inProgress.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-1 px-1 text-xs uppercase tracking-wide text-base-fg/50">
|
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
|
||||||
In Progress
|
In Progress
|
||||||
</div>
|
</div>
|
||||||
{inProgress.map((t) => (
|
{inProgress.map((t) => (
|
||||||
@@ -1057,14 +1065,14 @@ export const TaskQueue = () => {
|
|||||||
{failed.length > 0 && (
|
{failed.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-1 flex items-center justify-between px-1">
|
<div className="mb-1 flex items-center justify-between px-1">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-red-400/70">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-red-400/70">
|
||||||
Failed
|
Failed
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-xs tracking-wide text-red-400/70 transition-colors hover:text-red-300"
|
className="text-xs tracking-wide text-red-400/70 transition-colors hover:text-red-300"
|
||||||
onClick={() => handleClearFailed()}
|
onClick={() => handleClearFailed()}
|
||||||
>
|
>
|
||||||
<XIcon className="mr-1" />
|
<XIcon className="mr-1" />
|
||||||
Clear failed
|
Clear failed
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1080,14 +1088,14 @@ export const TaskQueue = () => {
|
|||||||
{completed.length > 0 && (
|
{completed.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center justify-between px-1">
|
<div className="mb-1 flex items-center justify-between px-1">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-base-fg/50">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
|
||||||
Completed
|
Completed
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-xs tracking-wide text-base-fg/50 transition-colors hover:text-base-fg/100"
|
className="text-xs tracking-wide text-base-fg/50 transition-colors hover:text-base-fg/100"
|
||||||
onClick={() => handleClearCompleted()}
|
onClick={() => handleClearCompleted()}
|
||||||
>
|
>
|
||||||
<XIcon className="mr-1" />
|
<XIcon className="mr-1" />
|
||||||
Clear completed
|
Clear completed
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1122,23 +1130,21 @@ export const TaskQueue = () => {
|
|||||||
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
|
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
|
||||||
<div className="relative task-queue-trigger">
|
<div className="relative task-queue-trigger">
|
||||||
{badgeCount > 0 && (
|
{badgeCount > 0 && (
|
||||||
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary-400 px-1 text-[10px] font-semibold text-white ring-2 ring-[#121212]">
|
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center bg-white px-1 font-mono text-[10px] font-semibold text-black ring-2 ring-[#0b0b0c]">
|
||||||
{badgeCount}
|
{badgeCount}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PopoverMenu
|
<PopoverMenu
|
||||||
mode="default"
|
mode="default"
|
||||||
buttonClassName="h-8 w-8 !p-0 relative bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/80 rounded-lg shadow-none"
|
buttonClassName="h-8 w-8 !p-0 relative bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 text-white/80 rounded-[3px] shadow-none"
|
||||||
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#1a1a1a] border border-white/[0.08] mt-2 rounded-xl"
|
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#101014] border border-white/15 mt-2 rounded-[3px]"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
triggerIcon={
|
triggerIcon={
|
||||||
inProgressCount > 0 ? (
|
inProgressCount > 0 ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin text-[11px]" />
|
||||||
|
|
||||||
className="animate-spin text-[11px]" />
|
|
||||||
) : (
|
) : (
|
||||||
<ListChecksIcon className="text-[11px]" />
|
<ListChecksIcon className="text-[11px]" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
@@ -1175,36 +1181,36 @@ export const TaskQueue = () => {
|
|||||||
showClose={false}
|
showClose={false}
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="rounded-t-xl border-ui-panel-border bg-ui-panel">
|
<div className="border-b border-ui-panel-border bg-ui-panel">
|
||||||
<div className="flex items-center justify-between p-3">
|
<div className="flex items-center justify-between p-3">
|
||||||
<h2 className="text-lg font-semibold">Task Queue</h2>
|
<h2 className="text-lg font-semibold">Task Queue</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
|
className="flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
|
||||||
onClick={() => handleClearCompleted()}
|
onClick={() => handleClearCompleted()}
|
||||||
>
|
>
|
||||||
<BrushIcon className="mr-1.5" />
|
<BrushIcon className="mr-1.5" />
|
||||||
Clear completed
|
Clear completed
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
|
className="flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
|
||||||
onClick={() => handleClearStale()}
|
onClick={() => handleClearStale()}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="mr-1.5" />
|
<Trash2Icon className="mr-1.5" />
|
||||||
Clear stale
|
Clear stale
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
|
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
|
||||||
onClick={() => handleClearFailed()}
|
onClick={() => handleClearFailed()}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="mr-1.5" />
|
<Trash2Icon className="mr-1.5" />
|
||||||
Clear failed
|
Clear failed
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
|
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
|
||||||
onClick={() => handleRemoveAll()}
|
onClick={() => handleRemoveAll()}
|
||||||
>
|
>
|
||||||
<BombIcon className="mr-1.5" />
|
<BombIcon className="mr-1.5" />
|
||||||
Remove all
|
Remove all
|
||||||
</Button>
|
</Button>
|
||||||
<div className="mr-2 h-4 w-[1px] bg-base-fg/10" />
|
<div className="mr-2 h-4 w-[1px] bg-base-fg/10" />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function TopBar() {
|
|||||||
const showSelectToggle = showViewToggle;
|
const showSelectToggle = showViewToggle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
|
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/15 bg-ui-background px-3 pb-4 pt-3 sm:pt-6">
|
||||||
{/* Left: sidebar trigger (mobile only) + logo (when sidebar closed) + breadcrumbs */}
|
{/* Left: sidebar trigger (mobile only) + logo (when sidebar closed) + breadcrumbs */}
|
||||||
<div className="flex items-center gap-2 min-w-0 shrink-0">
|
<div className="flex items-center gap-2 min-w-0 shrink-0">
|
||||||
<SidebarTrigger className="md:hidden" />
|
<SidebarTrigger className="md:hidden" />
|
||||||
@@ -35,8 +35,8 @@ export function TopBar() {
|
|||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
isMobile
|
isMobile
|
||||||
? "/images/artcraft-icon.png"
|
? "/images/artcraft-icon-2.png"
|
||||||
: "/images/artcraft-logo.png"
|
: "/images/artcraft-logo-2.png"
|
||||||
}
|
}
|
||||||
alt="ArtCraft"
|
alt="ArtCraft"
|
||||||
className={isMobile ? "h-5 w-auto" : "h-5 w-auto"}
|
className={isMobile ? "h-5 w-auto" : "h-5 w-auto"}
|
||||||
|
|||||||
@@ -53,16 +53,16 @@ const SheetContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 flex flex-col bg-[#1F1F1F] shadow-lg transition ease-in-out",
|
"fixed z-50 flex flex-col bg-[#101014] transition ease-in-out",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
"inset-y-0 left-0 h-full border-r border-white/[0.08] data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
|
"inset-y-0 left-0 h-full border-r border-white/15 data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"inset-y-0 right-0 h-full border-l border-white/[0.08] data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
|
"inset-y-0 right-0 h-full border-l border-white/15 data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
|
||||||
side === "top" &&
|
side === "top" &&
|
||||||
"inset-x-0 top-0 border-b border-white/[0.08] data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
|
"inset-x-0 top-0 border-b border-white/15 data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
"inset-x-0 bottom-0 border-t border-white/[0.08] data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
|
"inset-x-0 bottom-0 border-t border-white/15 data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ const Sidebar = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
"fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex bg-background",
|
||||||
side === "left"
|
side === "left"
|
||||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
@@ -247,7 +247,7 @@ const Sidebar = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-[20px] group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:backdrop-blur-[7.5px] group-data-[variant=floating]:overflow-hidden group-data-[variant=floating]:shadow-[-20px_68px_20px_0px_rgba(0,0,0,0),-13px_43px_18px_0px_rgba(0,0,0,0.01),-7px_24px_15px_0px_rgba(0,0,0,0.04),-3px_11px_11px_0px_rgba(0,0,0,0.07),-1px_3px_6px_0px_rgba(0,0,0,0.08)]"
|
className="flex h-full w-full flex-col bg-sidebar rounded-[3px] group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:overflow-hidden"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +274,7 @@ const SidebarTrigger = React.forwardRef<
|
|||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors",
|
"inline-flex h-8 w-8 items-center justify-center text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -323,7 +323,7 @@ const SidebarInset = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full flex-1 flex-col bg-background",
|
"relative flex w-full flex-1 flex-col bg-background",
|
||||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -397,7 +397,7 @@ const SidebarGroupLabel = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-sidebar="group-label"
|
data-sidebar="group-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
"flex h-8 shrink-0 items-center px-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/50 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -447,7 +447,7 @@ const SidebarMenuItem = React.forwardRef<
|
|||||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[3px] px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-[60] overflow-hidden rounded-md bg-[#1f1f1f] border border-white/[0.08] px-2.5 py-1.5 text-xs text-white shadow-md",
|
"z-[60] overflow-hidden rounded-[3px] bg-[#101014] border border-white/15 px-2.5 py-1.5 text-xs text-white",
|
||||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,24 +7,19 @@ import Seo from "../../components/seo";
|
|||||||
|
|
||||||
const CheckoutCancel = () => {
|
const CheckoutCancel = () => {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
|
<div className="relative min-h-screen overflow-hidden bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Checkout Cancelled - ArtCraft"
|
title="Checkout Cancelled - ArtCraft"
|
||||||
description="Your checkout was cancelled. No payment was made."
|
description="Your checkout was cancelled. No payment was made."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Background gradient - subtle/neutral */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
|
||||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||||
{/* Cancel Card */}
|
{/* Cancel Card */}
|
||||||
<div className="max-w-lg w-full">
|
<div className="max-w-lg w-full">
|
||||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
|
<div className="bg-[#101014] border border-white/15 p-8 md:p-12 text-center">
|
||||||
{/* Cancel Icon */}
|
{/* Cancel Icon */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="w-20 h-20 mx-auto rounded-full bg-white/10 flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto border border-white/15 bg-white/10 flex items-center justify-center">
|
||||||
<CircleXIcon
|
<CircleXIcon
|
||||||
|
|
||||||
className="text-5xl text-white/50" />
|
className="text-5xl text-white/50" />
|
||||||
@@ -41,7 +36,7 @@ const CheckoutCancel = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="bg-[#252529] rounded-2xl p-5 mb-8 text-left">
|
<div className="bg-ui-controls border border-white/15 p-5 mb-8 text-left">
|
||||||
<p className="text-white/70 text-sm">
|
<p className="text-white/70 text-sm">
|
||||||
<span className="text-white font-medium">
|
<span className="text-white font-medium">
|
||||||
Changed your mind?
|
Changed your mind?
|
||||||
@@ -56,14 +51,14 @@ const CheckoutCancel = () => {
|
|||||||
<Button
|
<Button
|
||||||
as="link"
|
as="link"
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
className="px-8 py-3 justify-center"
|
||||||
>
|
>
|
||||||
View Plans Again
|
View Plans Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discord CTA */}
|
{/* Discord CTA */}
|
||||||
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
|
<div className="mt-8 pt-6 border-t border-white/15 flex flex-col gap-2 items-center">
|
||||||
<div className="flex items-center justify-center gap-2 mb-2">
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<span className="text-white/70 text-sm">
|
<span className="text-white/70 text-sm">
|
||||||
Need help deciding?
|
Need help deciding?
|
||||||
@@ -73,7 +68,7 @@ const CheckoutCancel = () => {
|
|||||||
as="link"
|
as="link"
|
||||||
href={SOCIAL_LINKS.DISCORD}
|
href={SOCIAL_LINKS.DISCORD}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
|
className="bg-white text-black hover:bg-white/80 px-4 py-2 justify-center border-transparent"
|
||||||
>
|
>
|
||||||
<DiscordIcon />
|
<DiscordIcon />
|
||||||
Join Discord
|
Join Discord
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { ArrowRightIcon, BoxIcon, CircleCheckIcon, DownloadIcon, ImageIcon, MonitorIcon, RocketIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
BoxIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
ImageIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
RocketIcon,
|
||||||
|
VideoIcon,
|
||||||
|
WandSparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
|
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
@@ -59,35 +69,27 @@ const CheckoutSuccess = () => {
|
|||||||
|
|
||||||
if (isCheckingOnboarding) {
|
if (isCheckingOnboarding) {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
|
<div className="relative min-h-screen bg-ui-background text-white flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full bg-[#101014] text-white">
|
<div className="relative min-h-full bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Payment Successful - ArtCraft"
|
title="Payment Successful - ArtCraft"
|
||||||
description="Your ArtCraft subscription is now active. Download and start creating!"
|
description="Your ArtCraft subscription is now active. Download and start creating!"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Background gradient — clipped to its own wrapper so the outer
|
|
||||||
page can grow naturally and let SidebarInset handle scrolling. */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
|
|
||||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||||
{/* Success Card */}
|
{/* Success Card */}
|
||||||
<div className="max-w-4xl w-full">
|
<div className="max-w-4xl w-full">
|
||||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
|
<div className="bg-[#101014] border border-white/15 p-8 md:p-12 text-center">
|
||||||
{/* Success Icon */}
|
{/* Success Icon */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
|
||||||
<CircleCheckIcon
|
<CircleCheckIcon className="text-5xl text-green-400" />
|
||||||
|
|
||||||
className="text-5xl text-green-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,12 +104,10 @@ const CheckoutSuccess = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Start creating in the browser */}
|
{/* Start creating in the browser */}
|
||||||
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
|
<div className="bg-ui-controls border border-white/15 p-6 mb-8 text-left">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
|
||||||
<WandSparklesIcon
|
<WandSparklesIcon className="text-white/70 text-sm" />
|
||||||
|
|
||||||
className="text-primary text-sm" />
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium text-white">
|
<h2 className="text-lg font-medium text-white">
|
||||||
Start creating right here
|
Start creating right here
|
||||||
@@ -130,30 +130,26 @@ const CheckoutSuccess = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className="group flex items-center gap-3 bg-[#1A1A1E] hover:bg-[#222227] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
|
className="group flex items-center gap-3 rounded-[3px] bg-ui-background hover:bg-white/10 border border-white/15 hover:border-white/30 px-4 py-3 transition-all"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
className="text-primary text-base shrink-0"
|
className="text-white/70 text-base shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-white/90 font-medium flex-1">
|
<span className="text-white/90 font-medium flex-1">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<ArrowRightIcon
|
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
||||||
|
|
||||||
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Steps */}
|
{/* Next Steps */}
|
||||||
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
|
<div className="bg-ui-controls border border-white/15 p-6 mb-8 text-left">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
|
||||||
<RocketIcon
|
<RocketIcon className="text-white/70 text-sm" />
|
||||||
|
|
||||||
className="text-primary text-sm" />
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium text-white">Next Steps</h2>
|
<h2 className="text-lg font-medium text-white">Next Steps</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +162,7 @@ const CheckoutSuccess = () => {
|
|||||||
"Start creating with all premium features!",
|
"Start creating with all premium features!",
|
||||||
].map((step, idx) => (
|
].map((step, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-3">
|
<div key={idx} className="flex items-start gap-3">
|
||||||
<div className="w-6 h-6 shrink-0 rounded-full bg-primary/50 flex items-center justify-center text-xs font-bold text-white mt-0.5">
|
<div className="w-6 h-6 shrink-0 bg-white/10 border border-white/15 flex items-center justify-center font-mono text-[11px] font-semibold text-white mt-0.5">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white/80">{step}</span>
|
<span className="text-white/80">{step}</span>
|
||||||
@@ -181,16 +177,16 @@ const CheckoutSuccess = () => {
|
|||||||
<Button
|
<Button
|
||||||
as="link"
|
as="link"
|
||||||
href={downloadUrl}
|
href={downloadUrl}
|
||||||
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
|
className="px-8 py-3 justify-center"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="mr-2" />
|
<DownloadIcon className="mr-2" />
|
||||||
Download ArtCraft
|
Download ArtCraft
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
<div className="bg-[#431407] border border-orange-900/50 p-6 text-orange-200 text-sm leading-relaxed">
|
||||||
<div className="flex items-center justify-center mb-3 text-orange-400">
|
<div className="flex items-center justify-center mb-3 text-orange-400">
|
||||||
<MonitorIcon className="text-2xl" />
|
<MonitorIcon className="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
ArtCraft is a desktop application. <br />
|
ArtCraft is a desktop application. <br />
|
||||||
Please head to your computer to download and start creating.
|
Please head to your computer to download and start creating.
|
||||||
@@ -204,21 +200,21 @@ const CheckoutSuccess = () => {
|
|||||||
href={DOWNLOAD_LINKS.WINDOWS}
|
href={DOWNLOAD_LINKS.WINDOWS}
|
||||||
className="hover:text-white transition-colors flex items-center gap-2"
|
className="hover:text-white transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
<span className="w-1.5 h-1.5 bg-current"></span>
|
||||||
Windows
|
Windows
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={DOWNLOAD_LINKS.MACOS}
|
href={DOWNLOAD_LINKS.MACOS}
|
||||||
className="hover:text-white transition-colors flex items-center gap-2"
|
className="hover:text-white transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
<span className="w-1.5 h-1.5 bg-current"></span>
|
||||||
Mac
|
Mac
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Discord CTA */}
|
{/* Discord CTA */}
|
||||||
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
|
<div className="mt-8 pt-6 border-t border-white/15 flex flex-col gap-2 items-center">
|
||||||
<div className="flex items-center justify-center gap-2 mb-2">
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<span className="text-white/70 text-sm">
|
<span className="text-white/70 text-sm">
|
||||||
Join our community
|
Join our community
|
||||||
@@ -228,7 +224,7 @@ const CheckoutSuccess = () => {
|
|||||||
as="link"
|
as="link"
|
||||||
href={SOCIAL_LINKS.DISCORD}
|
href={SOCIAL_LINKS.DISCORD}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
|
className="bg-white text-black hover:bg-white/80 px-4 py-2 justify-center border-transparent"
|
||||||
>
|
>
|
||||||
<DiscordIcon />
|
<DiscordIcon />
|
||||||
Join Discord
|
Join Discord
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ const AUDIO_REF_MAX_DURATION_SECONDS = 600;
|
|||||||
|
|
||||||
const supports = (flag: boolean | null | undefined): boolean => flag === true;
|
const supports = (flag: boolean | null | undefined): boolean => flag === true;
|
||||||
|
|
||||||
|
// The lib ToggleButton still carries the old glassy look (2px transparent
|
||||||
|
// border, grey translucent blur). These overrides align the audio toggles
|
||||||
|
// with their sibling toolbar controls (the PopoverMenu triggers): flat
|
||||||
|
// ui-controls surface, 1px hairline border, 4px corners. Active keeps the
|
||||||
|
// brand tint as the on-state affordance.
|
||||||
|
const toolbarToggleClassName = (isActive: boolean): string =>
|
||||||
|
isActive
|
||||||
|
? "rounded-[3px] border border-ui-controls-border backdrop-blur-none hover:border-ui-controls-border"
|
||||||
|
: "rounded-[3px] border border-ui-controls-border bg-ui-controls backdrop-blur-none hover:bg-ui-controls/80";
|
||||||
|
|
||||||
// Store API model data alongside popover items via a lookup map
|
// Store API model data alongside popover items via a lookup map
|
||||||
let _modelLookup = new Map<string, OmniGenAudioModelDetails>();
|
let _modelLookup = new Map<string, OmniGenAudioModelDetails>();
|
||||||
|
|
||||||
@@ -490,6 +500,7 @@ export default function CreateAudio() {
|
|||||||
icon={MicOffIcon}
|
icon={MicOffIcon}
|
||||||
activeIcon={MicOffIcon}
|
activeIcon={MicOffIcon}
|
||||||
label="Instrumental"
|
label="Instrumental"
|
||||||
|
className={toolbarToggleClassName(ui.isInstrumental)}
|
||||||
onClick={() => setUi({ isInstrumental: !ui.isInstrumental })}
|
onClick={() => setUi({ isInstrumental: !ui.isInstrumental })}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -506,6 +517,7 @@ export default function CreateAudio() {
|
|||||||
icon={MicIcon}
|
icon={MicIcon}
|
||||||
activeIcon={MicIcon}
|
activeIcon={MicIcon}
|
||||||
label="Keep lyrics"
|
label="Keep lyrics"
|
||||||
|
className={toolbarToggleClassName(ui.keepLyrics)}
|
||||||
onClick={() => setUi({ keepLyrics: !ui.keepLyrics })}
|
onClick={() => setUi({ keepLyrics: !ui.keepLyrics })}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -522,6 +534,7 @@ export default function CreateAudio() {
|
|||||||
icon={RepeatIcon}
|
icon={RepeatIcon}
|
||||||
activeIcon={RepeatIcon}
|
activeIcon={RepeatIcon}
|
||||||
label="Loop"
|
label="Loop"
|
||||||
|
className={toolbarToggleClassName(ui.isLoopable)}
|
||||||
onClick={() => setUi({ isLoopable: !ui.isLoopable })}
|
onClick={() => setUi({ isLoopable: !ui.isLoopable })}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -630,7 +643,7 @@ export default function CreateAudio() {
|
|||||||
settingsFields={
|
settingsFields={
|
||||||
<>
|
<>
|
||||||
{styleSupported && (
|
{styleSupported && (
|
||||||
<div className="rounded-xl border border-ui-panel-border bg-ui-controls px-3 py-1.5">
|
<div className="rounded-[3px] border border-ui-panel-border bg-ui-controls px-3 py-1.5">
|
||||||
<StylePromptRow
|
<StylePromptRow
|
||||||
value={ui.stylePrompt}
|
value={ui.stylePrompt}
|
||||||
onChange={setStylePrompt}
|
onChange={setStylePrompt}
|
||||||
@@ -774,7 +787,7 @@ export default function CreateAudio() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={openSignupCta}
|
onClick={openSignupCta}
|
||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
className="h-12 px-6"
|
||||||
>
|
>
|
||||||
Sign up to create
|
Sign up to create
|
||||||
</Button>
|
</Button>
|
||||||
@@ -953,7 +966,7 @@ function MobileSliderRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
className="rounded bg-white/5 px-2 py-0.5 text-xs font-medium text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
|
className="bg-white/5 px-2 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
|
||||||
>
|
>
|
||||||
Auto
|
Auto
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const AspectRatioIcon = ({
|
|||||||
y={y}
|
y={y}
|
||||||
width={w}
|
width={w}
|
||||||
height={h}
|
height={h}
|
||||||
rx={1.5}
|
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -680,7 +680,7 @@ export default function CreateImage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={openSignupCta}
|
onClick={openSignupCta}
|
||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
className="h-12 px-6"
|
||||||
>
|
>
|
||||||
Sign up to create
|
Sign up to create
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+66
-64
@@ -2,7 +2,11 @@ import { useRef, useState } from "react";
|
|||||||
import { BoxIcon, ImageIcon, LoaderCircleIcon, XIcon } from "lucide-react";
|
import { BoxIcon, ImageIcon, LoaderCircleIcon, XIcon } from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { AddButton, type RefImage } from "../../../components/prompt-box";
|
import { AddButton, type RefImage } from "../../../components/prompt-box";
|
||||||
import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload";
|
import {
|
||||||
|
MESH_FILE_ACCEPT,
|
||||||
|
uploadMeshFile,
|
||||||
|
uploadViewImage,
|
||||||
|
} from "./mesh-upload";
|
||||||
|
|
||||||
// Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input
|
// Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input
|
||||||
// file — the mobile form band. The desktop prompt box renders the same store
|
// file — the mobile form band. The desktop prompt box renders the same store
|
||||||
@@ -53,59 +57,61 @@ export function MeshInputsRow({
|
|||||||
return (
|
return (
|
||||||
// Title on the left, upload slots right-aligned + top-aligned (matches
|
// Title on the left, upload slots right-aligned + top-aligned (matches
|
||||||
// ImagePromptRow).
|
// ImagePromptRow).
|
||||||
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
|
<div className="glass flex items-start gap-3 px-3 py-2">
|
||||||
<div className="flex grow flex-col gap-1 min-w-32">
|
<div className="flex grow flex-col gap-1 min-w-32">
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<DynamicIcon icon={titleIcon} className="h-3.5 w-3.5" />
|
<DynamicIcon icon={titleIcon} className="h-3.5 w-3.5" />
|
||||||
<span className="text-sm font-medium">{title}</span>
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[13px] text-white/60">{subtitle}</span>
|
<span className="text-[13px] text-white/60">{subtitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end justify-end gap-3">
|
<div className="flex flex-wrap items-end justify-end gap-3">
|
||||||
{showMultiView && (
|
{showMultiView && (
|
||||||
<>
|
<>
|
||||||
<ImageSlot
|
<ImageSlot
|
||||||
label="Front"
|
label="Front"
|
||||||
image={frontImage}
|
image={frontImage}
|
||||||
onChange={(img) => onChange({ frontImage: img })}
|
onChange={(img) => onChange({ frontImage: img })}
|
||||||
onPickFromLibrary={
|
onPickFromLibrary={
|
||||||
onPickSlotFromLibrary
|
onPickSlotFromLibrary
|
||||||
? () => onPickSlotFromLibrary("front")
|
? () => onPickSlotFromLibrary("front")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ImageSlot
|
<ImageSlot
|
||||||
label="Back"
|
label="Back"
|
||||||
image={backImage}
|
image={backImage}
|
||||||
onChange={(img) => onChange({ backImage: img })}
|
onChange={(img) => onChange({ backImage: img })}
|
||||||
onPickFromLibrary={
|
onPickFromLibrary={
|
||||||
onPickSlotFromLibrary
|
onPickSlotFromLibrary
|
||||||
? () => onPickSlotFromLibrary("back")
|
? () => onPickSlotFromLibrary("back")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ImageSlot
|
<ImageSlot
|
||||||
label="Left"
|
label="Left"
|
||||||
image={leftImage}
|
image={leftImage}
|
||||||
onChange={(img) => onChange({ leftImage: img })}
|
onChange={(img) => onChange({ leftImage: img })}
|
||||||
onPickFromLibrary={
|
onPickFromLibrary={
|
||||||
onPickSlotFromLibrary
|
onPickSlotFromLibrary
|
||||||
? () => onPickSlotFromLibrary("left")
|
? () => onPickSlotFromLibrary("left")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ImageSlot
|
<ImageSlot
|
||||||
label="Right"
|
label="Right"
|
||||||
image={rightImage}
|
image={rightImage}
|
||||||
onChange={(img) => onChange({ rightImage: img })}
|
onChange={(img) => onChange({ rightImage: img })}
|
||||||
onPickFromLibrary={
|
onPickFromLibrary={
|
||||||
onPickSlotFromLibrary
|
onPickSlotFromLibrary
|
||||||
? () => onPickSlotFromLibrary("right")
|
? () => onPickSlotFromLibrary("right")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showMeshInput && (
|
{showMeshInput && (
|
||||||
<MeshFileSlot
|
<MeshFileSlot
|
||||||
mesh={inputMesh}
|
mesh={inputMesh}
|
||||||
@@ -120,7 +126,7 @@ export function MeshInputsRow({
|
|||||||
// ── Slots ──────────────────────────────────────────────────────────────────
|
// ── Slots ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SLOT_CLASS =
|
const SLOT_CLASS =
|
||||||
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
|
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
|
||||||
|
|
||||||
function SlotShell({
|
function SlotShell({
|
||||||
label,
|
label,
|
||||||
@@ -132,7 +138,9 @@ function SlotShell({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
{children}
|
{children}
|
||||||
<span className="text-[11px] text-white/60">{label}</span>
|
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,7 +179,7 @@ function ImageSlot({
|
|||||||
onChange={handleUpload}
|
onChange={handleUpload}
|
||||||
/>
|
/>
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
|
<div className="group relative aspect-square w-14 overflow-hidden rounded-[3px] border border-white/30">
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={`${label} view`}
|
alt={`${label} view`}
|
||||||
@@ -179,17 +187,14 @@ function ImageSlot({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange(undefined)}
|
onClick={() => onChange(undefined)}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : uploading ? (
|
) : uploading ? (
|
||||||
<div className={SLOT_CLASS}>
|
<div className={SLOT_CLASS}>
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
|
||||||
|
|
||||||
spin
|
|
||||||
className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AddButton
|
<AddButton
|
||||||
@@ -231,28 +236,25 @@ function MeshFileSlot({
|
|||||||
onChange={handleUpload}
|
onChange={handleUpload}
|
||||||
/>
|
/>
|
||||||
{mesh ? (
|
{mesh ? (
|
||||||
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/10">
|
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-white/30 bg-white/10">
|
||||||
<BoxIcon className="h-5 w-5 text-white/80" />
|
<BoxIcon className="h-5 w-5 text-white/80" />
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange(undefined)}
|
onClick={() => onChange(undefined)}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : uploading ? (
|
) : uploading ? (
|
||||||
<div className={SLOT_CLASS}>
|
<div className={SLOT_CLASS}>
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
|
||||||
|
|
||||||
spin
|
|
||||||
className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className={SLOT_CLASS}
|
className={SLOT_CLASS}
|
||||||
>
|
>
|
||||||
<BoxIcon className="text-xl text-white/80" />
|
<BoxIcon className="text-xl text-white/80" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</SlotShell>
|
</SlotShell>
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ export default function CreateObject() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={openSignupCta}
|
onClick={openSignupCta}
|
||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
className="h-12 px-6"
|
||||||
>
|
>
|
||||||
Sign up to create
|
Sign up to create
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export default function CreateVFX() {
|
|||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-[#101014]">
|
<div className="flex h-screen items-center justify-center bg-ui-background">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon
|
||||||
|
|
||||||
className="animate-spin text-4xl text-primary/80" />
|
className="animate-spin text-4xl text-primary/80" />
|
||||||
@@ -256,7 +256,7 @@ export default function CreateVFX() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-[#101014] text-white">
|
<div className="relative h-full w-full bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Background Change - ArtCraft"
|
title="Background Change - ArtCraft"
|
||||||
description="Swap the backdrop of a video using a reference image."
|
description="Swap the backdrop of a video using a reference image."
|
||||||
@@ -304,7 +304,7 @@ export default function CreateVFX() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none fixed bottom-0 right-0 z-20 h-72 bg-gradient-to-t from-[#101014] via-[#101014]/85 to-transparent transition-[left] duration-200 ease-linear"
|
className="pointer-events-none fixed bottom-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background via-ui-background/85 to-transparent transition-[left] duration-200 ease-linear"
|
||||||
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
|
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
|
||||||
<div className="flex max-w-md flex-col items-center gap-4 text-center">
|
<div className="flex max-w-md flex-col items-center gap-4 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10">
|
<div className="flex h-12 w-12 items-center justify-center border border-white/15 bg-white/5">
|
||||||
<SparklesIcon className="text-2xl text-white/40" />
|
<SparklesIcon className="text-2xl text-white/40" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-white">{title}</h3>
|
<h3 className="text-2xl font-bold text-white">{title}</h3>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { AudioLinesIcon, ClockIcon, InfoIcon, SparklesIcon } from "lucide-react";
|
import {
|
||||||
|
AudioLinesIcon,
|
||||||
|
ClockIcon,
|
||||||
|
InfoIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
|
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
|
||||||
import type { OmniGenVideoModelInfo } from "@storyteller/api";
|
import type { OmniGenVideoModelInfo } from "@storyteller/api";
|
||||||
@@ -1487,10 +1492,8 @@ export default function CreateVideo() {
|
|||||||
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
|
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
|
||||||
banner={
|
banner={
|
||||||
requiresImageInput ? (
|
requiresImageInput ? (
|
||||||
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-900/80 px-3.5 py-2.5 text-xs text-amber-100">
|
<div className="flex items-start gap-2.5 border border-amber-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-amber-100">
|
||||||
<InfoIcon
|
<InfoIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||||
|
|
||||||
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
|
||||||
<span>
|
<span>
|
||||||
This model can't generate from text alone — add a starting
|
This model can't generate from text alone — add a starting
|
||||||
frame to animate your prompt.
|
frame to animate your prompt.
|
||||||
@@ -1506,13 +1509,13 @@ export default function CreateVideo() {
|
|||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleInputModeChange(item)}
|
onClick={() => handleInputModeChange(item)}
|
||||||
className={`rounded-xl border px-3 py-2.5 text-left transition-colors ${
|
className={`border px-3 py-2.5 text-left transition-colors ${
|
||||||
item.selected
|
item.selected
|
||||||
? "border-primary bg-primary/15"
|
? "border-white bg-white/10"
|
||||||
: "border-ui-panel-border bg-ui-controls hover:bg-ui-controls/80"
|
: "border-ui-panel-border bg-ui-controls hover:bg-ui-controls/80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block text-sm font-semibold text-base-fg">
|
<span className="block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-base-fg/55">
|
<span className="block text-xs text-base-fg/55">
|
||||||
@@ -1566,9 +1569,9 @@ export default function CreateVideo() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasSound && (
|
{hasSound && (
|
||||||
<div className="flex items-center justify-between rounded-xl border border-ui-panel-border bg-ui-controls px-3.5 py-2.5">
|
<div className="flex items-center justify-between rounded-[3px] border border-ui-panel-border bg-ui-controls px-3.5 py-2.5">
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-base-fg/70">
|
<span className="flex items-center gap-2 text-sm font-medium text-base-fg/70">
|
||||||
<AudioLinesIcon className="h-4 w-4" />
|
<AudioLinesIcon className="h-4 w-4" />
|
||||||
Audio
|
Audio
|
||||||
</span>
|
</span>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -1584,7 +1587,7 @@ export default function CreateVideo() {
|
|||||||
<>
|
<>
|
||||||
<MobileFieldButton
|
<MobileFieldButton
|
||||||
label="Output"
|
label="Output"
|
||||||
icon={<ClockIcon className="h-4 w-4" />}
|
icon={<ClockIcon className="h-4 w-4" />}
|
||||||
value={outputSummary}
|
value={outputSummary}
|
||||||
onClick={() => setIsOutputDrawerOpen(true)}
|
onClick={() => setIsOutputDrawerOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -1653,7 +1656,7 @@ export default function CreateVideo() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCharactersModalOpen(true)}
|
onClick={() => setIsCharactersModalOpen(true)}
|
||||||
className="flex h-11 items-center justify-center gap-1 rounded-xl border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all hover:bg-ui-controls/80 active:scale-95"
|
className="flex h-11 items-center justify-center gap-1 border border-ui-controls-border bg-ui-controls px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
|
||||||
>
|
>
|
||||||
@Characters
|
@Characters
|
||||||
</button>
|
</button>
|
||||||
@@ -1664,14 +1667,6 @@ export default function CreateVideo() {
|
|||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const videoGlowOrbs = (
|
|
||||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
|
||||||
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
|
|
||||||
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.08] blur-[120px] transform-gpu" />
|
|
||||||
<div className="absolute bottom-[10%] right-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateMediaPageShell
|
<CreateMediaPageShell
|
||||||
title="Create Video - ArtCraft"
|
title="Create Video - ArtCraft"
|
||||||
@@ -1688,7 +1683,7 @@ export default function CreateVideo() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={openSignupCta}
|
onClick={openSignupCta}
|
||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
className="h-12 px-6"
|
||||||
>
|
>
|
||||||
Sign up to create
|
Sign up to create
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1697,7 +1692,6 @@ export default function CreateVideo() {
|
|||||||
bottomOffset={promptHeight + 24}
|
bottomOffset={promptHeight + 24}
|
||||||
modelItems={modelItems}
|
modelItems={modelItems}
|
||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
glowOrbs={videoGlowOrbs}
|
|
||||||
promptForm={mobileForm}
|
promptForm={mobileForm}
|
||||||
gridContent={
|
gridContent={
|
||||||
<GenerationGallery
|
<GenerationGallery
|
||||||
@@ -1726,7 +1720,7 @@ export default function CreateVideo() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* {selectedModel?.model === "seedance_2p0" && (
|
{/* {selectedModel?.model === "seedance_2p0" && (
|
||||||
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-yellow-500/40 px-3.5 py-2.5 text-xs text-yellow-200 shadow-lg backdrop-blur-xl bg-yellow-800/60">
|
<div className="mb-2 flex items-start gap-2.5 border border-yellow-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-yellow-200">
|
||||||
<TriangleAlertIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-yellow-400" />
|
<TriangleAlertIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-yellow-400" />
|
||||||
<span>
|
<span>
|
||||||
Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages.
|
Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages.
|
||||||
@@ -1735,10 +1729,8 @@ export default function CreateVideo() {
|
|||||||
</div>
|
</div>
|
||||||
)} */}
|
)} */}
|
||||||
{requiresImageInput && (
|
{requiresImageInput && (
|
||||||
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-900/80 px-3.5 py-2.5 text-xs text-amber-100 shadow-lg backdrop-blur-sm">
|
<div className="mb-2 flex items-start gap-2.5 border border-amber-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-amber-100">
|
||||||
<InfoIcon
|
<InfoIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||||
|
|
||||||
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
|
||||||
<span>
|
<span>
|
||||||
This model can't generate from text alone — add a starting
|
This model can't generate from text alone — add a starting
|
||||||
frame to animate your prompt.
|
frame to animate your prompt.
|
||||||
@@ -1900,11 +1892,7 @@ export default function CreateVideo() {
|
|||||||
<PopoverMenu
|
<PopoverMenu
|
||||||
mode="default"
|
mode="default"
|
||||||
panelTitle="Duration"
|
panelTitle="Duration"
|
||||||
triggerIcon={
|
triggerIcon={<ClockIcon className="h-3.5 w-3.5" />}
|
||||||
<ClockIcon
|
|
||||||
|
|
||||||
className="h-3.5 w-3.5" />
|
|
||||||
}
|
|
||||||
triggerLabel={`${effectiveDuration}s`}
|
triggerLabel={`${effectiveDuration}s`}
|
||||||
>
|
>
|
||||||
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
|
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
|
||||||
@@ -1948,7 +1936,7 @@ export default function CreateVideo() {
|
|||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
generateWithSound
|
generateWithSound
|
||||||
? "bg-primary/40 hover:bg-primary/50 border-primary/30"
|
? "bg-white/10 hover:bg-white/15 border-white/40"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1973,7 +1961,7 @@ export default function CreateVideo() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCharactersModalOpen(true)}
|
onClick={() => setIsCharactersModalOpen(true)}
|
||||||
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg shadow-sm transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
|
className="flex h-9 items-center justify-center gap-1 border border-ui-controls-border bg-ui-controls px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors duration-150 hover:bg-ui-controls/80"
|
||||||
>
|
>
|
||||||
@Characters
|
@Characters
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+17
-10
@@ -11,7 +11,7 @@ import type { RefVideo } from "../../../components/prompt-box";
|
|||||||
// the desktop prompt box renders the same ref in its reference deck).
|
// the desktop prompt box renders the same ref in its reference deck).
|
||||||
|
|
||||||
const SLOT_CLASS =
|
const SLOT_CLASS =
|
||||||
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
|
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
|
||||||
|
|
||||||
export function ReferenceVideoSlot({
|
export function ReferenceVideoSlot({
|
||||||
video,
|
video,
|
||||||
@@ -62,13 +62,17 @@ export function ReferenceVideoSlot({
|
|||||||
return (
|
return (
|
||||||
// Title on the left, upload slot right-aligned + top-aligned (matches
|
// Title on the left, upload slot right-aligned + top-aligned (matches
|
||||||
// ImagePromptRow).
|
// ImagePromptRow).
|
||||||
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
|
<div className="glass flex items-start gap-3 px-3 py-2">
|
||||||
<div className="flex grow flex-col gap-1 min-w-32">
|
<div className="flex grow flex-col gap-1 min-w-32">
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<VideoIcon className="h-3.5 w-3.5" />
|
<VideoIcon className="h-3.5 w-3.5" />
|
||||||
<span className="text-sm font-medium">Reference video</span>
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
|
Reference video
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[13px] text-white/60">Guide the world (optional)</span>
|
<span className="text-[13px] text-white/60">
|
||||||
|
Guide the world (optional)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -78,7 +82,7 @@ export function ReferenceVideoSlot({
|
|||||||
onChange={handleUpload}
|
onChange={handleUpload}
|
||||||
/>
|
/>
|
||||||
{video ? (
|
{video ? (
|
||||||
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
|
<div className="group relative aspect-square w-14 overflow-hidden rounded-[3px] border border-white/30">
|
||||||
<video
|
<video
|
||||||
src={video.url}
|
src={video.url}
|
||||||
muted
|
muted
|
||||||
@@ -87,9 +91,9 @@ export function ReferenceVideoSlot({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
|
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
|
||||||
>
|
>
|
||||||
<XIcon className="h-2.5 w-2.5" />
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : uploading ? (
|
) : uploading ? (
|
||||||
@@ -97,8 +101,11 @@ export function ReferenceVideoSlot({
|
|||||||
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
|
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => inputRef.current?.click()} className={SLOT_CLASS}>
|
<button
|
||||||
<PlusIcon className="text-xl text-white/80" />
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={SLOT_CLASS}
|
||||||
|
>
|
||||||
|
<PlusIcon className="text-xl text-white/80" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ export default function CreateWorld() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={openSignupCta}
|
onClick={openSignupCta}
|
||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
className="h-12 px-6"
|
||||||
>
|
>
|
||||||
Sign up to create
|
Sign up to create
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -41,22 +41,14 @@ const ForgotPassword = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
|
||||||
<Seo
|
<Seo
|
||||||
title="Reset Password - ArtCraft"
|
title="Reset Password - ArtCraft"
|
||||||
description="Reset your ArtCraft password."
|
description="Reset your ArtCraft password."
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
|
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
|
||||||
{!submitted ? (
|
{!submitted ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
@@ -80,13 +72,13 @@ const ForgotPassword = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Email or Username
|
Email or Username
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -95,21 +87,19 @@ const ForgotPassword = () => {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
inputClassName="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"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
id="send-reset-btn"
|
id="send-reset-btn"
|
||||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="w-full justify-center h-10"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
|
||||||
className="animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
"Send Reset Code"
|
"Send Reset Code"
|
||||||
)}
|
)}
|
||||||
@@ -119,8 +109,8 @@ const ForgotPassword = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
|
<div className="w-16 h-16 border border-white/15 bg-green-500/10 flex items-center justify-center mx-auto mb-4 text-green-500">
|
||||||
<MailIcon className="text-2xl" />
|
<MailIcon className="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium mb-2">Check your email</h3>
|
<h3 className="text-xl font-medium mb-2">Check your email</h3>
|
||||||
<p className="text-white/60 text-sm mb-6">
|
<p className="text-white/60 text-sm mb-6">
|
||||||
@@ -128,12 +118,12 @@ const ForgotPassword = () => {
|
|||||||
<span className="text-white font-medium">{email}</span>
|
<span className="text-white font-medium">{email}</span>
|
||||||
</p>
|
</p>
|
||||||
<Link to="/forgot-password/verify">
|
<Link to="/forgot-password/verify">
|
||||||
<Button className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
|
<Button className="w-full justify-center h-10 mb-3">
|
||||||
Enter Verification Code
|
Enter Verification Code
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
|
className="w-full bg-transparent border border-white/15 hover:bg-white/10 text-white justify-center h-10"
|
||||||
onClick={() => setSubmitted(false)}
|
onClick={() => setSubmitted(false)}
|
||||||
>
|
>
|
||||||
Try another email
|
Try another email
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ArrowLeftIcon, CircleCheckIcon, EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeOffIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { Input } from "@storyteller/ui-input";
|
import { Input } from "@storyteller/ui-input";
|
||||||
@@ -96,22 +102,14 @@ const VerifyReset = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
|
||||||
<Seo
|
<Seo
|
||||||
title="Verify Password Reset - ArtCraft"
|
title="Verify Password Reset - ArtCraft"
|
||||||
description="Enter your verification code and new password."
|
description="Enter your verification code and new password."
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
|
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
|
||||||
{!success ? (
|
{!success ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
@@ -137,14 +135,14 @@ const VerifyReset = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Verification Code */}
|
{/* Verification Code */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Verification Code
|
Verification Code
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -154,7 +152,7 @@ const VerifyReset = () => {
|
|||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
placeholder="Enter verification code"
|
placeholder="Enter verification code"
|
||||||
isError={!!fieldErrors.verificationCode}
|
isError={!!fieldErrors.verificationCode}
|
||||||
inputClassName="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"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
{fieldErrors.verificationCode && (
|
{fieldErrors.verificationCode && (
|
||||||
<p className="text-red-400 text-xs ml-1">
|
<p className="text-red-400 text-xs ml-1">
|
||||||
@@ -165,7 +163,7 @@ const VerifyReset = () => {
|
|||||||
|
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
New Password
|
New Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -176,7 +174,7 @@ const VerifyReset = () => {
|
|||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
isError={!!fieldErrors.newPassword}
|
isError={!!fieldErrors.newPassword}
|
||||||
inputClassName="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 pr-12"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -197,7 +195,7 @@ const VerifyReset = () => {
|
|||||||
|
|
||||||
{/* Verify New Password */}
|
{/* Verify New Password */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Verify New Password
|
Verify New Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -208,7 +206,7 @@ const VerifyReset = () => {
|
|||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="Enter new password again"
|
placeholder="Enter new password again"
|
||||||
isError={!!fieldErrors.confirmPassword}
|
isError={!!fieldErrors.confirmPassword}
|
||||||
inputClassName="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 pr-12"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,14 +230,12 @@ const VerifyReset = () => {
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
id="change-password-btn"
|
id="change-password-btn"
|
||||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="w-full justify-center h-10"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
|
||||||
className="animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
"Change Password"
|
"Change Password"
|
||||||
)}
|
)}
|
||||||
@@ -259,8 +255,8 @@ const VerifyReset = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
|
<div className="w-16 h-16 border border-white/15 bg-green-500/10 flex items-center justify-center mx-auto mb-4 text-green-500">
|
||||||
<CircleCheckIcon className="text-2xl" />
|
<CircleCheckIcon className="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium mb-2">
|
<h3 className="text-xl font-medium mb-2">
|
||||||
Password Reset Successfully
|
Password Reset Successfully
|
||||||
@@ -271,7 +267,7 @@ const VerifyReset = () => {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
id="back-to-homepage-btn"
|
id="back-to-homepage-btn"
|
||||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="w-full justify-center h-10"
|
||||||
onClick={() => navigate(redirectTo)}
|
onClick={() => navigate(redirectTo)}
|
||||||
>
|
>
|
||||||
{redirectLabel}
|
{redirectLabel}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export const ExtractionPanel = ({
|
|||||||
const burstAvailable = Number.isFinite(duration) && duration > 0;
|
const burstAvailable = Number.isFinite(duration) && duration > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-fit flex-col divide-y divide-ui-divider rounded-xl border border-ui-panel-border bg-ui-panel">
|
<div className="flex h-fit flex-col divide-y divide-ui-divider border border-ui-panel-border bg-ui-panel">
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
<div className="flex items-center justify-between font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
|
||||||
<span>Playhead</span>
|
<span>Playhead</span>
|
||||||
<span className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
|
<span className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
|
||||||
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
|
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
|
||||||
@@ -56,7 +56,7 @@ export const ExtractionPanel = ({
|
|||||||
icon={CameraIcon}
|
icon={CameraIcon}
|
||||||
onClick={onCaptureCurrent}
|
onClick={onCaptureCurrent}
|
||||||
disabled={disabled || isExtracting}
|
disabled={disabled || isExtracting}
|
||||||
className="w-full py-2 text-sm font-medium"
|
className="w-full py-2"
|
||||||
>
|
>
|
||||||
Capture frame
|
Capture frame
|
||||||
</Button>
|
</Button>
|
||||||
@@ -72,14 +72,12 @@ export const ExtractionPanel = ({
|
|||||||
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-base-fg/75 transition-colors hover:text-base-fg disabled:cursor-default disabled:opacity-40"
|
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-base-fg/75 transition-colors hover:text-base-fg disabled:cursor-default disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LayersIcon
|
<LayersIcon className="text-xs text-base-fg/40" />
|
||||||
|
|
||||||
className="text-xs text-base-fg/40" />
|
|
||||||
Burst capture
|
Burst capture
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
|
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`}
|
||||||
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`} />
|
/>
|
||||||
</button>
|
</button>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{burstOpen && !disabled && burstAvailable && (
|
{burstOpen && !disabled && burstAvailable && (
|
||||||
@@ -108,10 +106,13 @@ export const ExtractionPanel = ({
|
|||||||
disabled={isExtracting}
|
disabled={isExtracting}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onNumFramesChange(
|
onNumFramesChange(
|
||||||
Math.max(1, Math.min(50, parseInt(e.target.value) || 1)),
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(50, parseInt(e.target.value) || 1),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
|
className="w-full border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-white/40 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -126,18 +127,21 @@ export const ExtractionPanel = ({
|
|||||||
disabled={isExtracting}
|
disabled={isExtracting}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSpacingChange(
|
onSpacingChange(
|
||||||
Math.max(1, Math.min(10000, parseInt(e.target.value) || 1)),
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(10000, parseInt(e.target.value) || 1),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
|
className="w-full border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-white/40 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{isExtracting && progress ? (
|
{isExtracting && progress ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
|
<div className="h-1.5 flex-1 overflow-hidden bg-white/10">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
className="h-full bg-white transition-all"
|
||||||
style={{
|
style={{
|
||||||
width: `${(progress.done / progress.total) * 100}%`,
|
width: `${(progress.done / progress.total) * 100}%`,
|
||||||
}}
|
}}
|
||||||
@@ -148,10 +152,10 @@ export const ExtractionPanel = ({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
|
||||||
aria-label="Cancel extraction"
|
aria-label="Cancel extraction"
|
||||||
>
|
>
|
||||||
<XIcon className="text-xs" />
|
<XIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -159,7 +163,7 @@ export const ExtractionPanel = ({
|
|||||||
variant="action"
|
variant="action"
|
||||||
onClick={onExtractBurst}
|
onClick={onExtractBurst}
|
||||||
disabled={isExtracting}
|
disabled={isExtracting}
|
||||||
className="w-full py-1.5 text-sm font-medium"
|
className="w-full py-1.5"
|
||||||
>
|
>
|
||||||
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
|
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -174,13 +178,17 @@ export const ExtractionPanel = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Duration</span>
|
<span>Duration</span>
|
||||||
<span className="font-mono text-base-fg/70">
|
<span className="font-mono text-base-fg/70">
|
||||||
{!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"}
|
{!disabled && Number.isFinite(duration)
|
||||||
|
? formatTime(duration)
|
||||||
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Resolution</span>
|
<span>Resolution</span>
|
||||||
<span className="font-mono text-base-fg/70">
|
<span className="font-mono text-base-fg/70">
|
||||||
{!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"}
|
{!disabled && resolution
|
||||||
|
? `${resolution.w} × ${resolution.h}`
|
||||||
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export default function FrameExtractor() {
|
|||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full w-full shrink-0 bg-[#101014] px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
|
<div className="relative min-h-full w-full shrink-0 bg-ui-background px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
|
||||||
<Seo
|
<Seo
|
||||||
title="Video Frame Extractor - ArtCraft"
|
title="Video Frame Extractor - ArtCraft"
|
||||||
description="Grab a still frame from any video — capture, save to your library, or use it as an image prompt reference."
|
description="Grab a still frame from any video — capture, save to your library, or use it as an image prompt reference."
|
||||||
@@ -452,7 +452,7 @@ export default function FrameExtractor() {
|
|||||||
variant="action"
|
variant="action"
|
||||||
icon={RotateCwIcon}
|
icon={RotateCwIcon}
|
||||||
onClick={clearSource}
|
onClick={clearSource}
|
||||||
className="px-3 py-1.5 text-sm"
|
className="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
Switch Video
|
Switch Video
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const FramesGrid = ({
|
|||||||
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
|
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-ui-panel-border bg-ui-panel p-4">
|
<div className="border border-ui-panel-border bg-ui-panel p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-base-fg">
|
<h3 className="text-sm font-semibold text-base-fg">
|
||||||
Captured frames
|
Captured frames
|
||||||
@@ -56,7 +56,7 @@ export const FramesGrid = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onSaveAll}
|
onClick={onSaveAll}
|
||||||
disabled={isSavingAll}
|
disabled={isSavingAll}
|
||||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={isSavingAll ? LoaderCircleIcon : SaveIcon}
|
icon={isSavingAll ? LoaderCircleIcon : SaveIcon}
|
||||||
@@ -67,7 +67,7 @@ export const FramesGrid = ({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
|
className="px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -86,7 +86,7 @@ export const FramesGrid = ({
|
|||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
transition={{ duration: 0.18, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.18, ease: EASE_EMPHASIS }}
|
||||||
className="group/frame overflow-hidden rounded-lg border border-ui-panel-border bg-ui-background"
|
className="group/frame overflow-hidden border border-ui-panel-border bg-ui-background"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video bg-black">
|
<div className="relative aspect-video bg-black">
|
||||||
<img
|
<img
|
||||||
@@ -96,12 +96,12 @@ export const FramesGrid = ({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemove(frame)}
|
onClick={() => onRemove(frame)}
|
||||||
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white/60 opacity-0 backdrop-blur-sm transition-all hover:text-white group-hover/frame:opacity-100"
|
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center bg-black/60 text-white/60 opacity-0 transition-all hover:text-white group-hover/frame:opacity-100"
|
||||||
aria-label="Remove frame"
|
aria-label="Remove frame"
|
||||||
>
|
>
|
||||||
<XIcon className="text-xs" />
|
<XIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
<span className="absolute bottom-1.5 left-1.5 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85 backdrop-blur-sm">
|
<span className="absolute bottom-1.5 left-1.5 bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85">
|
||||||
{formatTimePrecise(frame.timestamp)}
|
{formatTimePrecise(frame.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,14 +158,14 @@ const FrameAction = ({
|
|||||||
disabled,
|
disabled,
|
||||||
success,
|
success,
|
||||||
}: FrameActionProps) => (
|
}: FrameActionProps) => (
|
||||||
// fixed strategy: the card is overflow-hidden (rounded corners), which
|
// fixed strategy: the card is overflow-hidden, which would clip an
|
||||||
// would clip an absolutely-positioned tooltip.
|
// absolutely-positioned tooltip.
|
||||||
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
|
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={loading || disabled}
|
disabled={loading || disabled}
|
||||||
aria-label={tooltip}
|
aria-label={tooltip}
|
||||||
className={`flex h-7 w-8 items-center justify-center rounded-md text-xs transition-colors disabled:cursor-default ${
|
className={`flex h-7 w-8 items-center justify-center text-xs transition-colors disabled:cursor-default ${
|
||||||
success
|
success
|
||||||
? "text-green"
|
? "text-green"
|
||||||
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
|
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ export const VideoDropZone = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-3">
|
<div className="overflow-hidden border border-ui-panel-border bg-ui-panel p-3">
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex aspect-video flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
|
"flex aspect-video flex-col items-center justify-center gap-4 rounded-[3px] border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
|
||||||
isDragActive && "border-primary/60 bg-primary/5",
|
isDragActive && "border-white/60 bg-white/5",
|
||||||
)}
|
)}
|
||||||
onDragEnter={(e) => {
|
onDragEnter={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -65,7 +65,7 @@ export const VideoDropZone = ({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
icon={UploadIcon}
|
icon={UploadIcon}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="px-4 py-1.5 text-sm font-medium"
|
className="px-4 py-1.5"
|
||||||
>
|
>
|
||||||
Select video
|
Select video
|
||||||
</Button>
|
</Button>
|
||||||
@@ -73,7 +73,7 @@ export const VideoDropZone = ({
|
|||||||
variant="action"
|
variant="action"
|
||||||
icon={ImagesIcon}
|
icon={ImagesIcon}
|
||||||
onClick={onPickFromLibrary}
|
onClick={onPickFromLibrary}
|
||||||
className="px-4 py-1.5 text-sm font-medium"
|
className="px-4 py-1.5"
|
||||||
>
|
>
|
||||||
From library
|
From library
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export const VideoScrubber = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/scrubber overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel">
|
<div className="group/scrubber overflow-hidden border border-ui-panel-border bg-ui-panel">
|
||||||
<div className="relative aspect-video w-full bg-black">
|
<div className="relative aspect-video w-full bg-black">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -243,7 +243,7 @@ export const VideoScrubber = ({
|
|||||||
{!isPlaying && !disabled && (
|
{!isPlaying && !disabled && (
|
||||||
<button
|
<button
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/scrubber:opacity-100"
|
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center bg-black/55 text-white opacity-0 transition-opacity group-hover/scrubber:opacity-100"
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
<PlayIcon className="ml-0.5" />
|
<PlayIcon className="ml-0.5" />
|
||||||
@@ -261,21 +261,21 @@ export const VideoScrubber = ({
|
|||||||
onPointerCancel={handleTimelinePointerUp}
|
onPointerCancel={handleTimelinePointerUp}
|
||||||
onPointerLeave={() => setHoverRatio(null)}
|
onPointerLeave={() => setHoverRatio(null)}
|
||||||
>
|
>
|
||||||
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-white/10 transition-all group-hover/timeline:h-3">
|
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 bg-white/10 transition-all group-hover/timeline:h-3">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary"
|
className="h-full bg-white"
|
||||||
style={{ width: `${progressPercent}%` }}
|
style={{ width: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
|
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 bg-white"
|
||||||
style={{ left: `${progressPercent}%` }}
|
style={{ left: `${progressPercent}%` }}
|
||||||
/>
|
/>
|
||||||
{hoverRatio !== null &&
|
{hoverRatio !== null &&
|
||||||
Number.isFinite(duration) &&
|
Number.isFinite(duration) &&
|
||||||
duration > 0 && (
|
duration > 0 && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-md bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
|
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-[3px] bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
|
||||||
style={{ left: `${hoverRatio * 100}%` }}
|
style={{ left: `${hoverRatio * 100}%` }}
|
||||||
>
|
>
|
||||||
{formatTimePrecise(hoverRatio * duration)}
|
{formatTimePrecise(hoverRatio * duration)}
|
||||||
@@ -289,7 +289,7 @@ export const VideoScrubber = ({
|
|||||||
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
|
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
|
||||||
<button
|
<button
|
||||||
onClick={() => seekBy(step.delta)}
|
onClick={() => seekBy(step.delta)}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
className="flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
aria-label={step.tooltip}
|
aria-label={step.tooltip}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={step.icon} className="text-xs" />
|
<DynamicIcon icon={step.icon} className="text-xs" />
|
||||||
@@ -299,7 +299,7 @@ export const VideoScrubber = ({
|
|||||||
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
|
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
|
||||||
<button
|
<button
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
className="mx-0.5 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
className="mx-0.5 flex h-9 w-9 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20"
|
||||||
aria-label="Play or pause"
|
aria-label="Play or pause"
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||||
@@ -309,7 +309,7 @@ export const VideoScrubber = ({
|
|||||||
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
|
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
|
||||||
<button
|
<button
|
||||||
onClick={() => seekBy(step.delta)}
|
onClick={() => seekBy(step.delta)}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
className="flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
aria-label={step.tooltip}
|
aria-label={step.tooltip}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={step.icon} className="text-xs" />
|
<DynamicIcon icon={step.icon} className="text-xs" />
|
||||||
@@ -322,7 +322,7 @@ export const VideoScrubber = ({
|
|||||||
<div className="hidden items-center gap-2 sm:flex">
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
className="flex h-7 w-7 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
@@ -340,7 +340,7 @@ export const VideoScrubber = ({
|
|||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (video) video.volume = parseFloat(e.target.value);
|
if (video) video.volume = parseFloat(e.target.value);
|
||||||
}}
|
}}
|
||||||
className="w-20 accent-primary"
|
className="w-20 accent-white"
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import {
|
||||||
import { ArrowRightIcon, BoxIcon, FilmIcon, GlobeIcon, GroupIcon, ImageIcon, ImagesIcon, MusicIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
|
ArrowRightIcon,
|
||||||
|
BoxIcon,
|
||||||
|
FilmIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
GroupIcon,
|
||||||
|
ImageIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
MusicIcon,
|
||||||
|
VideoIcon,
|
||||||
|
WandSparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import Seo from "../../components/seo";
|
import Seo from "../../components/seo";
|
||||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||||
import { TRANSITION_SPRING } from "../../lib/motion";
|
|
||||||
|
|
||||||
const MotionLink = motion(Link);
|
|
||||||
|
|
||||||
type AppCard = {
|
type AppCard = {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
accent: string;
|
iconTile: string;
|
||||||
iconBg: string;
|
hoverBorder: string;
|
||||||
iconColor: string;
|
|
||||||
badge?: string;
|
badge?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,72 +32,64 @@ const APPS: AppCard[] = [
|
|||||||
description: "Generate images from prompts and references.",
|
description: "Generate images from prompts and references.",
|
||||||
href: "/create-image",
|
href: "/create-image",
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
accent: "from-blue-500/20 to-blue-500/0",
|
iconTile: "border-blue-400/40 bg-blue-500/20 text-blue-300",
|
||||||
iconBg: "bg-blue-500/25 border-blue-400/30",
|
hoverBorder: "hover:border-blue-400/60",
|
||||||
iconColor: "text-blue-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Video",
|
label: "Video",
|
||||||
description: "Generate cinematic clips from text or images.",
|
description: "Generate cinematic clips from text or images.",
|
||||||
href: "/create-video",
|
href: "/create-video",
|
||||||
icon: VideoIcon,
|
icon: VideoIcon,
|
||||||
accent: "from-purple-500/20 to-purple-500/0",
|
iconTile: "border-purple-400/40 bg-purple-500/20 text-purple-300",
|
||||||
iconBg: "bg-purple-500/25 border-purple-400/30",
|
hoverBorder: "hover:border-purple-400/60",
|
||||||
iconColor: "text-purple-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Audio",
|
label: "Audio",
|
||||||
description: "Generate songs and sound effects from prompts.",
|
description: "Generate songs and sound effects from prompts.",
|
||||||
href: "/create-audio",
|
href: "/create-audio",
|
||||||
icon: MusicIcon,
|
icon: MusicIcon,
|
||||||
accent: "from-pink-500/20 to-pink-500/0",
|
iconTile: "border-pink-400/40 bg-pink-500/20 text-pink-300",
|
||||||
iconBg: "bg-pink-500/25 border-pink-400/30",
|
hoverBorder: "hover:border-pink-400/60",
|
||||||
iconColor: "text-pink-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "3D Object",
|
label: "3D Object",
|
||||||
description: "Turn a prompt or image into a 3D model.",
|
description: "Turn a prompt or image into a 3D model.",
|
||||||
href: "/create-object",
|
href: "/create-object",
|
||||||
icon: BoxIcon,
|
icon: BoxIcon,
|
||||||
accent: "from-cyan-500/20 to-cyan-500/0",
|
iconTile: "border-cyan-400/40 bg-cyan-500/20 text-cyan-300",
|
||||||
iconBg: "bg-cyan-500/25 border-cyan-400/30",
|
hoverBorder: "hover:border-cyan-400/60",
|
||||||
iconColor: "text-cyan-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "3D World",
|
label: "3D World",
|
||||||
description: "Turn a prompt or image into an explorable world.",
|
description: "Turn a prompt or image into an explorable world.",
|
||||||
href: "/create-world",
|
href: "/create-world",
|
||||||
icon: GlobeIcon,
|
icon: GlobeIcon,
|
||||||
accent: "from-teal-500/20 to-teal-500/0",
|
iconTile: "border-teal-400/40 bg-teal-500/20 text-teal-300",
|
||||||
iconBg: "bg-teal-500/25 border-teal-400/30",
|
hoverBorder: "hover:border-teal-400/60",
|
||||||
iconColor: "text-teal-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Edit 3D",
|
label: "Edit 3D",
|
||||||
description: "Compose 3D scenes and render with AI cameras.",
|
description: "Compose 3D scenes and render with AI cameras.",
|
||||||
href: "/edit-3d",
|
href: "/edit-3d",
|
||||||
icon: BoxIcon,
|
icon: BoxIcon,
|
||||||
accent: "from-amber-500/20 to-amber-500/0",
|
iconTile: "border-amber-400/40 bg-amber-500/20 text-amber-300",
|
||||||
iconBg: "bg-amber-500/25 border-amber-400/30",
|
hoverBorder: "hover:border-amber-400/60",
|
||||||
iconColor: "text-amber-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Background Change",
|
label: "Background Change",
|
||||||
description: "Swap or remove backgrounds with AI VFX.",
|
description: "Swap or remove backgrounds with AI VFX.",
|
||||||
href: "/background-change",
|
href: "/background-change",
|
||||||
icon: WandSparklesIcon,
|
icon: WandSparklesIcon,
|
||||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
iconTile: "border-emerald-400/40 bg-emerald-500/20 text-emerald-300",
|
||||||
iconBg: "bg-emerald-500/25 border-emerald-400/30",
|
hoverBorder: "hover:border-emerald-400/60",
|
||||||
iconColor: "text-emerald-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Edit Video",
|
label: "Edit Video",
|
||||||
description: "Trim, arrange, and edit clips on a timeline.",
|
description: "Trim, arrange, and edit clips on a timeline.",
|
||||||
href: "/video-editor",
|
href: "/video-editor",
|
||||||
icon: FilmIcon,
|
icon: FilmIcon,
|
||||||
accent: "from-rose-500/20 to-rose-500/0",
|
iconTile: "border-rose-400/40 bg-rose-500/20 text-rose-300",
|
||||||
iconBg: "bg-rose-500/25 border-rose-400/30",
|
hoverBorder: "hover:border-rose-400/60",
|
||||||
iconColor: "text-rose-300",
|
|
||||||
badge: "BETA",
|
badge: "BETA",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,9 +97,8 @@ const APPS: AppCard[] = [
|
|||||||
description: "Collect references and ideas to steer a generation.",
|
description: "Collect references and ideas to steer a generation.",
|
||||||
href: "/moodboard",
|
href: "/moodboard",
|
||||||
icon: GroupIcon,
|
icon: GroupIcon,
|
||||||
accent: "from-indigo-500/20 to-indigo-500/0",
|
iconTile: "border-indigo-400/40 bg-indigo-500/20 text-indigo-300",
|
||||||
iconBg: "bg-indigo-500/25 border-indigo-400/30",
|
hoverBorder: "hover:border-indigo-400/60",
|
||||||
iconColor: "text-indigo-300",
|
|
||||||
badge: "BETA",
|
badge: "BETA",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,27 +106,35 @@ const APPS: AppCard[] = [
|
|||||||
description: "Grab a still frame from any video.",
|
description: "Grab a still frame from any video.",
|
||||||
href: "/frame-extractor",
|
href: "/frame-extractor",
|
||||||
icon: ImagesIcon,
|
icon: ImagesIcon,
|
||||||
accent: "from-orange-500/20 to-orange-500/0",
|
iconTile: "border-orange-400/40 bg-orange-500/20 text-orange-300",
|
||||||
iconBg: "bg-orange-500/25 border-orange-400/30",
|
hoverBorder: "hover:border-orange-400/60",
|
||||||
iconColor: "text-orange-300",
|
|
||||||
badge: "NEW",
|
badge: "NEW",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const reduceMotion = useReducedMotion();
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
|
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
|
||||||
<Seo
|
<Seo
|
||||||
title="ArtCraft - Create AI Images and Video"
|
title="ArtCraft - Create AI Images and Video"
|
||||||
description="Generate AI images and video with ArtCraft."
|
description="Generate AI images and video with ArtCraft."
|
||||||
/>
|
/>
|
||||||
<Reveal as="h1" inView={false} y={20} className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
|
<Reveal
|
||||||
|
as="h1"
|
||||||
|
inView={false}
|
||||||
|
y={20}
|
||||||
|
className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight"
|
||||||
|
>
|
||||||
What will you <span className="text-primary">craft</span> today?
|
What will you <span className="text-primary">craft</span> today?
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<Reveal as="h2" inView={false} delay={0.08} className="text-sm font-semibold text-white/85 mb-4">
|
<Reveal
|
||||||
|
as="h2"
|
||||||
|
inView={false}
|
||||||
|
delay={0.08}
|
||||||
|
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-4"
|
||||||
|
>
|
||||||
Create
|
Create
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<RevealGroup
|
<RevealGroup
|
||||||
@@ -140,37 +145,36 @@ export function Home() {
|
|||||||
>
|
>
|
||||||
{APPS.map((app) => (
|
{APPS.map((app) => (
|
||||||
<Reveal key={app.href} y={20}>
|
<Reveal key={app.href} y={20}>
|
||||||
<MotionLink
|
<Link
|
||||||
to={app.href}
|
to={app.href}
|
||||||
className="bg-ui-controls/50 group relative flex h-full overflow-hidden rounded-2xl p-5 ring-1 ring-white/[0.06] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] hover:bg-ui-controls hover:ring-white/15"
|
className={`group relative flex h-full border rounded-[3px] border-white/10 bg-white/5 p-5 transition-colors duration-150 ${app.hoverBorder}`}
|
||||||
whileHover={reduceMotion ? undefined : { y: -4 }}
|
|
||||||
whileTap={reduceMotion ? undefined : { scale: 0.985 }}
|
|
||||||
transition={TRANSITION_SPRING}
|
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="relative flex w-full items-start gap-4">
|
<div className="relative flex w-full items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor} shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)] transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-105`}
|
className={`flex h-11 w-11 shrink-0 items-center justify-center border ${app.iconTile}`}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={app.icon} className="text-base" />
|
<DynamicIcon icon={app.icon} className="text-base" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h3 className="text-base font-semibold text-white truncate">
|
<h3 className=" text-sm font-bold uppercase tracking-[0.08em] text-white truncate">
|
||||||
{app.label}
|
{app.label}
|
||||||
</h3>
|
</h3>
|
||||||
{app.badge && (
|
{app.badge && (
|
||||||
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
|
<span
|
||||||
|
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none ${
|
||||||
|
app.badge === "NEW"
|
||||||
|
? "border-purple-400/40 text-purple-300"
|
||||||
|
: "border-amber-400/40 text-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{app.badge}
|
{app.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/[0.04] text-white/40 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-white/10 group-hover:text-white/80 group-hover:translate-x-0.5">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[3px] border border-white/15 text-white/40 transition-colors duration-150 group-hover:border-white/40 group-hover:text-white">
|
||||||
<ArrowRightIcon className="text-xs" />
|
<ArrowRightIcon className="text-xs" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-white/55 leading-snug">
|
<p className="mt-1 text-sm text-white/55 leading-snug">
|
||||||
@@ -178,7 +182,7 @@ export function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MotionLink>
|
</Link>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</RevealGroup>
|
</RevealGroup>
|
||||||
|
|||||||
@@ -35,7 +35,31 @@ import {
|
|||||||
showActionReminder,
|
showActionReminder,
|
||||||
isActionReminderOpen,
|
isActionReminderOpen,
|
||||||
} from "@storyteller/ui-action-reminder-modal";
|
} from "@storyteller/ui-action-reminder-modal";
|
||||||
import { ArrowDownToLineIcon, BoxIcon, CloudIcon, EllipsisIcon, FolderIcon, FolderOpenIcon, FolderPlusIcon, GlobeIcon, Grid3x3Icon, ImageIcon, ImagesIcon, ListIcon, LoaderCircleIcon, MusicIcon, PencilIcon, PlusIcon, RefreshCwIcon, StarIcon, TagIcon, TagsIcon, Trash2Icon, VideoIcon, XIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowDownToLineIcon,
|
||||||
|
BoxIcon,
|
||||||
|
CloudIcon,
|
||||||
|
EllipsisIcon,
|
||||||
|
FolderIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
Grid3x3Icon,
|
||||||
|
ImageIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
ListIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
MusicIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
StarIcon,
|
||||||
|
TagIcon,
|
||||||
|
TagsIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
VideoIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { Lightbox } from "../../components/lightbox/lightbox";
|
import { Lightbox } from "../../components/lightbox/lightbox";
|
||||||
import { toast } from "../../components/toast/toast";
|
import { toast } from "../../components/toast/toast";
|
||||||
@@ -988,7 +1012,7 @@ export default function Library() {
|
|||||||
// ── Not logged in / loading auth ──────────────────────────────────────────
|
// ── Not logged in / loading auth ──────────────────────────────────────────
|
||||||
if (isLoggedIn === false) {
|
if (isLoggedIn === false) {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full w-full bg-[#101014] flex items-center justify-center px-4">
|
<div className="relative min-h-full w-full bg-ui-background flex items-center justify-center px-4">
|
||||||
<div className="text-center space-y-6">
|
<div className="text-center space-y-6">
|
||||||
<h1 className="text-3xl font-medium text-white">My Library</h1>
|
<h1 className="text-3xl font-medium text-white">My Library</h1>
|
||||||
<p className="text-white/60 text-lg max-w-md mx-auto">
|
<p className="text-white/60 text-lg max-w-md mx-auto">
|
||||||
@@ -996,18 +1020,12 @@ export default function Library() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Link to="/login">
|
<Link to="/login">
|
||||||
<Button
|
<Button variant="primary" className="px-6 py-2.5">
|
||||||
variant="primary"
|
|
||||||
className="rounded-full bg-white text-black hover:bg-white/90 text-sm font-semibold px-6 py-2.5"
|
|
||||||
>
|
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/signup">
|
<Link to="/signup">
|
||||||
<Button
|
<Button variant="secondary" className="px-6 py-2.5">
|
||||||
variant="primary"
|
|
||||||
className="rounded-full text-sm font-semibold px-6 py-2.5"
|
|
||||||
>
|
|
||||||
Sign up
|
Sign up
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1019,7 +1037,7 @@ export default function Library() {
|
|||||||
|
|
||||||
if (isLoggedIn === null) {
|
if (isLoggedIn === null) {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full w-full bg-[#101014] flex items-center justify-center">
|
<div className="relative min-h-full w-full bg-ui-background flex items-center justify-center">
|
||||||
<LoadingSpinner className="h-10 w-10 text-white/60" />
|
<LoadingSpinner className="h-10 w-10 text-white/60" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1048,7 +1066,9 @@ export default function Library() {
|
|||||||
gridColumns={4}
|
gridColumns={4}
|
||||||
scrollRoot={null}
|
scrollRoot={null}
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-medium text-white/50 mb-2">{date}</h3>
|
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 mb-2">
|
||||||
|
{date}
|
||||||
|
</h3>
|
||||||
<div className={GRID_CLASS}>
|
<div className={GRID_CLASS}>
|
||||||
{dateItems.map((item) => (
|
{dateItems.map((item) => (
|
||||||
<LibraryTile
|
<LibraryTile
|
||||||
@@ -1084,7 +1104,7 @@ export default function Library() {
|
|||||||
// `select-none` blocks the blue text/image highlight when sweeping a
|
// `select-none` blocks the blue text/image highlight when sweeping a
|
||||||
// marquee or fat-fingering a click; native selection would otherwise also
|
// marquee or fat-fingering a click; native selection would otherwise also
|
||||||
// hijack the drag. Form fields opt back in so dialog inputs stay editable.
|
// hijack the drag. Form fields opt back in so dialog inputs stay editable.
|
||||||
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-[#101014] pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
|
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-ui-background pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
|
||||||
onPointerDown={handleMarqueePointerDown}
|
onPointerDown={handleMarqueePointerDown}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-[1600px]">
|
<div className="mx-auto max-w-[1600px]">
|
||||||
@@ -1094,124 +1114,116 @@ export default function Library() {
|
|||||||
// z-10 keeps the sticky header above the (static) grid tiles while
|
// z-10 keeps the sticky header above the (static) grid tiles while
|
||||||
// staying below the app topbar (z-20), so topbar popovers (credits,
|
// staying below the app topbar (z-20), so topbar popovers (credits,
|
||||||
// avatar) aren't painted under this bar.
|
// avatar) aren't painted under this bar.
|
||||||
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
|
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-ui-background mb-4 sm:mb-6"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Tabs + actions */}
|
{/* Tabs + actions */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
|
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1">
|
||||||
<Link
|
<Link
|
||||||
to="/library"
|
to="/library"
|
||||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
tab === "unsorted"
|
tab === "unsorted"
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "unsorted" && (
|
{tab === "unsorted" && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-tab-indicator"
|
layoutId="library-tab-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Grid3x3Icon
|
<Grid3x3Icon className="relative z-10 text-xs" />
|
||||||
|
|
||||||
className="relative z-10 text-xs" />
|
|
||||||
<span className="relative z-10">All Assets</span>
|
<span className="relative z-10">All Assets</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/library/folders"
|
to="/library/folders"
|
||||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
tab === "folders"
|
tab === "folders"
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "folders" && (
|
{tab === "folders" && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-tab-indicator"
|
layoutId="library-tab-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FolderIcon
|
<FolderIcon className="relative z-10 text-xs" />
|
||||||
|
|
||||||
className="relative z-10 text-xs" />
|
|
||||||
<span className="relative z-10">Folders</span>
|
<span className="relative z-10">Folders</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/library/folderless"
|
to="/library/folderless"
|
||||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
tab === "folderless"
|
tab === "folderless"
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "folderless" && (
|
{tab === "folderless" && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-tab-indicator"
|
layoutId="library-tab-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FolderOpenIcon
|
<FolderOpenIcon className="relative z-10 text-xs" />
|
||||||
|
|
||||||
className="relative z-10 text-xs" />
|
|
||||||
<span className="relative z-10">Unfoldered</span>
|
<span className="relative z-10">Unfoldered</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/library/tags"
|
to="/library/tags"
|
||||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
tab === "tags"
|
tab === "tags"
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "tags" && (
|
{tab === "tags" && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-tab-indicator"
|
layoutId="library-tab-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TagIcon
|
<TagIcon className="relative z-10 text-xs" />
|
||||||
|
|
||||||
className="relative z-10 text-xs" />
|
|
||||||
<span className="relative z-10">Tags</span>
|
<span className="relative z-10">Tags</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{(tab === "unsorted" || tab === "folderless") && (
|
{(tab === "unsorted" || tab === "folderless") && (
|
||||||
<button
|
<button
|
||||||
onClick={refreshRoot}
|
onClick={refreshRoot}
|
||||||
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
className="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||||
title="Refresh library"
|
title="Refresh library"
|
||||||
>
|
>
|
||||||
<RefreshCwIcon
|
<RefreshCwIcon
|
||||||
|
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
|
||||||
className={`text-sm ${initialLoading ? "animate-spin" : ""}`} />
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{tab === "folderless" && (
|
{tab === "folderless" && (
|
||||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
|
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
|
||||||
{FOLDERLESS_CLASS_FILTERS.map((filter) => (
|
{FOLDERLESS_CLASS_FILTERS.map((filter) => (
|
||||||
<button
|
<button
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
onClick={() => setFolderlessClass(filter.id)}
|
onClick={() => setFolderlessClass(filter.id)}
|
||||||
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
folderlessClass === filter.id
|
folderlessClass === filter.id
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{folderlessClass === filter.id && (
|
{folderlessClass === filter.id && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-folderless-filter-indicator"
|
layoutId="library-folderless-filter-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1227,21 +1239,21 @@ export default function Library() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tab === "unsorted" && (
|
{tab === "unsorted" && (
|
||||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
|
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
|
||||||
{FILTERS.map((filter) => (
|
{FILTERS.map((filter) => (
|
||||||
<button
|
<button
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
onClick={() => navigate(filter.route)}
|
onClick={() => navigate(filter.route)}
|
||||||
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
activeFilter === filter.id
|
activeFilter === filter.id
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{activeFilter === filter.id && (
|
{activeFilter === filter.id && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-filter-indicator"
|
layoutId="library-filter-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1261,7 +1273,7 @@ export default function Library() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
icon={FolderPlusIcon}
|
icon={FolderPlusIcon}
|
||||||
onClick={() => openNewFolderModal(null)}
|
onClick={() => openNewFolderModal(null)}
|
||||||
className="rounded-full text-xs sm:text-sm px-3 sm:px-4 py-2"
|
className="px-3 sm:px-4 py-2"
|
||||||
>
|
>
|
||||||
New folder
|
New folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1269,7 +1281,7 @@ export default function Library() {
|
|||||||
{tab === "tags" && !activeTagToken && tags.length > 0 && (
|
{tab === "tags" && !activeTagToken && tags.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{tagView === "list" && (
|
{tagView === "list" && (
|
||||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
|
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["count", "Most used"],
|
["count", "Most used"],
|
||||||
@@ -1279,16 +1291,16 @@ export default function Library() {
|
|||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => setTagSort(id)}
|
onClick={() => setTagSort(id)}
|
||||||
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
tagSort === id
|
tagSort === id
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tagSort === id && (
|
{tagSort === id && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-tag-sort-indicator"
|
layoutId="library-tag-sort-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.32,
|
duration: 0.32,
|
||||||
ease: EASE_EMPHASIS,
|
ease: EASE_EMPHASIS,
|
||||||
@@ -1300,7 +1312,7 @@ export default function Library() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
|
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["cloud", CloudIcon, "Cloud"],
|
["cloud", CloudIcon, "Cloud"],
|
||||||
@@ -1311,16 +1323,16 @@ export default function Library() {
|
|||||||
key={id}
|
key={id}
|
||||||
onClick={() => setTagView(id)}
|
onClick={() => setTagView(id)}
|
||||||
title={label}
|
title={label}
|
||||||
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
|
||||||
tagView === id
|
tagView === id
|
||||||
? "text-white"
|
? "text-black"
|
||||||
: "text-white/60 hover:text-white"
|
: "text-white/60 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tagView === id && (
|
{tagView === id && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="library-tag-view-indicator"
|
layoutId="library-tag-view-indicator"
|
||||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
className="absolute inset-0 rounded-[3px] bg-white"
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.32,
|
duration: 0.32,
|
||||||
ease: EASE_EMPHASIS,
|
ease: EASE_EMPHASIS,
|
||||||
@@ -1368,17 +1380,17 @@ export default function Library() {
|
|||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => startRename(activeFolderId!)}
|
onClick={() => startRename(activeFolderId!)}
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||||
title="Rename folder"
|
title="Rename folder"
|
||||||
>
|
>
|
||||||
<PencilIcon className="text-xs" />
|
<PencilIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openNewFolderModal(activeFolderId)}
|
onClick={() => openNewFolderModal(activeFolderId)}
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||||
title="New subfolder"
|
title="New subfolder"
|
||||||
>
|
>
|
||||||
<FolderPlusIcon className="text-xs" />
|
<FolderPlusIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1404,17 +1416,17 @@ export default function Library() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagRenameTarget(activeTagToken)}
|
onClick={() => setTagRenameTarget(activeTagToken)}
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
|
||||||
title="Rename tag"
|
title="Rename tag"
|
||||||
>
|
>
|
||||||
<PencilIcon className="text-xs" />
|
<PencilIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => confirmDeleteTag(activeTagToken)}
|
onClick={() => confirmDeleteTag(activeTagToken)}
|
||||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
|
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
|
||||||
title="Delete tag"
|
title="Delete tag"
|
||||||
>
|
>
|
||||||
<Trash2Icon className="text-xs" />
|
<Trash2Icon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1427,7 +1439,7 @@ export default function Library() {
|
|||||||
{tab === "folders" && currentSubfolders.length > 0 && (
|
{tab === "folders" && currentSubfolders.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{inFolder && (
|
{inFolder && (
|
||||||
<h3 className="text-sm font-medium text-white/50 mb-2">
|
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 mb-2">
|
||||||
Folders
|
Folders
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
@@ -1451,17 +1463,15 @@ export default function Library() {
|
|||||||
/* ── Folders tab, root: cards only (above) ── */
|
/* ── Folders tab, root: cards only (above) ── */
|
||||||
currentSubfolders.length === 0 && (
|
currentSubfolders.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
|
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
|
||||||
<FolderOpenIcon
|
<FolderOpenIcon className="text-2xl text-white/40" />
|
||||||
|
|
||||||
className="text-2xl text-white/40" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/40 text-sm">No folders yet.</p>
|
<p className="text-white/40 text-sm">No folders yet.</p>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={FolderPlusIcon}
|
icon={FolderPlusIcon}
|
||||||
onClick={() => openNewFolderModal(null)}
|
onClick={() => openNewFolderModal(null)}
|
||||||
className="rounded-full text-sm px-4 py-2"
|
className="px-4 py-2"
|
||||||
>
|
>
|
||||||
New folder
|
New folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1482,18 +1492,12 @@ export default function Library() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link to="/create-image">
|
<Link to="/create-image">
|
||||||
<Button
|
<Button variant="primary" className="px-4 py-2">
|
||||||
variant="primary"
|
|
||||||
className="rounded-full text-sm px-4 py-2"
|
|
||||||
>
|
|
||||||
Create Image
|
Create Image
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/create-video">
|
<Link to="/create-video">
|
||||||
<Button
|
<Button variant="secondary" className="px-4 py-2">
|
||||||
variant="secondary"
|
|
||||||
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
|
|
||||||
>
|
|
||||||
Create Video
|
Create Video
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1517,10 +1521,8 @@ export default function Library() {
|
|||||||
</div>
|
</div>
|
||||||
) : tags.length === 0 ? (
|
) : tags.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
|
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
|
||||||
<TagsIcon
|
<TagsIcon className="text-2xl text-white/40" />
|
||||||
|
|
||||||
className="text-2xl text-white/40" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/40 text-sm">No tags yet.</p>
|
<p className="text-white/40 text-sm">No tags yet.</p>
|
||||||
<p className="text-white/30 text-xs max-w-xs text-center">
|
<p className="text-white/30 text-xs max-w-xs text-center">
|
||||||
@@ -1541,7 +1543,7 @@ export default function Library() {
|
|||||||
{sortedTags.map((t) => (
|
{sortedTags.map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.token}
|
key={t.token}
|
||||||
className="flex items-center rounded-full bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
|
className="flex items-center rounded-[3px] border border-white/15 bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1556,9 +1558,7 @@ export default function Library() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 pl-4 pr-1 py-2 text-sm font-medium text-white"
|
className="flex items-center gap-2 pl-4 pr-1 py-2 text-sm font-medium text-white"
|
||||||
>
|
>
|
||||||
<TagIcon
|
<TagIcon className="text-xs text-violet-400" />
|
||||||
|
|
||||||
className="text-xs text-violet-400" />
|
|
||||||
<span className="max-w-[14rem] truncate">{t.value}</span>
|
<span className="max-w-[14rem] truncate">{t.value}</span>
|
||||||
<span className="text-xs text-white/40">
|
<span className="text-xs text-white/40">
|
||||||
{t.useCount}
|
{t.useCount}
|
||||||
@@ -1576,9 +1576,9 @@ export default function Library() {
|
|||||||
}}
|
}}
|
||||||
aria-label={`Options for tag "${t.value}"`}
|
aria-label={`Options for tag "${t.value}"`}
|
||||||
title="Rename or delete"
|
title="Rename or delete"
|
||||||
className="mr-1.5 flex h-6 w-6 items-center justify-center rounded-full text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
className="mr-1.5 flex h-6 w-6 items-center justify-center text-white/40 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<EllipsisIcon className="text-xs" />
|
<EllipsisIcon className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1609,7 +1609,7 @@ export default function Library() {
|
|||||||
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
|
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="h-4 w-24 rounded mb-3"
|
className="h-4 w-24 mb-3"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 70%)",
|
"linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 70%)",
|
||||||
@@ -1619,10 +1619,7 @@ export default function Library() {
|
|||||||
/>
|
/>
|
||||||
<div className={GRID_CLASS}>
|
<div className={GRID_CLASS}>
|
||||||
{Array.from({ length: 15 }).map((_, i) => (
|
{Array.from({ length: 15 }).map((_, i) => (
|
||||||
<div
|
<div key={i} className="aspect-square overflow-hidden">
|
||||||
key={i}
|
|
||||||
className="aspect-square rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -1648,18 +1645,12 @@ export default function Library() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link to="/create-image">
|
<Link to="/create-image">
|
||||||
<Button
|
<Button variant="primary" className="px-4 py-2">
|
||||||
variant="primary"
|
|
||||||
className="rounded-full text-sm px-4 py-2"
|
|
||||||
>
|
|
||||||
Create Image
|
Create Image
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/create-video">
|
<Link to="/create-video">
|
||||||
<Button
|
<Button variant="secondary" className="px-4 py-2">
|
||||||
variant="secondary"
|
|
||||||
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
|
|
||||||
>
|
|
||||||
Create Video
|
Create Video
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1687,7 +1678,7 @@ export default function Library() {
|
|||||||
<div
|
<div
|
||||||
ref={marqueeRef}
|
ref={marqueeRef}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
className="pointer-events-none fixed z-40 rounded-sm border border-primary/60 bg-primary/10"
|
className="pointer-events-none fixed z-40 border border-white/60 bg-white/10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bulk selection bar — subscribes to the selection store itself so
|
{/* Bulk selection bar — subscribes to the selection store itself so
|
||||||
@@ -1772,7 +1763,7 @@ export default function Library() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
|
className="fixed z-[9999] min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1"
|
||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1783,7 +1774,7 @@ export default function Library() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
|
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFolderStar(
|
setFolderStar(
|
||||||
contextMenu.folderId,
|
contextMenu.folderId,
|
||||||
@@ -1793,8 +1784,8 @@ export default function Library() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StarIcon
|
<StarIcon
|
||||||
|
className={`w-4 ${menuFolder?.hasStar ? "text-amber-400" : "text-base-fg/40"}`}
|
||||||
className={`w-4 ${menuFolder?.hasStar ? "text-amber-400" : "text-base-fg/40"}`} />
|
/>
|
||||||
<span>{menuFolder?.hasStar ? "Unstar" : "Star"}</span>
|
<span>{menuFolder?.hasStar ? "Unstar" : "Star"}</span>
|
||||||
</button>
|
</button>
|
||||||
<FolderColorRow
|
<FolderColorRow
|
||||||
@@ -1809,32 +1800,32 @@ export default function Library() {
|
|||||||
})()}
|
})()}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
|
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openNewFolderModal(contextMenu.folderId);
|
openNewFolderModal(contextMenu.folderId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FolderPlusIcon className="w-4" />
|
<FolderPlusIcon className="w-4" />
|
||||||
<span>New subfolder</span>
|
<span>New subfolder</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
|
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
|
||||||
onClick={() => startRename(contextMenu.folderId)}
|
onClick={() => startRename(contextMenu.folderId)}
|
||||||
>
|
>
|
||||||
<PencilIcon className="w-4" />
|
<PencilIcon className="w-4" />
|
||||||
<span>Rename</span>
|
<span>Rename</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
|
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const folderId = contextMenu.folderId;
|
const folderId = contextMenu.folderId;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
confirmDeleteFolder(folderId);
|
confirmDeleteFolder(folderId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="w-4" />
|
<Trash2Icon className="w-4" />
|
||||||
<span>Delete folder</span>
|
<span>Delete folder</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1855,27 +1846,27 @@ export default function Library() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
|
className="fixed z-[9999] min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1"
|
||||||
style={{ left: tagContextMenu.x, top: tagContextMenu.y }}
|
style={{ left: tagContextMenu.x, top: tagContextMenu.y }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
|
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
|
||||||
onClick={() => setTagRenameTarget(tagContextMenu.tagToken)}
|
onClick={() => setTagRenameTarget(tagContextMenu.tagToken)}
|
||||||
>
|
>
|
||||||
<PencilIcon className="w-4" />
|
<PencilIcon className="w-4" />
|
||||||
<span>Rename</span>
|
<span>Rename</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
|
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const token = tagContextMenu.tagToken;
|
const token = tagContextMenu.tagToken;
|
||||||
setTagContextMenu(null);
|
setTagContextMenu(null);
|
||||||
confirmDeleteTag(token);
|
confirmDeleteTag(token);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="w-4" />
|
<Trash2Icon className="w-4" />
|
||||||
<span>Delete tag</span>
|
<span>Delete tag</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2051,7 +2042,7 @@ const SEND_TO_TARGETS: {
|
|||||||
// Shared styling for the bar's action pills. `whitespace-nowrap` keeps labels
|
// Shared styling for the bar's action pills. `whitespace-nowrap` keeps labels
|
||||||
// on a single line — the bar grows instead of squishing buttons into two rows.
|
// on a single line — the bar grows instead of squishing buttons into two rows.
|
||||||
const BAR_BUTTON_CLASS =
|
const BAR_BUTTON_CLASS =
|
||||||
"flex items-center gap-2 whitespace-nowrap rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors";
|
"flex items-center gap-2 whitespace-nowrap border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white hover:bg-ui-controls/90 transition-colors";
|
||||||
|
|
||||||
interface BulkSelectionBarProps {
|
interface BulkSelectionBarProps {
|
||||||
allItems: GalleryItem[];
|
allItems: GalleryItem[];
|
||||||
@@ -2180,7 +2171,7 @@ function BulkSelectionBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-no-marquee
|
data-no-marquee
|
||||||
className="fixed bottom-20 sm:bottom-4 z-30 flex w-fit -translate-x-1/2 items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur"
|
className="fixed bottom-20 sm:bottom-4 z-30 flex w-fit -translate-x-1/2 items-center gap-2 border border-ui-panel-border bg-ui-panel px-2.5 py-2"
|
||||||
style={{
|
style={{
|
||||||
// Center within the content area (viewport minus the app sidebar).
|
// Center within the content area (viewport minus the app sidebar).
|
||||||
left: "calc(50% + var(--ac-sidebar-offset, 0px) / 2)",
|
left: "calc(50% + var(--ac-sidebar-offset, 0px) / 2)",
|
||||||
@@ -2191,7 +2182,7 @@ function BulkSelectionBar({
|
|||||||
<BulkThumb key={si.id} item={si} />
|
<BulkThumb key={si.id} item={si} />
|
||||||
))}
|
))}
|
||||||
{selectedItems.length > 4 && (
|
{selectedItems.length > 4 && (
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 border-ui-panel bg-black/20">
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center border-2 border-ui-panel bg-black/20">
|
||||||
<span className="text-[11px] text-white/70">
|
<span className="text-[11px] text-white/70">
|
||||||
+{selectedItems.length - 4}
|
+{selectedItems.length - 4}
|
||||||
</span>
|
</span>
|
||||||
@@ -2225,8 +2216,8 @@ function BulkSelectionBar({
|
|||||||
className="fixed inset-0 z-[59]"
|
className="fixed inset-0 z-[59]"
|
||||||
onClick={() => setSendToOpen(false)}
|
onClick={() => setSendToOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
|
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
|
||||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||||
Use as reference in
|
Use as reference in
|
||||||
</div>
|
</div>
|
||||||
{SEND_TO_TARGETS.filter(
|
{SEND_TO_TARGETS.filter(
|
||||||
@@ -2240,7 +2231,7 @@ function BulkSelectionBar({
|
|||||||
sendToPrompt(selectedItems, target.destination, navigate);
|
sendToPrompt(selectedItems, target.destination, navigate);
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
|
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={target.icon} className="w-4 text-xs" />
|
<DynamicIcon icon={target.icon} className="w-4 text-xs" />
|
||||||
<span>{target.label}</span>
|
<span>{target.label}</span>
|
||||||
@@ -2265,7 +2256,7 @@ function BulkSelectionBar({
|
|||||||
}}
|
}}
|
||||||
className={BAR_BUTTON_CLASS}
|
className={BAR_BUTTON_CLASS}
|
||||||
>
|
>
|
||||||
<ImagesIcon className="text-xs" />
|
<ImagesIcon className="text-xs" />
|
||||||
<span className="hidden sm:inline">Extract frames</span>
|
<span className="hidden sm:inline">Extract frames</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -2278,7 +2269,7 @@ function BulkSelectionBar({
|
|||||||
onClick={() => setPopoverOpen((v) => !v)}
|
onClick={() => setPopoverOpen((v) => !v)}
|
||||||
className={BAR_BUTTON_CLASS}
|
className={BAR_BUTTON_CLASS}
|
||||||
>
|
>
|
||||||
<FolderPlusIcon className="text-xs" />
|
<FolderPlusIcon className="text-xs" />
|
||||||
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
|
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
|
||||||
<span className="hidden sm:inline">Add to folder</span>
|
<span className="hidden sm:inline">Add to folder</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2288,8 +2279,8 @@ function BulkSelectionBar({
|
|||||||
className="fixed inset-0 z-[59]"
|
className="fixed inset-0 z-[59]"
|
||||||
onClick={() => setPopoverOpen(false)}
|
onClick={() => setPopoverOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-full right-0 z-[60] mb-2 max-h-72 w-56 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
|
<div className="absolute bottom-full right-0 z-[60] mb-2 max-h-72 w-56 overflow-y-auto rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
|
||||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||||
Folders
|
Folders
|
||||||
</div>
|
</div>
|
||||||
{sortedFolders.length === 0 ? (
|
{sortedFolders.length === 0 ? (
|
||||||
@@ -2306,10 +2297,9 @@ function BulkSelectionBar({
|
|||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
|
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
|
||||||
>
|
>
|
||||||
<FolderIcon
|
<FolderIcon
|
||||||
|
|
||||||
className={
|
className={
|
||||||
folder.colorCode ? "text-xs" : "text-xs text-primary"
|
folder.colorCode ? "text-xs" : "text-xs text-primary"
|
||||||
}
|
}
|
||||||
@@ -2317,7 +2307,8 @@ function BulkSelectionBar({
|
|||||||
folder.colorCode
|
folder.colorCode
|
||||||
? { color: folder.colorCode }
|
? { color: folder.colorCode }
|
||||||
: undefined
|
: undefined
|
||||||
} />
|
}
|
||||||
|
/>
|
||||||
<span className="truncate">{folder.name}</span>
|
<span className="truncate">{folder.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@@ -2329,9 +2320,9 @@ function BulkSelectionBar({
|
|||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
onNewFolder(activeFolderId, Array.from(ids));
|
onNewFolder(activeFolderId, Array.from(ids));
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white/70 hover:bg-ui-controls/50 transition-colors"
|
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white/70 hover:bg-ui-controls/50 transition-colors"
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-4 text-xs" />
|
<PlusIcon className="w-4 text-xs" />
|
||||||
<span>Create new folder</span>
|
<span>Create new folder</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2353,7 +2344,7 @@ function BulkSelectionBar({
|
|||||||
}}
|
}}
|
||||||
className={BAR_BUTTON_CLASS}
|
className={BAR_BUTTON_CLASS}
|
||||||
>
|
>
|
||||||
<TagsIcon className="text-xs" />
|
<TagsIcon className="text-xs" />
|
||||||
<span className="hidden sm:inline">Add tags</span>
|
<span className="hidden sm:inline">Add tags</span>
|
||||||
</button>
|
</button>
|
||||||
{tagsOpen && (
|
{tagsOpen && (
|
||||||
@@ -2362,8 +2353,8 @@ function BulkSelectionBar({
|
|||||||
className="fixed inset-0 z-[59]"
|
className="fixed inset-0 z-[59]"
|
||||||
onClick={() => setTagsOpen(false)}
|
onClick={() => setTagsOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
|
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
|
||||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||||
Add tags
|
Add tags
|
||||||
</div>
|
</div>
|
||||||
<TagChipInput
|
<TagChipInput
|
||||||
@@ -2380,7 +2371,7 @@ function BulkSelectionBar({
|
|||||||
disabled={tagChips.length === 0}
|
disabled={tagChips.length === 0}
|
||||||
loading={addingTags}
|
loading={addingTags}
|
||||||
onClick={handleAddTags}
|
onClick={handleAddTags}
|
||||||
className="mt-2 w-full justify-center rounded-md text-sm py-1.5"
|
className="mt-2 w-full justify-center py-1.5"
|
||||||
>
|
>
|
||||||
Tag {ids.size} {ids.size === 1 ? "item" : "items"}
|
Tag {ids.size} {ids.size === 1 ? "item" : "items"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2407,16 +2398,16 @@ function BulkSelectionBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDeleteSelected}
|
onClick={onDeleteSelected}
|
||||||
className="flex items-center gap-2 whitespace-nowrap rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
|
className="flex items-center gap-2 whitespace-nowrap bg-red/90 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white hover:bg-red transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2Icon className="text-xs" />
|
<Trash2Icon className="text-xs" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
aria-label="Clear selection"
|
aria-label="Clear selection"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white hover:bg-ui-controls/90 transition-colors"
|
className="flex h-8 w-8 items-center justify-center border border-white/15 bg-ui-controls/60 text-white hover:bg-ui-controls/90 transition-colors"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -2438,7 +2429,7 @@ function BulkThumb({ item }: { item: GalleryItem }) {
|
|||||||
: ImageIcon;
|
: ImageIcon;
|
||||||
const showImage = !!item.thumbnail && !failed;
|
const showImage = !!item.thumbnail && !failed;
|
||||||
return (
|
return (
|
||||||
<div className="-ml-2 h-8 w-8 flex-shrink-0 overflow-hidden rounded border-2 border-ui-panel bg-black/30 first:ml-0">
|
<div className="-ml-2 h-8 w-8 flex-shrink-0 overflow-hidden border-2 border-ui-panel bg-black/30 first:ml-0">
|
||||||
{showImage ? (
|
{showImage ? (
|
||||||
<img
|
<img
|
||||||
src={item.thumbnail!}
|
src={item.thumbnail!}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { Input } from "@storyteller/ui-input";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { UsersApi } from "@storyteller/api";
|
import { UsersApi } from "@storyteller/api";
|
||||||
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
|
import {
|
||||||
|
AuthHeader,
|
||||||
|
AuthFooter,
|
||||||
|
GoogleLoginButton,
|
||||||
|
} from "../../components/auth";
|
||||||
import Seo from "../../components/seo";
|
import Seo from "../../components/seo";
|
||||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||||
import { refreshSession } from "../../lib/session";
|
import { refreshSession } from "../../lib/session";
|
||||||
@@ -76,7 +80,7 @@ const Login = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -93,7 +97,7 @@ const Login = () => {
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<Reveal className="space-y-2">
|
<Reveal className="space-y-2">
|
||||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 ml-1">
|
||||||
Email or Username
|
Email or Username
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -101,12 +105,12 @@ const Login = () => {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@example.com or username"
|
placeholder="you@example.com or username"
|
||||||
inputClassName="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"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal className="space-y-2">
|
<Reveal className="space-y-2">
|
||||||
<div className="flex justify-between items-center ml-1">
|
<div className="flex justify-between items-center ml-1">
|
||||||
<label className="text-xs font-semibold text-white/70">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<Link
|
<Link
|
||||||
@@ -122,7 +126,7 @@ const Login = () => {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Min. 8 characters"
|
placeholder="Min. 8 characters"
|
||||||
inputClassName="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 pr-12"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -137,12 +141,12 @@ const Login = () => {
|
|||||||
|
|
||||||
<Reveal className="pt-2">
|
<Reveal className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="w-full justify-center h-10"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Log in"
|
"Log in"
|
||||||
)}
|
)}
|
||||||
@@ -157,9 +161,9 @@ const Login = () => {
|
|||||||
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
|
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
|
||||||
<Reveal className="relative my-6 flex items-center justify-center">
|
<Reveal className="relative my-6 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-white/10" />
|
<div className="w-full border-t border-white/15" />
|
||||||
</div>
|
</div>
|
||||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
<span className="relative bg-[#101014] px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||||
or
|
or
|
||||||
</span>
|
</span>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export default function MediaPage() {
|
|||||||
description="View shared media from ArtCraft."
|
description="View shared media from ArtCraft."
|
||||||
/>
|
/>
|
||||||
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
|
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
|
||||||
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
|
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden border border-white/15">
|
||||||
{/* Media Preview Area */}
|
{/* Media Preview Area */}
|
||||||
<div className="relative flex-1 bg-black/20 backdrop-blur-lg flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
|
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
|
||||||
{mediaRecordLoading ? (
|
{mediaRecordLoading ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<LoadingSpinner className="h-12 w-12 text-white/60" />
|
<LoadingSpinner className="h-12 w-12 text-white/60" />
|
||||||
|
|||||||
@@ -285,30 +285,25 @@ const Onboarding = () => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
|
<div className="relative min-h-screen bg-ui-background text-white flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep === "complete") {
|
if (currentStep === "complete") {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white">
|
<div className="relative min-h-screen bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Setup Complete - ArtCraft"
|
title="Setup Complete - ArtCraft"
|
||||||
description="Your account setup is complete."
|
description="Your account setup is complete."
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
|
||||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||||
<div className="max-w-md w-full text-center">
|
<div className="max-w-md w-full text-center">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
|
||||||
<CircleCheckIcon
|
<CircleCheckIcon className="text-5xl text-green-400" />
|
||||||
|
|
||||||
className="text-5xl text-green-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-medium mb-4 text-white">
|
<h1 className="text-3xl md:text-4xl font-medium mb-4 text-white">
|
||||||
@@ -322,28 +317,20 @@ const Onboarding = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white">
|
<div className="relative min-h-screen bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Complete Your Setup - ArtCraft"
|
title="Complete Your Setup - ArtCraft"
|
||||||
description="Just a few more details to get you started with ArtCraft."
|
description="Just a few more details to get you started with ArtCraft."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
|
||||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
{isNewAccount && (
|
{isNewAccount && (
|
||||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-start gap-3 animate-fade-in shadow-lg shadow-blue-900/10">
|
<div className="bg-white/5 border border-white/15 p-4 mb-6 flex items-start gap-3 animate-fade-in">
|
||||||
<InfoIcon
|
<InfoIcon className="text-white/70 mt-1 flex-shrink-0 text-lg" />
|
||||||
|
|
||||||
className="text-blue-400 mt-1 flex-shrink-0 text-lg" />
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-blue-200 font-medium mb-1">
|
<h3 className="text-white font-medium mb-1">Account Created</h3>
|
||||||
Account Created
|
<p className="text-white/60 text-sm leading-relaxed">
|
||||||
</h3>
|
|
||||||
<p className="text-blue-300/80 text-sm leading-relaxed">
|
|
||||||
We've automatically created an account for you. Please
|
We've automatically created an account for you. Please
|
||||||
complete your setup below to secure it.
|
complete your setup below to secure it.
|
||||||
</p>
|
</p>
|
||||||
@@ -351,7 +338,7 @@ const Onboarding = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
|
<div className="bg-[#101014] border border-white/15 p-6 py-8">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-2xl font-semibold mb-2">
|
<h1 className="text-2xl font-semibold mb-2">
|
||||||
{currentStep === "password" &&
|
{currentStep === "password" &&
|
||||||
@@ -376,7 +363,7 @@ const Onboarding = () => {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -384,7 +371,7 @@ const Onboarding = () => {
|
|||||||
{currentStep === "password" && (
|
{currentStep === "password" && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -393,13 +380,13 @@ const Onboarding = () => {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Confirm Password
|
Confirm Password
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -408,7 +395,7 @@ const Onboarding = () => {
|
|||||||
value={passwordConfirmation}
|
value={passwordConfirmation}
|
||||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||||
required
|
required
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
@@ -418,7 +405,7 @@ const Onboarding = () => {
|
|||||||
|
|
||||||
{currentStep === "email" && (
|
{currentStep === "email" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Email Address
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -427,7 +414,7 @@ const Onboarding = () => {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +422,7 @@ const Onboarding = () => {
|
|||||||
|
|
||||||
{currentStep === "username" && (
|
{currentStep === "username" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -443,7 +430,7 @@ const Onboarding = () => {
|
|||||||
id="username"
|
id="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||||
placeholder="Your display name"
|
placeholder="Your display name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,13 +439,11 @@ const Onboarding = () => {
|
|||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="flex-1 justify-center h-10"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
|
||||||
className="animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
"Continue"
|
"Continue"
|
||||||
)}
|
)}
|
||||||
@@ -467,7 +452,7 @@ const Onboarding = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10 px-6"
|
className="bg-transparent border border-white/15 hover:bg-white/10 text-white justify-center h-10 px-6"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,14 +8,16 @@
|
|||||||
// - start from a blank canvas → BlankCanvasModal (from the lib)
|
// - start from a blank canvas → BlankCanvasModal (from the lib)
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ImagesIcon, LoaderCircleIcon, MaximizeIcon, PencilIcon } from "lucide-react";
|
import {
|
||||||
|
ImagesIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
MaximizeIcon,
|
||||||
|
PencilIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { UploaderStates } from "@storyteller/common";
|
import { UploaderStates } from "@storyteller/common";
|
||||||
import { MediaFilesApi } from "@storyteller/api";
|
import { MediaFilesApi } from "@storyteller/api";
|
||||||
import {
|
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||||
GalleryModal,
|
|
||||||
type GalleryItem,
|
|
||||||
} from "@storyteller/ui-gallery-modal";
|
|
||||||
import {
|
import {
|
||||||
BlankCanvasModal,
|
BlankCanvasModal,
|
||||||
type BaseSelectorImage,
|
type BaseSelectorImage,
|
||||||
@@ -141,10 +143,10 @@ export function BaseImageSelector({
|
|||||||
}}
|
}}
|
||||||
onDragLeave={() => setIsDragging(false)}
|
onDragLeave={() => setIsDragging(false)}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed p-8 text-center transition-colors ${
|
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-[3px] border border-dashed p-8 text-center transition-colors ${
|
||||||
isDragging
|
isDragging
|
||||||
? "border-primary-400 bg-primary/10"
|
? "border-white bg-white/10"
|
||||||
: "border-ui-panel-border bg-ui-background hover:border-primary-400/50"
|
: "border-white/15 bg-ui-background hover:border-white/40"
|
||||||
} ${busy ? "pointer-events-none opacity-60" : ""}`}
|
} ${busy ? "pointer-events-none opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -154,7 +156,7 @@ export function BaseImageSelector({
|
|||||||
onChange={onFileInput}
|
onChange={onFileInput}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary/30 text-primary-300">
|
<div className="flex h-14 w-14 items-center justify-center border border-primary-400/30 bg-primary/30 text-primary-300">
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={busy ? LoaderCircleIcon : PencilIcon}
|
icon={busy ? LoaderCircleIcon : PencilIcon}
|
||||||
className={`text-xl ${busy ? "animate-spin" : ""}`}
|
className={`text-xl ${busy ? "animate-spin" : ""}`}
|
||||||
@@ -174,7 +176,7 @@ export function BaseImageSelector({
|
|||||||
setIsGalleryOpen(true);
|
setIsGalleryOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="flex items-center gap-2 rounded-lg border border-ui-controls-border bg-ui-controls px-3 py-2 text-sm font-medium text-base-fg transition-colors hover:bg-ui-controls/80"
|
className="flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
|
||||||
>
|
>
|
||||||
<ImagesIcon />
|
<ImagesIcon />
|
||||||
Pick from Library
|
Pick from Library
|
||||||
@@ -186,7 +188,7 @@ export function BaseImageSelector({
|
|||||||
setIsBlankCanvasOpen(true);
|
setIsBlankCanvasOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="flex items-center gap-2 rounded-lg border border-ui-controls-border bg-ui-controls px-3 py-2 text-sm font-medium text-base-fg transition-colors hover:bg-ui-controls/80"
|
className="flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
|
||||||
>
|
>
|
||||||
<MaximizeIcon />
|
<MaximizeIcon />
|
||||||
Blank Canvas
|
Blank Canvas
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function PageDraw() {
|
|||||||
transform: "translateZ(0)",
|
transform: "translateZ(0)",
|
||||||
// Recolors the lib's `.pegboard-bg` canvas backdrop (the surface
|
// Recolors the lib's `.pegboard-bg` canvas backdrop (the surface
|
||||||
// behind the artboard) to the webapp's page color.
|
// behind the artboard) to the webapp's page color.
|
||||||
"--st-canvas": "#101014",
|
"--st-canvas": "#0b0b0c",
|
||||||
// With the global top bar hidden but the bottom prompt bar still
|
// With the global top bar hidden but the bottom prompt bar still
|
||||||
// present, the usable canvas area's center sits above the geometric
|
// present, the usable canvas area's center sits above the geometric
|
||||||
// center. Pull the side panels (toolbar + history) up by half the
|
// center. Pull the side panels (toolbar + history) up by half the
|
||||||
@@ -39,7 +39,7 @@ export default function PageDraw() {
|
|||||||
>
|
>
|
||||||
<PageDrawLib
|
<PageDrawLib
|
||||||
adapter={adapter}
|
adapter={adapter}
|
||||||
backgroundClassName="bg-[#101014]"
|
backgroundClassName="bg-ui-background"
|
||||||
fillParentHeight={!isMobile}
|
fillParentHeight={!isMobile}
|
||||||
// Cost/help live in the webapp's own chrome, not over the canvas.
|
// Cost/help live in the webapp's own chrome, not over the canvas.
|
||||||
showBottomRightControls={false}
|
showBottomRightControls={false}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ImageIcon, MaximizeIcon, MinimizeIcon, VideoIcon, WandSparklesIcon, XIcon } from "lucide-react";
|
import {
|
||||||
|
ImageIcon,
|
||||||
|
MaximizeIcon,
|
||||||
|
MinimizeIcon,
|
||||||
|
VideoIcon,
|
||||||
|
WandSparklesIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
|
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
|
||||||
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
|
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||||
@@ -154,7 +161,7 @@ export function DemoOutputOverlay({
|
|||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 animate-in fade-in duration-200">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close expanded view"
|
aria-label="Close expanded view"
|
||||||
@@ -209,9 +216,9 @@ function ShowOutputPill({ onClick }: { onClick: () => void }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label="Show rendered output"
|
aria-label="Show rendered output"
|
||||||
className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 rounded-xl border border-ui-controls-border bg-ui-controls px-3 py-1.5 text-sm font-medium text-base-fg shadow-xl transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4"
|
className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4"
|
||||||
>
|
>
|
||||||
<ImageIcon className="h-3 w-3 text-primary" />
|
<ImageIcon className="h-3 w-3 text-primary" />
|
||||||
Show output
|
Show output
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -223,9 +230,9 @@ interface ViewToggleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single-button sliding toggle. The whole pill is the click target —
|
// Single-button sliding toggle. The whole pill is the click target —
|
||||||
// clicking anywhere on it flips between video and image. A primary-
|
// clicking anywhere on it flips between video and image. A white
|
||||||
// coloured indicator translates between the two icons to show which
|
// indicator translates between the two icons to show which side is
|
||||||
// side is currently active.
|
// currently active.
|
||||||
function ViewToggle({ view, onChange }: ViewToggleProps) {
|
function ViewToggle({ view, onChange }: ViewToggleProps) {
|
||||||
const next: View = view === "video" ? "image" : "video";
|
const next: View = view === "video" ? "image" : "video";
|
||||||
return (
|
return (
|
||||||
@@ -233,11 +240,11 @@ function ViewToggle({ view, onChange }: ViewToggleProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={view === "video" ? "Switch to image" : "Switch to video"}
|
aria-label={view === "video" ? "Switch to image" : "Switch to video"}
|
||||||
onClick={() => onChange(next)}
|
onClick={() => onChange(next)}
|
||||||
className="relative flex h-7 w-14 shrink-0 items-center rounded-full border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
|
className="relative flex h-7 w-14 shrink-0 items-center border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
|
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] bg-white transition-transform duration-200 ease-out"
|
||||||
style={{
|
style={{
|
||||||
transform: view === "image" ? "translateX(100%)" : "translateX(0%)",
|
transform: view === "image" ? "translateX(100%)" : "translateX(0%)",
|
||||||
}}
|
}}
|
||||||
@@ -258,7 +265,7 @@ function ToggleIcon({ icon, active }: ToggleIconProps) {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
"relative z-10 flex h-full flex-1 items-center justify-center transition-colors " +
|
"relative z-10 flex h-full flex-1 items-center justify-center transition-colors " +
|
||||||
(active ? "text-white" : "text-base-fg/60")
|
(active ? "text-black" : "text-base-fg/60")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={icon} className="h-3 w-3" />
|
<DynamicIcon icon={icon} className="h-3 w-3" />
|
||||||
@@ -297,14 +304,12 @@ function Card({
|
|||||||
onToggleExpanded();
|
onToggleExpanded();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="glass pointer-events-auto overflow-hidden rounded-xl shadow-xl border-2 border-primary">
|
<div className="glass pointer-events-auto overflow-hidden border border-primary">
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-ui-controls-border/60 px-3 py-2">
|
<div className="flex items-center justify-between gap-3 border-b border-ui-controls-border/60 px-3 py-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<WandSparklesIcon
|
<WandSparklesIcon className="h-3 w-3 shrink-0 text-primary" />
|
||||||
|
|
||||||
className="h-3 w-3 shrink-0 text-primary" />
|
|
||||||
<div className="min-w-0 leading-tight">
|
<div className="min-w-0 leading-tight">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
|
||||||
Rendered Output
|
Rendered Output
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[10px] text-base-fg/50">
|
<div className="truncate text-[10px] text-base-fg/50">
|
||||||
@@ -322,9 +327,9 @@ function Card({
|
|||||||
? "Collapse to picture-in-picture"
|
? "Collapse to picture-in-picture"
|
||||||
: "Hide rendered output"
|
: "Hide rendered output"
|
||||||
}
|
}
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
|
className="flex h-6 w-6 shrink-0 items-center justify-center text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,8 +344,9 @@ function Card({
|
|||||||
onToggleExpanded();
|
onToggleExpanded();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`relative aspect-video w-full bg-black/40 ${isExpanded ? "cursor-zoom-out" : "cursor-zoom-in"
|
className={`relative aspect-video w-full bg-black/40 ${
|
||||||
}`}
|
isExpanded ? "cursor-zoom-out" : "cursor-zoom-in"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<button
|
<button
|
||||||
@@ -349,8 +355,10 @@ function Card({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleExpanded();
|
onToggleExpanded();
|
||||||
}}
|
}}
|
||||||
aria-label={isExpanded ? "Collapse output view" : "Expand output view"}
|
aria-label={
|
||||||
className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-md border border-white/10 bg-black/60 text-white/80 shadow-lg backdrop-blur-md transition-colors duration-150 hover:bg-black/80 hover:text-white"
|
isExpanded ? "Collapse output view" : "Expand output view"
|
||||||
|
}
|
||||||
|
className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center border border-white/15 bg-black/60 text-white/80 transition-colors duration-150 hover:bg-black/80 hover:text-white"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={isExpanded ? MinimizeIcon : MaximizeIcon}
|
icon={isExpanded ? MinimizeIcon : MaximizeIcon}
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ function CollapsedPill({ onClick }: { onClick: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex items-center gap-2.5 rounded-full border border-primary/40 bg-gradient-to-r from-primary/90 to-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:from-primary hover:to-primary animate-in fade-in slide-in-from-bottom-2 duration-300"
|
className="flex items-center gap-2.5 bg-white px-5 py-2.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors hover:bg-white/80 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||||
>
|
>
|
||||||
<BoxesIcon className="h-3.5 w-3.5" />
|
<BoxesIcon className="h-3.5 w-3.5" />
|
||||||
<span>See other demo scenes</span>
|
<span>See other demo scenes</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -79,13 +79,11 @@ interface ExpandedPanelProps {
|
|||||||
|
|
||||||
function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
|
function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-xl border border-ui-controls-border shadow-2xl animate-in fade-in slide-in-from-bottom-2 duration-200">
|
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-[3px] border border-ui-controls-border animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
<div className="flex items-center justify-between border-b border-ui-controls-border/60 px-4 py-2.5">
|
<div className="flex items-center justify-between border-b border-ui-controls-border/60 px-4 py-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LayersIcon
|
<LayersIcon className="h-3 w-3 text-primary" />
|
||||||
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
|
||||||
className="h-3 w-3 text-primary" />
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
|
|
||||||
Other demo scenes
|
Other demo scenes
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,9 +91,9 @@ function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close other scenes"
|
aria-label="Close other scenes"
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
|
className="flex h-6 w-6 items-center justify-center text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 p-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 p-3 sm:grid-cols-3">
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ function MobileGate() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center p-6">
|
<div className="flex h-full w-full items-center justify-center p-6">
|
||||||
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/25 text-amber-300">
|
<div className="flex h-14 w-14 items-center justify-center border border-amber-400/30 bg-amber-500/25 text-amber-300">
|
||||||
<MonitorIcon className="text-xl" />
|
<MonitorIcon className="text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-semibold text-white">
|
<h1 className="text-xl font-semibold text-white">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// One reusable card for the edit-3D splash. The blank-scene variant
|
// One reusable card for the edit-3D splash. The blank-scene variant
|
||||||
// renders a dashed plus tile; the example variant renders a thumbnail
|
// renders a dashed plus tile; the example variant renders a thumbnail
|
||||||
// of the scene's rendered output (with the prompt's first reference
|
// of the scene's rendered output (with the prompt's first reference
|
||||||
// image cross-fading in on hover) over the accent gradient fallback.
|
// image cross-fading in on hover) over the accent surface fallback.
|
||||||
// Sharing one component keeps the hover/border/typography treatment
|
// Sharing one component keeps the hover/border/typography treatment
|
||||||
// consistent across the grid.
|
// consistent across the grid.
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ interface ExampleCardProps {
|
|||||||
type SceneSplashCardProps = BlankCardProps | ExampleCardProps;
|
type SceneSplashCardProps = BlankCardProps | ExampleCardProps;
|
||||||
|
|
||||||
const SHELL_CLASS =
|
const SHELL_CLASS =
|
||||||
"group flex flex-col overflow-hidden rounded-xl border bg-white/[0.03] text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
|
"group flex flex-col overflow-hidden border bg-white/[0.03] text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60";
|
||||||
|
|
||||||
export function SceneSplashCard(props: SceneSplashCardProps) {
|
export function SceneSplashCard(props: SceneSplashCardProps) {
|
||||||
return (
|
return (
|
||||||
@@ -53,8 +53,8 @@ function CardThumbnail(props: SceneSplashCardProps) {
|
|||||||
if (props.variant === "blank") {
|
if (props.variant === "blank") {
|
||||||
return (
|
return (
|
||||||
<div className="flex aspect-video items-center justify-center bg-white/[0.02]">
|
<div className="flex aspect-video items-center justify-center bg-white/[0.02]">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
|
<div className="flex h-9 w-9 items-center justify-center bg-white/5 text-white/55 transition-colors group-hover:bg-white/10 group-hover:text-white">
|
||||||
<PlusIcon className="text-sm" />
|
<PlusIcon className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -78,10 +78,7 @@ function ExampleThumbnail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge("relative aspect-video overflow-hidden", accentClass)}
|
||||||
"relative aspect-video overflow-hidden bg-gradient-to-br",
|
|
||||||
accentClass,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<img
|
<img
|
||||||
@@ -99,7 +96,7 @@ function ExampleThumbnail({
|
|||||||
className="absolute inset-0 h-full w-full select-none object-cover opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
className="absolute inset-0 h-full w-full select-none object-cover opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="absolute top-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white/75 backdrop-blur-sm">
|
<span className="absolute top-2 left-2 bg-black/55 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/75">
|
||||||
Preview
|
Preview
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +122,7 @@ function CardCaption({
|
|||||||
// - previewUrl: the rendered output image (the "after"), shown by default
|
// - previewUrl: the rendered output image (the "after"), shown by default
|
||||||
// - hoverUrl: the prompt's first reference image (the "before"), shown
|
// - hoverUrl: the prompt's first reference image (the "before"), shown
|
||||||
// on hover so users can see what the scene generated FROM
|
// on hover so users can see what the scene generated FROM
|
||||||
// Failures stay silent — the card's accent gradient is a graceful fallback.
|
// Failures stay silent, the card's accent surface is a graceful fallback.
|
||||||
function useSceneCardImages(outputToken: string): {
|
function useSceneCardImages(outputToken: string): {
|
||||||
previewUrl: string | null;
|
previewUrl: string | null;
|
||||||
hoverUrl: string | null;
|
hoverUrl: string | null;
|
||||||
@@ -157,7 +154,7 @@ function useSceneCardImages(outputToken: string): {
|
|||||||
promptResp?.data?.maybe_context_images?.[0]?.media_links?.cdn_url;
|
promptResp?.data?.maybe_context_images?.[0]?.media_links?.cdn_url;
|
||||||
if (firstRef) setHoverUrl(firstRef);
|
if (firstRef) setHoverUrl(firstRef);
|
||||||
} catch {
|
} catch {
|
||||||
// Decorative imagery — gradient fallback is acceptable.
|
// Decorative imagery, the fallback surface is acceptable.
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Edit-3D splash modal. Mirrors the design language of
|
// Edit-3D splash modal. Mirrors the design language of
|
||||||
// SignupCtaModal (bg-[#161618], border-white/10, off-center primary
|
// SignupCtaModal (flat near-black surface, hairline border,
|
||||||
// glow, font-display heading) so the editor's onboarding modal feels
|
// font-display heading) so the editor's onboarding modal feels
|
||||||
// like the rest of the webapp, not a one-off.
|
// like the rest of the webapp, not a one-off.
|
||||||
|
|
||||||
import { Modal } from "@storyteller/ui-modal";
|
import { Modal } from "@storyteller/ui-modal";
|
||||||
@@ -22,7 +22,7 @@ export function SceneSplashModal({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
className="rounded-2xl w-full max-w-3xl overflow-hidden border border-white/10 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
|
className="w-full max-w-3xl overflow-hidden border border-white/15 bg-ui-modal p-0"
|
||||||
childPadding={false}
|
childPadding={false}
|
||||||
backdropClassName="!bg-black/80"
|
backdropClassName="!bg-black/80"
|
||||||
closeOnOutsideClick
|
closeOnOutsideClick
|
||||||
@@ -32,11 +32,7 @@ export function SceneSplashModal({
|
|||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
|
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/15"
|
||||||
/>
|
|
||||||
<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-12 sm:pb-10">
|
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface ExampleScene {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
// Tailwind gradient class fragment used by the card's thumbnail. Kept
|
// Tailwind class fragment used by the card's thumbnail fallback. Kept
|
||||||
// as a class string (not a CSS color) so JIT picks it up at build time.
|
// as a class string (not a CSS color) so JIT picks it up at build time.
|
||||||
accentClass: string;
|
accentClass: string;
|
||||||
sceneToken: string;
|
sceneToken: string;
|
||||||
@@ -27,7 +27,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
|
|||||||
id: "lone-drifter",
|
id: "lone-drifter",
|
||||||
title: "Lone Drifter",
|
title: "Lone Drifter",
|
||||||
description: "Sundown standoff, dust on the wind",
|
description: "Sundown standoff, dust on the wind",
|
||||||
accentClass: "from-orange-500/30 to-amber-700/20",
|
accentClass: "bg-orange-500/15",
|
||||||
sceneToken: "m_ywnjq1bdjw2163p456tczbg5p7ean8",
|
sceneToken: "m_ywnjq1bdjw2163p456tczbg5p7ean8",
|
||||||
outputToken: "m_9n2w5c4teefm21nhsceex3degeca9h",
|
outputToken: "m_9n2w5c4teefm21nhsceex3degeca9h",
|
||||||
},
|
},
|
||||||
@@ -35,7 +35,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
|
|||||||
id: "black-sails",
|
id: "black-sails",
|
||||||
title: "Black Sails",
|
title: "Black Sails",
|
||||||
description: "Galleon at dawn, salt on the bow",
|
description: "Galleon at dawn, salt on the bow",
|
||||||
accentClass: "from-slate-700/30 to-teal-800/20",
|
accentClass: "bg-teal-500/15",
|
||||||
sceneToken: PLACEHOLDER_SCENE_TOKEN,
|
sceneToken: PLACEHOLDER_SCENE_TOKEN,
|
||||||
outputToken: PLACEHOLDER_OUTPUT_TOKEN,
|
outputToken: PLACEHOLDER_OUTPUT_TOKEN,
|
||||||
},
|
},
|
||||||
@@ -43,7 +43,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
|
|||||||
id: "cold-brew-hero",
|
id: "cold-brew-hero",
|
||||||
title: "Cold Brew",
|
title: "Cold Brew",
|
||||||
description: "Studio bottle shot, soft rim light",
|
description: "Studio bottle shot, soft rim light",
|
||||||
accentClass: "from-amber-900/30 to-stone-700/20",
|
accentClass: "bg-amber-500/15",
|
||||||
sceneToken: "m_c8earcb2pnhb4n6pnbcfn01hg9vn9w",
|
sceneToken: "m_c8earcb2pnhb4n6pnbcfn01hg9vn9w",
|
||||||
outputToken: "m_a5y9cvskk8xe2y4j3hjskrha86x65q",
|
outputToken: "m_a5y9cvskk8xe2y4j3hjskrha86x65q",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import { Reveal } from "../../components/motion/reveal";
|
|||||||
const SeedanceBanner = () => (
|
const SeedanceBanner = () => (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-200 bg-primary/[0.12] border border-primary/25 rounded-full px-3 py-1">
|
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-white/10 border border-white/30 px-3 py-1">
|
||||||
Early access
|
Early access
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-white/65 bg-white/[0.04] border border-white/[0.08] rounded-full px-3 py-1">
|
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/65 bg-white/5 border border-white/15 px-3 py-1">
|
||||||
Available today in ArtCraft
|
Available today in ArtCraft
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +33,7 @@ const SeedanceBanner = () => (
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full rounded-2xl sm:rounded-[24px] overflow-hidden bg-[#080808] border border-white/[0.08]">
|
<div className="relative w-full overflow-hidden bg-[#080808] border border-white/15">
|
||||||
<div style={{ paddingTop: "56.25%" }} className="relative">
|
<div style={{ paddingTop: "56.25%" }} className="relative">
|
||||||
<iframe
|
<iframe
|
||||||
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
|
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
|
||||||
@@ -46,16 +46,16 @@ const SeedanceBanner = () => (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl bg-gradient-to-br from-primary/15 via-white/[0.03] to-white/[0.02] border border-primary/25 p-4">
|
<div className="bg-[#101014] border border-white/30 p-4">
|
||||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-primary mb-1">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white mb-1">
|
||||||
Seedance video credits
|
Seedance video credits
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/55 text-sm leading-snug">
|
<div className="text-white/55 text-sm leading-snug">
|
||||||
Included with every paid ArtCraft plan
|
Included with every paid ArtCraft plan
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-[#080808] border border-white/[0.08] p-4">
|
<div className="bg-[#101014] border border-white/15 p-4">
|
||||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-white/70 mb-1">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 mb-1">
|
||||||
First in the world
|
First in the world
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/55 text-sm leading-snug">
|
<div className="text-white/55 text-sm leading-snug">
|
||||||
@@ -73,10 +73,10 @@ const BuyMoreCreditsCta = ({ onClick }: { onClick: () => void }) => (
|
|||||||
<p className="text-base text-white/65">Need more credits?</p>
|
<p className="text-base text-white/65">Need more credits?</p>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="mt-3 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
|
className="mt-3 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<CoinsIcon className="text-primary text-[13px]" />
|
<CoinsIcon className="text-[13px]" />
|
||||||
Buy more credits
|
Buy more credits
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,25 +113,12 @@ const Pricing = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full bg-[#101014] text-white">
|
<div className="relative min-h-full bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Pricing - ArtCraft"
|
title="Pricing - ArtCraft"
|
||||||
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
|
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtle radial accent at top, matches landing3. Clipped to its own
|
|
||||||
wrapper so the outer page can grow naturally and let SidebarInset
|
|
||||||
handle scrolling. */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 top-0 h-[700px]"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="relative z-10 px-4 sm:px-8 pt-10">
|
<main className="relative z-10 px-4 sm:px-8 pt-10">
|
||||||
{isSeedanceRef ? (
|
{isSeedanceRef ? (
|
||||||
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
|
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
|
||||||
@@ -152,7 +139,12 @@ const Pricing = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<PricingPromoBanner className="mb-10 sm:mb-12" />
|
<PricingPromoBanner className="mb-10 sm:mb-12" />
|
||||||
<Reveal as="div" inView={false} y={20} className="text-center mb-14">
|
<Reveal
|
||||||
|
as="div"
|
||||||
|
inView={false}
|
||||||
|
y={20}
|
||||||
|
className="text-center mb-14"
|
||||||
|
>
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
|
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
|
||||||
Invest in <span className="font-serif-italic">yourself</span>.
|
Invest in <span className="font-serif-italic">yourself</span>.
|
||||||
</h1>
|
</h1>
|
||||||
@@ -187,12 +179,10 @@ const Pricing = () => {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="mt-4 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
|
className="mt-4 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
|
||||||
onClick={() => setCreditsModalOpen(true)}
|
onClick={() => setCreditsModalOpen(true)}
|
||||||
>
|
>
|
||||||
<CoinsIcon
|
<CoinsIcon className="text-[13px]" />
|
||||||
|
|
||||||
className="text-primary text-[13px]" />
|
|
||||||
Buy Credits
|
Buy Credits
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function Referrals() {
|
|||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full items-center justify-center bg-[#101014]">
|
<div className="flex min-h-full items-center justify-center bg-ui-background">
|
||||||
<LoadingSpinner className="h-10 w-10 text-white/60" />
|
<LoadingSpinner className="h-10 w-10 text-white/60" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -133,25 +133,16 @@ export default function Referrals() {
|
|||||||
const profileLink = `${SHARE_ORIGIN}/?u=${encodeURIComponent(user.username)}`;
|
const profileLink = `${SHARE_ORIGIN}/?u=${encodeURIComponent(user.username)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full w-full bg-[#101014] text-white">
|
<div className="relative min-h-full w-full bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Referrals - ArtCraft"
|
title="Referrals - ArtCraft"
|
||||||
description="Create and share referral links to invite people to ArtCraft."
|
description="Create and share referral links to invite people to ArtCraft."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0 overflow-hidden"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="relative z-10 px-4 sm:px-8 pt-10 pb-10">
|
<main className="relative z-10 px-4 sm:px-8 pt-10 pb-10">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<header className="text-center mb-8">
|
<header className="text-center mb-8">
|
||||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-5">
|
<span className="inline-block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-5">
|
||||||
Share the craft
|
Share the craft
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-5xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
|
<h1 className="text-5xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
|
||||||
@@ -166,16 +157,16 @@ export default function Referrals() {
|
|||||||
|
|
||||||
<div className="my-6 sm:my-8 flex items-center justify-center gap-3 text-white/40">
|
<div className="my-6 sm:my-8 flex items-center justify-center gap-3 text-white/40">
|
||||||
<div className="h-px w-10 bg-white/20" />
|
<div className="h-px w-10 bg-white/20" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.18em]">
|
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||||
Or
|
Or
|
||||||
</span>
|
</span>
|
||||||
<div className="h-px w-10 bg-white/20" />
|
<div className="h-px w-10 bg-white/20" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-2xl bg-[#1C1C20] border border-white/10 p-6 sm:p-8 shadow-2xl">
|
<section className="bg-[#101014] border border-white/15 p-6 sm:p-8">
|
||||||
<div className="flex items-start justify-between gap-4 mb-5">
|
<div className="flex items-start justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary mb-2">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-2">
|
||||||
Option 2
|
Option 2
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium text-white">
|
<h2 className="text-lg font-medium text-white">
|
||||||
@@ -196,7 +187,7 @@ export default function Referrals() {
|
|||||||
<LoadingSpinner className="h-7 w-7 text-white/60" />
|
<LoadingSpinner className="h-7 w-7 text-white/60" />
|
||||||
</div>
|
</div>
|
||||||
) : listError ? (
|
) : listError ? (
|
||||||
<div className="rounded-xl bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
<div className="bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||||
{listError}
|
{listError}
|
||||||
</div>
|
</div>
|
||||||
) : codes.length === 0 ? (
|
) : codes.length === 0 ? (
|
||||||
@@ -222,7 +213,7 @@ export default function Referrals() {
|
|||||||
<form onSubmit={handleCreate} className="mt-6 space-y-2">
|
<form onSubmit={handleCreate} className="mt-6 space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="new-referral-code"
|
htmlFor="new-referral-code"
|
||||||
className="block text-xs font-bold uppercase tracking-wide text-white/60 ml-1"
|
className="block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1"
|
||||||
>
|
>
|
||||||
New code
|
New code
|
||||||
</label>
|
</label>
|
||||||
@@ -240,14 +231,14 @@ export default function Referrals() {
|
|||||||
}
|
}
|
||||||
disabled={atLimit || creating}
|
disabled={atLimit || creating}
|
||||||
maxLength={REFERRAL_CODE_MAX_LENGTH}
|
maxLength={REFERRAL_CODE_MAX_LENGTH}
|
||||||
className="flex-1 h-11 rounded-xl bg-black/20 border border-white/10 focus:border-primary/50 px-4 text-sm text-white placeholder-white/25 outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 h-11 bg-ui-controls border border-white/15 focus:border-white/40 px-4 text-sm text-white placeholder-white/25 outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitDisabled}
|
disabled={submitDisabled}
|
||||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-primary hover:bg-primary-600 px-6 text-[14px] font-semibold text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="inline-flex h-11 items-center justify-center gap-2 bg-white hover:bg-white/80 px-6 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<PlusIcon className="text-[12px]" />
|
<PlusIcon className="text-[12px]" />
|
||||||
{creating ? "Creating…" : "Create"}
|
{creating ? "Creating…" : "Create"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,9 +266,9 @@ export default function Referrals() {
|
|||||||
|
|
||||||
function ProfileLinkPanel({ profileLink }: { profileLink: string }) {
|
function ProfileLinkPanel({ profileLink }: { profileLink: string }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl bg-[#1C1C20] border border-white/10 p-6 sm:p-8 shadow-2xl">
|
<section className="bg-[#101014] border border-white/15 p-6 sm:p-8">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary mb-2">
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-2">
|
||||||
Option 1
|
Option 1
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium text-white">
|
<h2 className="text-lg font-medium text-white">
|
||||||
@@ -316,13 +307,13 @@ function ReferralCodeRow({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-xl bg-black/20 border border-white/[0.08] px-4 py-3.5">
|
<li className="bg-ui-controls border border-white/15 px-4 py-3.5">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[15px] font-medium text-white truncate">
|
<div className="text-[15px] font-medium text-white truncate">
|
||||||
{entry.code}
|
{entry.code}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] uppercase tracking-wide text-white/40">
|
<div className="mt-0.5 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
|
||||||
Created {createdLabel}
|
Created {createdLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,7 +323,7 @@ function ReferralCodeRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onCancelDelete}
|
onClick={onCancelDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="h-9 px-3 rounded-full text-xs font-semibold text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors disabled:opacity-50"
|
className="h-9 px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -340,7 +331,7 @@ function ReferralCodeRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirmDelete}
|
onClick={onConfirmDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="h-9 px-3.5 rounded-full text-xs font-semibold text-white bg-red-500/85 hover:bg-red-500 transition-colors disabled:opacity-50"
|
className="h-9 px-3.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-red-500/85 hover:bg-red-500 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDeleting ? "Deleting…" : "Delete"}
|
{isDeleting ? "Deleting…" : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
@@ -349,10 +340,10 @@ function ReferralCodeRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRequestDelete}
|
onClick={onRequestDelete}
|
||||||
className="h-9 w-9 inline-flex items-center justify-center rounded-full text-white/45 hover:text-white hover:bg-white/[0.06] transition-colors"
|
className="h-9 w-9 inline-flex items-center justify-center text-white/45 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
title="Delete referral code"
|
title="Delete referral code"
|
||||||
>
|
>
|
||||||
<Trash2Icon className="text-[13px]" />
|
<Trash2Icon className="text-[13px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,12 +372,12 @@ function CopyableLink({ value }: { value: string }) {
|
|||||||
value={value}
|
value={value}
|
||||||
readOnly
|
readOnly
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
className="flex-1 min-w-0 h-11 rounded-xl bg-black/20 border border-white/10 focus:border-primary/50 px-4 text-sm text-white/80 font-mono outline-none transition-colors"
|
className="flex-1 min-w-0 h-11 bg-ui-controls border border-white/15 focus:border-white/40 px-4 text-sm text-white/80 font-mono outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="inline-flex h-11 items-center gap-2 rounded-full bg-white/[0.06] hover:bg-white/[0.1] border border-white/[0.1] px-4 text-xs font-semibold text-white/85 transition-all"
|
className="inline-flex h-11 items-center gap-2 bg-transparent hover:bg-white/10 border border-white/15 px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/85 transition-colors"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={copied ? CheckIcon : CopyIcon}
|
icon={copied ? CheckIcon : CopyIcon}
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ const SetPassword = () => {
|
|||||||
// a password) from being misrouted here.
|
// a password) from being misrouted here.
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-[#101014]">
|
<div className="flex min-h-screen items-center justify-center bg-ui-background">
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin text-4xl text-white/80" />
|
||||||
|
|
||||||
className="animate-spin text-4xl text-primary/80" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,22 +66,14 @@ const SetPassword = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
|
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
|
||||||
<Seo
|
<Seo
|
||||||
title="Set Password - ArtCraft"
|
title="Set Password - ArtCraft"
|
||||||
description="Set a password for your ArtCraft account."
|
description="Set a password for your ArtCraft account."
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
|
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<img
|
<img
|
||||||
src="/images/artcraft-icon.png"
|
src="/images/artcraft-icon.png"
|
||||||
@@ -105,13 +95,13 @@ const SetPassword = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -121,7 +111,7 @@ const SetPassword = () => {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Min. 8 characters"
|
placeholder="Min. 8 characters"
|
||||||
autoFocus
|
autoFocus
|
||||||
inputClassName="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 pr-12"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,7 +125,7 @@ const SetPassword = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
|
||||||
Confirm password
|
Confirm password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -144,7 +134,7 @@ const SetPassword = () => {
|
|||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="Re-enter your password"
|
placeholder="Re-enter your password"
|
||||||
inputClassName="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 pr-12"
|
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,14 +151,12 @@ const SetPassword = () => {
|
|||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
className="w-full justify-center h-10"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircleIcon
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
|
||||||
className="animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
"Set password"
|
"Set password"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const Support = () => {
|
|||||||
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.";
|
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#101014] text-white">
|
<div className="relative min-h-screen bg-ui-background text-white">
|
||||||
<Seo title={title} description={description} />
|
<Seo title={title} description={description} />
|
||||||
|
|
||||||
<main className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-20">
|
<main className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-20">
|
||||||
@@ -58,7 +58,7 @@ const Support = () => {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul className="divide-y divide-white/[0.06] border border-white/[0.06] rounded-xl overflow-hidden bg-white/[0.02]">
|
<ul className="divide-y divide-white/15 border border-white/15 overflow-hidden bg-[#101014]">
|
||||||
{ROWS.map((row) => (
|
{ROWS.map((row) => (
|
||||||
<li key={row.title}>
|
<li key={row.title}>
|
||||||
<SupportLink row={row} />
|
<SupportLink row={row} />
|
||||||
@@ -72,13 +72,13 @@ const Support = () => {
|
|||||||
|
|
||||||
const SupportLink = ({ row }: { row: SupportRow }) => {
|
const SupportLink = ({ row }: { row: SupportRow }) => {
|
||||||
const className =
|
const className =
|
||||||
"group flex items-center gap-4 px-4 py-4 sm:px-5 sm:py-5 transition-colors duration-150 hover:bg-white/[0.03]";
|
"group flex items-center gap-4 rounded-[3px] px-4 py-4 sm:px-5 sm:py-5 transition-colors duration-150 hover:bg-white/[0.03]";
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-md border transition-colors ${
|
className={`flex h-10 w-10 shrink-0 items-center justify-center border transition-colors ${
|
||||||
row.iconBgClass ?? "bg-white/[0.04] border-white/[0.06]"
|
row.iconBgClass ?? "bg-white/[0.04] border-white/15"
|
||||||
} ${row.iconColorClass ?? "text-white/70 group-hover:text-white"}`}
|
} ${row.iconColorClass ?? "text-white/70 group-hover:text-white"}`}
|
||||||
>
|
>
|
||||||
<DynamicIcon icon={row.icon} />
|
<DynamicIcon icon={row.icon} />
|
||||||
|
|||||||
+6
-15
@@ -18,8 +18,8 @@ import {
|
|||||||
|
|
||||||
// Landing view for /video-editor (no project in the URL): the user's saved
|
// Landing view for /video-editor (no project in the URL): the user's saved
|
||||||
// video projects plus a "New project" tile. Styled to match the create
|
// video projects plus a "New project" tile. Styled to match the create
|
||||||
// pages and library: #101014 canvas, glow orbs, eyebrow + display heading,
|
// pages and library: near-black canvas, eyebrow + display heading,
|
||||||
// ui-controls cards with primary hover rings, kebab menus, and the app's
|
// ui-controls cards with hairline hover rings, kebab menus, and the app's
|
||||||
// standard confirm dialog.
|
// standard confirm dialog.
|
||||||
|
|
||||||
const GRID_CLASSES =
|
const GRID_CLASSES =
|
||||||
@@ -128,14 +128,7 @@ export function VideoEditorProjectsLanding({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full overflow-y-auto bg-[#101014] text-white">
|
<div className="relative h-full w-full overflow-y-auto bg-ui-background text-white">
|
||||||
{/* Video-themed glow orbs (create-video recipe; absolute, not fixed,
|
|
||||||
since this page scrolls inside SidebarInset). */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
|
||||||
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 transform-gpu rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.10] blur-[120px]" />
|
|
||||||
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] transform-gpu rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.07] blur-[120px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The global topbar is hidden on /video-editor routes; surface its
|
{/* The global topbar is hidden on /video-editor routes; surface its
|
||||||
actions here like the editor header does. */}
|
actions here like the editor header does. */}
|
||||||
<div className="absolute right-4 top-3 z-20">
|
<div className="absolute right-4 top-3 z-20">
|
||||||
@@ -144,7 +137,7 @@ export function VideoEditorProjectsLanding({
|
|||||||
|
|
||||||
<div className="relative z-10 mx-auto w-full max-w-[1600px] px-3 pb-16 pt-10 sm:px-4 md:px-8 lg:px-12">
|
<div className="relative z-10 mx-auto w-full max-w-[1600px] px-3 pb-16 pt-10 sm:px-4 md:px-8 lg:px-12">
|
||||||
<header className="animate-fade-in-up">
|
<header className="animate-fade-in-up">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||||
Video editor
|
Video editor
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
|
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
|
||||||
@@ -193,7 +186,7 @@ export function VideoEditorProjectsLanding({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
onClick={onNewProject}
|
onClick={onNewProject}
|
||||||
className="mt-8 h-12 rounded-full px-6 text-base font-semibold"
|
className="mt-8 h-12 px-6"
|
||||||
>
|
>
|
||||||
New project
|
New project
|
||||||
</Button>
|
</Button>
|
||||||
@@ -202,9 +195,7 @@ export function VideoEditorProjectsLanding({
|
|||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
|
||||||
<TriangleAlertIcon
|
<TriangleAlertIcon className="text-3xl text-white/30" />
|
||||||
|
|
||||||
className="text-3xl text-white/30" />
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-medium">
|
<div className="text-lg font-medium">
|
||||||
Couldn't load your projects
|
Couldn't load your projects
|
||||||
|
|||||||
+34
-33
@@ -1,5 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ClapperboardIcon, EllipsisIcon, PenIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
import {
|
||||||
|
ClapperboardIcon,
|
||||||
|
EllipsisIcon,
|
||||||
|
PenIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||||
import type { ProjectMeta } from "@storyteller/ui-video-editor";
|
import type { ProjectMeta } from "@storyteller/ui-video-editor";
|
||||||
@@ -7,19 +13,18 @@ import { formatTimeAgo } from "../../../lib/format-time-ago";
|
|||||||
|
|
||||||
// Card, skeleton, and "new project" tile for the video projects landing.
|
// Card, skeleton, and "new project" tile for the video projects landing.
|
||||||
// Visual language follows the library/create-page cards: ui-controls
|
// Visual language follows the library/create-page cards: ui-controls
|
||||||
// surface, primary hover ring, hover-revealed kebab menu, and the house
|
// surface, hairline hover ring, hover-revealed kebab menu, and the house
|
||||||
// dashed recipe for the new-item tile.
|
// dashed recipe for the new-item tile.
|
||||||
|
|
||||||
// Deterministic per-project placeholder art so thumbnail-less cards don't
|
// Deterministic per-project placeholder art so thumbnail-less cards don't
|
||||||
// all look identical (blue/teal/purple/pink families, matching the
|
// all look identical (flat blue/teal/purple/pink tints).
|
||||||
// create-page glow orbs).
|
const PLACEHOLDER_TINTS = [
|
||||||
const PLACEHOLDER_GRADIENTS = [
|
"bg-blue-600/20",
|
||||||
"from-blue-600/30 via-blue-500/15 to-[#00AABA]/20",
|
"bg-purple-600/20",
|
||||||
"from-purple-600/30 via-blue-500/15 to-pink-500/20",
|
"bg-[#00AABA]/20",
|
||||||
"from-[#00AABA]/30 via-blue-600/15 to-purple-500/20",
|
"bg-pink-500/15",
|
||||||
"from-pink-500/25 via-purple-500/15 to-blue-600/20",
|
"bg-indigo-500/20",
|
||||||
"from-indigo-500/30 via-blue-500/15 to-teal-500/20",
|
"bg-sky-500/15",
|
||||||
"from-sky-500/25 via-indigo-500/15 to-purple-600/20",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const STAGGER_STEP_MS = 30;
|
const STAGGER_STEP_MS = 30;
|
||||||
@@ -48,7 +53,7 @@ export function VideoProjectCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-xl bg-ui-controls/40 ring-1 ring-white/5 transition-all duration-200 hover:ring-2 hover:ring-primary-400/60",
|
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-[3px] bg-ui-controls/40 ring-1 ring-white/15 transition-all duration-200 hover:ring-white/40",
|
||||||
isDeleting && "pointer-events-none opacity-50",
|
isDeleting && "pointer-events-none opacity-50",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -73,17 +78,15 @@ export function VideoProjectCard({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex h-full w-full items-center justify-center bg-gradient-to-br",
|
"flex h-full w-full items-center justify-center",
|
||||||
gradientForProject(project.id),
|
tintForProject(project.id),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ClapperboardIcon
|
<ClapperboardIcon className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
|
||||||
|
|
||||||
className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
<span className="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white backdrop-blur-sm">
|
<span className="bg-white/15 px-3 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white">
|
||||||
Open project
|
Open project
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,10 +109,8 @@ export function VideoProjectCard({
|
|||||||
position="bottom"
|
position="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
mode="default"
|
mode="default"
|
||||||
triggerIcon={
|
triggerIcon={<EllipsisIcon className="text-base-fg" />}
|
||||||
<EllipsisIcon className="text-base-fg" />
|
buttonClassName="h-7 w-7 p-0 bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
|
||||||
}
|
|
||||||
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
|
|
||||||
panelClassName="w-max min-w-44 p-1"
|
panelClassName="w-max min-w-44 p-1"
|
||||||
closeOnUnhover
|
closeOnUnhover
|
||||||
>
|
>
|
||||||
@@ -117,26 +118,26 @@ export function VideoProjectCard({
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
|
className="flex w-full items-center gap-2 whitespace-nowrap px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
close();
|
close();
|
||||||
onRename();
|
onRename();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PenIcon className="w-3.5" />
|
<PenIcon className="w-3.5" />
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-red hover:bg-red/10"
|
className="flex w-full items-center gap-2 whitespace-nowrap px-2 py-2 text-sm text-red hover:bg-red/10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
close();
|
close();
|
||||||
onDelete();
|
onDelete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="w-3.5" />
|
<Trash2Icon className="w-3.5" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,11 +150,11 @@ export function VideoProjectCard({
|
|||||||
|
|
||||||
export function VideoProjectCardSkeleton() {
|
export function VideoProjectCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col overflow-hidden rounded-xl ring-1 ring-white/5">
|
<div className="flex flex-col overflow-hidden rounded-[3px] ring-1 ring-white/15">
|
||||||
<div className="animate-shimmer aspect-video w-full bg-white/[0.04]" />
|
<div className="animate-shimmer aspect-video w-full bg-white/[0.04]" />
|
||||||
<div className="space-y-2 px-3 py-3">
|
<div className="space-y-2 px-3 py-3">
|
||||||
<div className="animate-shimmer h-3.5 w-2/3 rounded bg-white/[0.06]" />
|
<div className="animate-shimmer h-3.5 w-2/3 bg-white/[0.06]" />
|
||||||
<div className="animate-shimmer h-3 w-1/3 rounded bg-white/[0.05]" />
|
<div className="animate-shimmer h-3 w-1/3 bg-white/[0.05]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -164,9 +165,9 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 rounded-xl border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
||||||
>
|
>
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
|
<span className="flex h-9 w-9 items-center justify-center bg-white/5 text-white/55 transition-colors group-hover:bg-white/15 group-hover:text-white">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-white/70">New project</span>
|
<span className="text-sm font-medium text-white/70">New project</span>
|
||||||
@@ -174,10 +175,10 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function gradientForProject(id: string): string {
|
function tintForProject(id: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < id.length; i++) {
|
for (let i = 0; i < id.length; i++) {
|
||||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
}
|
}
|
||||||
return PLACEHOLDER_GRADIENTS[hash % PLACEHOLDER_GRADIENTS.length];
|
return PLACEHOLDER_TINTS[hash % PLACEHOLDER_TINTS.length];
|
||||||
}
|
}
|
||||||
@@ -117,9 +117,7 @@ export default function VideoEditorPage() {
|
|||||||
return (
|
return (
|
||||||
<VideoEditorProjectsLanding
|
<VideoEditorProjectsLanding
|
||||||
projectStorage={projectStorage}
|
projectStorage={projectStorage}
|
||||||
onNewProject={() =>
|
onNewProject={() => navigate(`/video-editor/${makeLocalProjectId()}`)}
|
||||||
navigate(`/video-editor/${makeLocalProjectId()}`)
|
|
||||||
}
|
|
||||||
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
|
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -215,7 +213,7 @@ function VideoEditorSession({
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/video-editor")}
|
onClick={() => navigate("/video-editor")}
|
||||||
className="rounded-lg border border-white/15 px-4 py-2 text-sm transition-colors hover:bg-white/10"
|
className="border border-white/15 px-4 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors hover:bg-white/10"
|
||||||
>
|
>
|
||||||
Back to projects
|
Back to projects
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { ArrowRightIcon, BoxIcon, CircleCheckIcon, DownloadIcon, ImageIcon, MonitorIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
BoxIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
ImageIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
VideoIcon,
|
||||||
|
WandSparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { DynamicIcon } from "@storyteller/icons";
|
import { DynamicIcon } from "@storyteller/icons";
|
||||||
import { Button } from "@storyteller/ui-button";
|
import { Button } from "@storyteller/ui-button";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -11,25 +20,17 @@ const Welcome = () => {
|
|||||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-full bg-[#101014] text-white">
|
<div className="relative min-h-full bg-ui-background text-white">
|
||||||
<Seo
|
<Seo
|
||||||
title="Welcome - ArtCraft"
|
title="Welcome - ArtCraft"
|
||||||
description="Welcome to ArtCraft. Get started with your subscription."
|
description="Welcome to ArtCraft. Get started with your subscription."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Background gradient — clipped to its own wrapper so the outer
|
|
||||||
page can grow naturally and let SidebarInset handle scrolling. */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
|
|
||||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8">
|
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8">
|
||||||
{/* Success Header */}
|
{/* Success Header */}
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
|
<div className="inline-flex items-center gap-3 bg-white/5 border border-white/15 px-6 py-3 mb-6">
|
||||||
<CircleCheckIcon
|
<CircleCheckIcon className="text-xl text-green-400" />
|
||||||
|
|
||||||
className="text-xl text-primary" />
|
|
||||||
<span className="text-white font-medium">
|
<span className="text-white font-medium">
|
||||||
Account created successfully!
|
Account created successfully!
|
||||||
</span>
|
</span>
|
||||||
@@ -56,12 +57,10 @@ const Welcome = () => {
|
|||||||
|
|
||||||
{/* Create-in-browser CTA — desktop download is optional */}
|
{/* Create-in-browser CTA — desktop download is optional */}
|
||||||
<div className="max-w-3xl mx-auto mb-8">
|
<div className="max-w-3xl mx-auto mb-8">
|
||||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
|
<div className="bg-[#101014] border border-white/15 p-8 md:p-10">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
|
||||||
<WandSparklesIcon
|
<WandSparklesIcon className="text-white/70 text-lg" />
|
||||||
|
|
||||||
className="text-primary text-lg" />
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-medium text-white">
|
<h2 className="text-2xl font-medium text-white">
|
||||||
Start creating right here
|
Start creating right here
|
||||||
@@ -84,18 +83,16 @@ const Welcome = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className="group flex items-center gap-3 bg-[#252529] hover:bg-[#2D2D32] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
|
className="group flex items-center gap-3 rounded-[3px] bg-ui-controls hover:bg-white/10 border border-white/15 hover:border-white/30 px-4 py-3 transition-all"
|
||||||
>
|
>
|
||||||
<DynamicIcon
|
<DynamicIcon
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
className="text-primary text-base shrink-0"
|
className="text-white/70 text-base shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-white/90 font-medium flex-1">
|
<span className="text-white/90 font-medium flex-1">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<ArrowRightIcon
|
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
||||||
|
|
||||||
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -104,12 +101,10 @@ const Welcome = () => {
|
|||||||
|
|
||||||
{/* Desktop app alternative */}
|
{/* Desktop app alternative */}
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
|
<div className="bg-[#101014] border border-white/15 p-8 md:p-10">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
|
||||||
<MonitorIcon
|
<MonitorIcon className="text-white/70 text-lg" />
|
||||||
|
|
||||||
className="text-primary text-lg" />
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-medium text-white">
|
<h2 className="text-2xl font-medium text-white">
|
||||||
Want the desktop app too?
|
Want the desktop app too?
|
||||||
@@ -130,9 +125,9 @@ const Welcome = () => {
|
|||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.step}
|
key={item.step}
|
||||||
className="bg-[#252529] p-4 rounded-xl flex items-center gap-4"
|
className="bg-ui-controls border border-white/15 p-4 flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 shrink-0 rounded-full bg-primary/60 flex items-center justify-center text-sm font-bold text-white">
|
<div className="w-8 h-8 shrink-0 bg-white/10 border border-white/15 flex items-center justify-center font-mono text-[11px] font-semibold text-white">
|
||||||
{item.step}
|
{item.step}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/90 font-medium">{item.text}</div>
|
<div className="text-white/90 font-medium">{item.text}</div>
|
||||||
@@ -142,7 +137,7 @@ const Welcome = () => {
|
|||||||
|
|
||||||
{/* Download Contingency */}
|
{/* Download Contingency */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="pt-6 border-t border-white/10">
|
<div className="pt-6 border-t border-white/15">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<span className="text-white/50 text-sm">
|
<span className="text-white/50 text-sm">
|
||||||
Download didn't start?
|
Download didn't start?
|
||||||
@@ -151,9 +146,9 @@ const Welcome = () => {
|
|||||||
<Button
|
<Button
|
||||||
as="link"
|
as="link"
|
||||||
href={downloadUrl}
|
href={downloadUrl}
|
||||||
className="rounded-full bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold"
|
className="bg-white text-black hover:bg-white/80 px-6 py-2.5"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="mr-2" />
|
<DownloadIcon className="mr-2" />
|
||||||
Download for {isMacOs ? "Mac" : "Windows"}
|
Download for {isMacOs ? "Mac" : "Windows"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-4 text-sm font-medium text-white/30">
|
<div className="flex gap-4 text-sm font-medium text-white/30">
|
||||||
@@ -161,14 +156,14 @@ const Welcome = () => {
|
|||||||
href={DOWNLOAD_LINKS.WINDOWS}
|
href={DOWNLOAD_LINKS.WINDOWS}
|
||||||
className="hover:text-white transition-colors flex items-center gap-2"
|
className="hover:text-white transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
<span className="w-1.5 h-1.5 bg-current"></span>
|
||||||
Windows
|
Windows
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={DOWNLOAD_LINKS.MACOS}
|
href={DOWNLOAD_LINKS.MACOS}
|
||||||
className="hover:text-white transition-colors flex items-center gap-2"
|
className="hover:text-white transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
|
<span className="w-1.5 h-1.5 bg-current"></span>
|
||||||
Mac
|
Mac
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,9 +173,9 @@ const Welcome = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
|
<div className="bg-[#431407] border border-orange-900/50 p-6 text-orange-200 text-sm leading-relaxed">
|
||||||
<div className="flex items-center justify-center mb-3 text-orange-400">
|
<div className="flex items-center justify-center mb-3 text-orange-400">
|
||||||
<MonitorIcon className="text-2xl" />
|
<MonitorIcon className="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
ArtCraft is a powerful desktop experience. <br />
|
ArtCraft is a powerful desktop experience. <br />
|
||||||
Please head to your computer to download and install.
|
Please head to your computer to download and install.
|
||||||
|
|||||||
@@ -5,36 +5,37 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Sidebar tokens (consumed by tailwind colors in tailwind.config.js).
|
/* Sidebar tokens (consumed by tailwind colors in tailwind.config.js).
|
||||||
Stored as raw hex so we can reference them via theme() if needed. */
|
Brutalist system: near-black surfaces, white/15 hairlines, white
|
||||||
--sidebar-bg: #1a1a1a;
|
accent for selection/focus. */
|
||||||
|
--sidebar-bg: #1e1f22;
|
||||||
--sidebar-fg: rgba(255, 255, 255, 0.72);
|
--sidebar-fg: rgba(255, 255, 255, 0.72);
|
||||||
--sidebar-border: rgba(64, 64, 64, 0.5);
|
--sidebar-border: rgba(255, 255, 255, 0.15);
|
||||||
--sidebar-accent: rgba(255, 255, 255, 0.06);
|
--sidebar-accent: rgba(255, 255, 255, 0.08);
|
||||||
--sidebar-accent-fg: #ffffff;
|
--sidebar-accent-fg: #ffffff;
|
||||||
--sidebar-ring: rgba(45, 129, 255, 0.4);
|
--sidebar-ring: rgba(255, 255, 255, 0.4);
|
||||||
|
|
||||||
/* shadcn design tokens consumed by @storyteller/ui-video-editor.
|
/* shadcn design tokens consumed by @storyteller/ui-video-editor.
|
||||||
Dark-theme palette (the editor only runs in dark mode today).
|
Dark-theme palette (the editor only runs in dark mode today).
|
||||||
--primary matches the webapp's brand blue (#2d81ff). */
|
--primary matches the webapp's brand blue (#2d81ff). */
|
||||||
--background: 0 0% 9%;
|
--background: 240 5% 8%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 0 0% 12%;
|
--card: 240 11% 7%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 0 0% 12%;
|
--popover: 240 4% 5%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 216 100% 59%;
|
--primary: 216 100% 59%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 0 0% 24%;
|
--secondary: 240 4% 9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 0 0% 18%;
|
--muted: 240 4% 9%;
|
||||||
--muted-foreground: 0 0% 64%;
|
--muted-foreground: 0 0% 64%;
|
||||||
--accent: 0 0% 16%;
|
--accent: 240 4% 9%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 70% 50%;
|
--destructive: 0 70% 50%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 18%;
|
--border: 0 0% 100% / 0.15;
|
||||||
--input: 0 0% 18%;
|
--input: 0 0% 100% / 0.15;
|
||||||
--ring: 217 91% 60%;
|
--ring: 0 0% 100% / 0.6;
|
||||||
|
|
||||||
/* ── Motion tokens ──────────────────────────────────────────────────
|
/* ── Motion tokens ──────────────────────────────────────────────────
|
||||||
Shared easing curves + durations so motion across the app feels like
|
Shared easing curves + durations so motion across the app feels like
|
||||||
@@ -71,9 +72,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle 3px corners on interactive controls. The brutalist system keeps
|
||||||
|
large surfaces (panels, modals, tables), passive labels, and media tiles
|
||||||
|
square; anything pressable gets a soft corner for affordance. Explicit
|
||||||
|
rounding elsewhere uses the same rounded-[3px] value. Tailwind radius
|
||||||
|
utilities (rounded-full, rounded-none) still win — the utilities layer
|
||||||
|
is emitted after base. */
|
||||||
|
@layer base {
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
input:not([type="radio"]):not([type="range"]),
|
||||||
|
[role="button"] {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #363636 #171717;
|
scrollbar-color: #2c2c2f #0b0b0c;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
@@ -81,13 +98,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
background: #171717;
|
background: #0b0b0c;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background-color: #363636;
|
background-color: #2c2c2f;
|
||||||
border-radius: 4px;
|
border: 2px solid #0b0b0c;
|
||||||
border: 2px solid #171717;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hidden {
|
.scrollbar-hidden {
|
||||||
@@ -100,6 +116,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
/* Display face: Archivo — an industrial grotesque that matches the
|
||||||
|
hard-edged HUD system better than Bricolage's editorial quirk.
|
||||||
|
Weights still come from per-usage utilities. */
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
@@ -107,7 +126,7 @@
|
|||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
font-family:
|
font-family:
|
||||||
"Outfit",
|
"Archivo",
|
||||||
system-ui,
|
system-ui,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
@@ -116,12 +135,19 @@
|
|||||||
"Helvetica Neue",
|
"Helvetica Neue",
|
||||||
Arial,
|
Arial,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only display-scale headings run expanded on the wdth axis; compact
|
||||||
|
headings (card titles, section labels) stay at normal width so they
|
||||||
|
don't truncate in tight layouts. */
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-stretch: 118%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
"Source Sans 3",
|
|
||||||
system-ui,
|
system-ui,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
@@ -131,11 +157,21 @@
|
|||||||
Arial,
|
Arial,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: #121212;
|
background-color: #0b0b0c;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mono HUD micro-label, matching the marketing site's .hud-label. */
|
||||||
|
.hud-label {
|
||||||
|
font-family:
|
||||||
|
"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.font-serif-italic {
|
.font-serif-italic {
|
||||||
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
|
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -144,6 +180,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Display utility carries the ax-display reference treatment (Archivo at
|
||||||
|
weight 620, 118% width), matching the marketing site. Plain (unlayered)
|
||||||
|
rule so it wins over font-weight utilities like font-semibold, which are
|
||||||
|
emitted at the top of the file. */
|
||||||
|
.font-display {
|
||||||
|
font-stretch: 118%;
|
||||||
|
font-weight: 620;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared component utilities (matching desktop app theme) */
|
/* Shared component utilities (matching desktop app theme) */
|
||||||
.bg-ui-badge {
|
.bg-ui-badge {
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
@@ -153,16 +198,11 @@
|
|||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient panel surface used by pagescene + lib components. */
|
/* Panel surface used by pagescene + lib components. Brutalist system:
|
||||||
|
flat near-black, no gradient (class name kept for lib compatibility). */
|
||||||
.bg-ui-panel-gradient {
|
.bg-ui-panel-gradient {
|
||||||
background:
|
background: #101014;
|
||||||
radial-gradient(
|
background-color: #101014;
|
||||||
60.68% 60.68% at 50% 0%,
|
|
||||||
rgba(255, 255, 255, 0.08) 0%,
|
|
||||||
rgba(0, 0, 0, 0) 70%
|
|
||||||
),
|
|
||||||
#1f1f1f;
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prevent-select {
|
.prevent-select {
|
||||||
@@ -233,19 +273,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Brutalist system: former "glass" surfaces are now flat near-black panels
|
||||||
|
with white/15 hairline borders (class names kept for lib compatibility). */
|
||||||
@layer components {
|
@layer components {
|
||||||
.glass {
|
.glass {
|
||||||
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
|
@apply relative border border-white/15 transition-colors duration-300 rounded-[3px] backdrop-blur-sm;
|
||||||
@apply bg-[#303032]/90 !important;
|
@apply bg-[#1E1F22]/95 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.liquid-glass {
|
.liquid-glass {
|
||||||
background: #232323c0;
|
background: rgba(11, 11, 12, 0.92);
|
||||||
border: 1px solid #ffffff14;
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
backdrop-filter: blur(18px) saturate(180%);
|
|
||||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
||||||
box-shadow: 0 8px 32px -8px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Minimal thin scrollbar for prompt textareas */
|
/* Minimal thin scrollbar for prompt textareas */
|
||||||
@@ -264,7 +303,6 @@
|
|||||||
|
|
||||||
.promptbox-scrollbar::-webkit-scrollbar-thumb {
|
.promptbox-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
|
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
@@ -412,56 +450,6 @@
|
|||||||
@apply bg-center;
|
@apply bg-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@property --angle {
|
|
||||||
syntax: "<angle>";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 0deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
0% {
|
|
||||||
--angle: 0deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
--angle: 360deg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-border-animated {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-border-animated::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -3px;
|
|
||||||
background: conic-gradient(
|
|
||||||
from var(--angle),
|
|
||||||
transparent 0%,
|
|
||||||
transparent 80%,
|
|
||||||
rgba(147, 197, 253, 0.4) 85%,
|
|
||||||
rgba(191, 219, 254, 0.9) 90%,
|
|
||||||
rgba(255, 255, 255, 1) 92.5%,
|
|
||||||
rgba(191, 219, 254, 0.9) 95%,
|
|
||||||
rgba(147, 197, 253, 0.4) 100%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
border-radius: inherit;
|
|
||||||
animation: rotate 2.2s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-border-animated::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 2px;
|
|
||||||
background: inherit;
|
|
||||||
border-radius: inherit;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile bottom-sheet (SettingsDrawer). The shadcn Sheet relies on the
|
/* Mobile bottom-sheet (SettingsDrawer). The shadcn Sheet relies on the
|
||||||
tailwindcss-animate plugin, which isn't enabled here, so we drive the
|
tailwindcss-animate plugin, which isn't enabled here, so we drive the
|
||||||
slide ourselves off Radix's data-state. Defining a closed-state animation
|
slide ourselves off Radix's data-state. Defining a closed-state animation
|
||||||
|
|||||||
@@ -18,8 +18,26 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
mono: [
|
||||||
|
"Geist Mono",
|
||||||
|
"ui-monospace",
|
||||||
|
"SFMono-Regular",
|
||||||
|
"Menlo",
|
||||||
|
"Consolas",
|
||||||
|
"monospace",
|
||||||
|
],
|
||||||
display: [
|
display: [
|
||||||
"Outfit",
|
"Archivo",
|
||||||
|
"system-ui",
|
||||||
|
"-apple-system",
|
||||||
|
"BlinkMacSystemFont",
|
||||||
|
"Segoe UI",
|
||||||
|
"Roboto",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"Arial",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
sans: [
|
||||||
"system-ui",
|
"system-ui",
|
||||||
"-apple-system",
|
"-apple-system",
|
||||||
"BlinkMacSystemFont",
|
"BlinkMacSystemFont",
|
||||||
@@ -159,16 +177,20 @@ module.exports = {
|
|||||||
ring: "var(--sidebar-ring)",
|
ring: "var(--sidebar-ring)",
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
|
// Brutalist system: near-black surfaces with white/15 hairline
|
||||||
background: "#242424",
|
// borders, aligned with artcraft-website + artcraft-website-next.
|
||||||
panel: "#1F1F1F",
|
background: "#121316",
|
||||||
"panel-border": "#272727",
|
panel: "#0b0b0c",
|
||||||
border: "#3f3f3f",
|
"panel-border": "rgb(255 255 255 / 0.15)",
|
||||||
controls: "#3e3e41",
|
border: "rgb(255 255 255 / 0.15)",
|
||||||
"controls-button": "#3E3E41",
|
// Raised control surface — a step above the #0b0b0c page/panel bg
|
||||||
"controls-border": "#3f3f3f",
|
// (matches the sidebar's #1e1f22) so buttons and inputs read as
|
||||||
divider: "#272727",
|
// surfaces instead of blending into the background.
|
||||||
modal: "#1F1F1F",
|
controls: "#1e1f22",
|
||||||
|
"controls-button": "#1e1f22",
|
||||||
|
"controls-border": "rgb(255 255 255 / 0.15)",
|
||||||
|
divider: "rgb(255 255 255 / 0.15)",
|
||||||
|
modal: "#0b0b0c",
|
||||||
},
|
},
|
||||||
"axis-x": "#D33242",
|
"axis-x": "#D33242",
|
||||||
"axis-y": "#308752",
|
"axis-y": "#308752",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ the whole box with `–`, or expand/collapse individual sections.
|
|||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- **Live**: physics/lighting/material values are read every frame, so slider
|
- **Live**: motion/look values are read every frame, so slider changes
|
||||||
changes apply instantly. Formation values (anything that changes the ball
|
apply instantly. Layout values (anything that changes the wall's panel
|
||||||
arrangement itself) trigger a debounced rebuild (~250 ms after the last
|
arrangement itself) trigger a debounced rebuild (~250 ms after the last
|
||||||
change).
|
change).
|
||||||
- **Persistent**: tuned values are stored in `localStorage`
|
- **Persistent**: tuned values are stored in `localStorage`
|
||||||
@@ -48,12 +48,12 @@ Then read values where they're used:
|
|||||||
inside the loop — it's cheap and picks up changes instantly.
|
inside the loop — it's cheap and picks up changes instantly.
|
||||||
- **Build-time consumers** (values baked into geometry/sampling): read at
|
- **Build-time consumers** (values baked into geometry/sampling): read at
|
||||||
build time and subscribe to `useTunerStore` to trigger a debounced rebuild
|
build time and subscribe to `useTunerStore` to trigger a debounced rebuild
|
||||||
when the group's values change (see the Formation wiring in
|
when the group's values change (see the Wall layout wiring in
|
||||||
`hero-wordmark.tsx`).
|
`hero-wall.tsx`).
|
||||||
|
|
||||||
The panel picks up new groups automatically — no panel changes needed.
|
The panel picks up new groups automatically — no panel changes needed.
|
||||||
Registered groups: Formation, Physics, Cursor & Light, Material, Halo (all
|
Registered groups: Wall layout, Wall motion, Wall look (all for the hero
|
||||||
for the hero wordmark; see `src/components/landing/hero-tunables.ts`).
|
render wall; see `src/components/landing/hero-tunables.ts`).
|
||||||
|
|
||||||
## Shipping tuned values
|
## Shipping tuned values
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
// Same-origin proxy for large media served from the live site. The hero
|
||||||
|
// rail draws these as WebGL video textures, which need CORS-clean
|
||||||
|
// sources — getartcraft.com serves its videos without
|
||||||
|
// Access-Control-Allow-Origin, so we route them through our own origin.
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/ext-media/:path*",
|
||||||
|
destination: "https://getartcraft.com/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
+1
-9
@@ -1114,7 +1114,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
||||||
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
|
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/webxr": "*",
|
"@types/webxr": "*",
|
||||||
@@ -1497,7 +1496,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1508,7 +1506,6 @@
|
|||||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -1533,7 +1530,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||||
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
@@ -2342,7 +2338,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
|
||||||
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
|
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
|
||||||
"license": "Zlib",
|
"license": "Zlib",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"three": ">= 0.168.0 < 0.186.0"
|
"three": ">= 0.168.0 < 0.186.0"
|
||||||
}
|
}
|
||||||
@@ -2368,7 +2363,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -2378,7 +2372,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -2597,8 +2590,7 @@
|
|||||||
"version": "0.184.0",
|
"version": "0.184.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/three-mesh-bvh": {
|
"node_modules/three-mesh-bvh": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg: #0b0b0c;
|
--bg: #121316;
|
||||||
--bg-raised: #101014;
|
--bg-raised: #101014;
|
||||||
--bg-sunken: #060607;
|
--bg-sunken: #060607;
|
||||||
--ink: #f2f1ee;
|
--ink: #f2f1ee;
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0b0b0c;
|
--bg: #121316;
|
||||||
--bg-raised: #101014;
|
--bg-raised: #101014;
|
||||||
--bg-sunken: #060607;
|
--bg-sunken: #060607;
|
||||||
--ink: #f2f1ee;
|
--ink: #f2f1ee;
|
||||||
@@ -92,10 +92,11 @@
|
|||||||
--color-invert-fg: var(--invert-fg);
|
--color-invert-fg: var(--invert-fg);
|
||||||
--color-danger: var(--danger);
|
--color-danger: var(--danger);
|
||||||
|
|
||||||
--font-display: var(--font-bricolage), system-ui, sans-serif;
|
--font-display: var(--font-archivo), system-ui, sans-serif;
|
||||||
--font-serif: var(--font-instrument), Georgia, serif;
|
--font-serif: var(--font-instrument), Georgia, serif;
|
||||||
--font-body: var(--font-inter), system-ui, sans-serif;
|
--font-body: var(--font-inter), system-ui, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono:
|
||||||
|
var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
||||||
/* No rounded corners anywhere in this design language. */
|
/* No rounded corners anywhere in this design language. */
|
||||||
--radius-*: initial;
|
--radius-*: initial;
|
||||||
@@ -125,7 +126,9 @@ body {
|
|||||||
|
|
||||||
/* Signature interaction: label blocks invert on hover/active. */
|
/* Signature interaction: label blocks invert on hover/active. */
|
||||||
.invert-block {
|
.invert-block {
|
||||||
transition: background-color 120ms linear, color 120ms linear;
|
transition:
|
||||||
|
background-color 120ms linear,
|
||||||
|
color 120ms linear;
|
||||||
}
|
}
|
||||||
.invert-block:hover,
|
.invert-block:hover,
|
||||||
.invert-block[data-active="true"] {
|
.invert-block[data-active="true"] {
|
||||||
@@ -146,6 +149,17 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Display type: Archivo at weight 620, slightly expanded on the width
|
||||||
|
axis (the ax-display reference treatment). Declared after the utility
|
||||||
|
layer so it also wins over per-usage font-weight utilities. The hero
|
||||||
|
wordmark opts back out of the stretch inline (canvas rasterization
|
||||||
|
can't apply font-stretch, so its static fallback must match the
|
||||||
|
sampled blocks). */
|
||||||
|
.font-display {
|
||||||
|
font-stretch: 118%;
|
||||||
|
font-weight: 620;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mono HUD micro-label. */
|
/* Mono HUD micro-label. */
|
||||||
.hud-label {
|
.hud-label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Bricolage_Grotesque, Instrument_Serif, Inter, Geist_Mono } from "next/font/google";
|
import {
|
||||||
|
Archivo,
|
||||||
|
Archivo_Black,
|
||||||
|
Instrument_Serif,
|
||||||
|
Inter,
|
||||||
|
Geist_Mono,
|
||||||
|
} from "next/font/google";
|
||||||
import SiteNav from "@/components/site-nav";
|
import SiteNav from "@/components/site-nav";
|
||||||
import SiteFooter from "@/components/site-footer";
|
import SiteFooter from "@/components/site-footer";
|
||||||
import MotionProvider from "@/components/motion-provider";
|
import MotionProvider from "@/components/motion-provider";
|
||||||
@@ -8,9 +14,21 @@ import "./globals.css";
|
|||||||
|
|
||||||
const SITE_URL = "https://getartcraft.com";
|
const SITE_URL = "https://getartcraft.com";
|
||||||
|
|
||||||
const bricolage = Bricolage_Grotesque({
|
// Display face: Archivo with its width axis loaded, so headings can run
|
||||||
|
// slightly expanded (font-stretch) for the industrial-grotesque look.
|
||||||
|
const archivo = Archivo({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-bricolage",
|
variable: "--font-archivo",
|
||||||
|
display: "swap",
|
||||||
|
axes: ["wdth"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wordmark face: Archivo Black, the display family's poster-weight cut
|
||||||
|
// (a single-weight family of its own, not Archivo at 900).
|
||||||
|
const archivoBlack = Archivo_Black({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: "400",
|
||||||
|
variable: "--font-archivo-black",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +109,7 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={`${bricolage.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
|
className={`${archivo.variable} ${archivoBlack.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
||||||
|
|||||||
@@ -1,62 +1,81 @@
|
|||||||
// Every tweakable constant of the hero wordmark, registered with the dev
|
// Every tweakable constant of the hero render wall, registered with the dev
|
||||||
// tuner (see TUNER.md). Defaults here ARE the shipped values — tune live,
|
// tuner (see TUNER.md). Defaults here ARE the shipped values — tune live,
|
||||||
// copy the JSON from the panel, then update the defaults.
|
// copy the JSON from the panel, then update the defaults.
|
||||||
|
|
||||||
import { defineTunables } from "@/lib/tuner";
|
import { defineTunables } from "@/lib/tuner";
|
||||||
|
|
||||||
export const formationTuner = defineTunables("formation", "Formation", {
|
// Wall structure — moving these rebuilds the panel layout (debounced).
|
||||||
ballScale: { label: "Ball scale", min: 0.5, max: 2.5, step: 0.05, default: 1 },
|
export const wallLayoutTuner = defineTunables("wallLayout", "Wall layout", {
|
||||||
sizeMin: { label: "Size min ×", min: 0.05, max: 0.6, step: 0.01, default: 0.18 },
|
rowHeight: {
|
||||||
sizeMax: { label: "Size max ×", min: 0.2, max: 1.4, step: 0.01, default: 0.66 },
|
label: "Near row × vh",
|
||||||
sizeNoiseScale: { label: "Size noise px", min: 30, max: 400, step: 5, default: 130 },
|
min: 0.14,
|
||||||
sizeRoughen: { label: "Size roughen", min: 0, max: 1, step: 0.02, default: 0.4 },
|
max: 0.42,
|
||||||
gap: { label: "Gap px", min: 0, max: 6, step: 0.1, default: 0.6 },
|
step: 0.01,
|
||||||
depth: { label: "Depth ×", min: 0.2, max: 5, step: 0.1, default: 1.9 },
|
default: 0.31,
|
||||||
density: { label: "Fill density", min: 0.2, max: 3, step: 0.05, default: 1 },
|
},
|
||||||
edgeMargin: { label: "Edge margin", min: 0, max: 1, step: 0.05, default: 0.55 },
|
rowScale: {
|
||||||
seed: { label: "Seed", min: 1, max: 9999, step: 1, default: 1013 },
|
label: "Row shrink",
|
||||||
|
min: 0.5,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
default: 0.78,
|
||||||
|
},
|
||||||
|
gap: { label: "Gap px", min: 8, max: 80, step: 1, default: 22 },
|
||||||
|
zStep: { label: "Row z step px", min: 0, max: 400, step: 5, default: 170 },
|
||||||
|
yNear: {
|
||||||
|
label: "Near row y × vh",
|
||||||
|
min: -0.5,
|
||||||
|
max: 0.5,
|
||||||
|
step: 0.01,
|
||||||
|
default: -0.17,
|
||||||
|
},
|
||||||
|
yMid: {
|
||||||
|
label: "Mid row y × vh",
|
||||||
|
min: -0.5,
|
||||||
|
max: 0.5,
|
||||||
|
step: 0.01,
|
||||||
|
default: 0.17,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const physicsTuner = defineTunables("physics", "Physics", {
|
export const wallMotionTuner = defineTunables("wallMotion", "Wall motion", {
|
||||||
spring: { label: "Spring", min: 5, max: 140, step: 1, default: 46 },
|
yawDeg: { label: "Yaw °", min: -35, max: 35, step: 0.5, default: -11 },
|
||||||
damping: { label: "Damping", min: 1, max: 14, step: 0.1, default: 5.2 },
|
pitchDeg: { label: "Pitch °", min: -12, max: 12, step: 0.25, default: 2.5 },
|
||||||
pushRadius: { label: "Push radius", min: 40, max: 450, step: 5, default: 180 },
|
speed: { label: "Drift px/s", min: 0, max: 240, step: 2, default: 42 },
|
||||||
pushStrength: { label: "Push strength", min: 1000, max: 80000, step: 500, default: 23000 },
|
speedStep: {
|
||||||
swirl: { label: "Swirl", min: 0, max: 1.5, step: 0.05, default: 0.55 },
|
label: "Row parallax",
|
||||||
popZ: { label: "Pop toward viewer", min: 0, max: 2, step: 0.05, default: 0.6 },
|
min: 0.3,
|
||||||
bobAmp: { label: "Idle bob px", min: 0, max: 8, step: 0.1, default: 1.4 },
|
max: 1,
|
||||||
scatter: { label: "Intro scatter px", min: 0, max: 1400, step: 20, default: 480 },
|
step: 0.02,
|
||||||
|
default: 0.62,
|
||||||
|
},
|
||||||
|
dragMax: {
|
||||||
|
label: "Max throw px/s",
|
||||||
|
min: 400,
|
||||||
|
max: 6000,
|
||||||
|
step: 100,
|
||||||
|
default: 2400,
|
||||||
|
},
|
||||||
|
introBurst: { label: "Intro burst ×", min: 1, max: 40, step: 1, default: 16 },
|
||||||
|
parallax: {
|
||||||
|
label: "Pointer tilt rad",
|
||||||
|
min: 0,
|
||||||
|
max: 0.09,
|
||||||
|
step: 0.002,
|
||||||
|
default: 0.024,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const lightTuner = defineTunables("light", "Cursor & Light", {
|
export const wallLookTuner = defineTunables("wallLook", "Wall look", {
|
||||||
intensityDark: { label: "Cursor int (dark)", min: 0, max: 220000, step: 1000, default: 58000 },
|
dimNear: { label: "Near row dim", min: 0, max: 1, step: 0.02, default: 0.86 },
|
||||||
intensityLight: { label: "Cursor int (light)", min: 0, max: 220000, step: 1000, default: 30000 },
|
dimMid: { label: "Mid row dim", min: 0, max: 1, step: 0.02, default: 0.7 },
|
||||||
idleDim: { label: "Idle dim", min: 0, max: 1, step: 0.05, default: 0.3 },
|
washLight: { label: "Light wash", min: 0, max: 1, step: 0.02, default: 0.15 },
|
||||||
lightZ: { label: "Light z", min: 30, max: 500, step: 5, default: 130 },
|
edgeFade: { label: "Edge fade", min: 0, max: 1, step: 0.02, default: 0.65 },
|
||||||
lightRange: { label: "Light range", min: 100, max: 3000, step: 50, default: 900 },
|
frameAlpha: {
|
||||||
keyIntensity: { label: "Back key (dark)", min: 0, max: 8, step: 0.1, default: 2.2 },
|
label: "Frame alpha",
|
||||||
counterIntensity: { label: "Counter rim (dark)", min: 0, max: 4, step: 0.05, default: 0.7 },
|
min: 0,
|
||||||
frontFill: { label: "Front fill (dark)", min: 0, max: 1, step: 0.01, default: 0.06 },
|
max: 1,
|
||||||
ambientDark: { label: "Ambient (dark)", min: 0, max: 1, step: 0.01, default: 0.05 },
|
step: 0.02,
|
||||||
ambientLight: { label: "Ambient (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
|
default: 0.9,
|
||||||
dirLight: { label: "Key (light)", min: 0, max: 3, step: 0.05, default: 0.75 },
|
},
|
||||||
});
|
|
||||||
|
|
||||||
export const materialTuner = defineTunables("material", "Material", {
|
|
||||||
roughnessDark: { label: "Roughness (dark)", min: 0, max: 1, step: 0.02, default: 0.42 },
|
|
||||||
metalnessDark: { label: "Metalness (dark)", min: 0, max: 1, step: 0.02, default: 0.5 },
|
|
||||||
clearcoat: { label: "Clearcoat (dark)", min: 0, max: 1, step: 0.02, default: 0.9 },
|
|
||||||
clearcoatRoughness: { label: "Coat rough (dark)", min: 0, max: 1, step: 0.02, default: 0.22 },
|
|
||||||
roughnessLight: { label: "Roughness (light)", min: 0, max: 1, step: 0.02, default: 0.3 },
|
|
||||||
metalnessLight: { label: "Metalness (light)", min: 0, max: 1, step: 0.02, default: 0.15 },
|
|
||||||
envLight: { label: "Env int (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
|
|
||||||
accentShare: { label: "Accent share", min: 0, max: 0.3, step: 0.005, default: 0.05 },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const haloTuner = defineTunables("halo", "Halo", {
|
|
||||||
size: { label: "Size px", min: 200, max: 1800, step: 20, default: 720 },
|
|
||||||
alphaDark: { label: "Alpha (dark)", min: 0, max: 1, step: 0.02, default: 0.5 },
|
|
||||||
alphaLight: { label: "Alpha (light)", min: 0, max: 0.6, step: 0.01, default: 0.14 },
|
|
||||||
z: { label: "Z offset", min: -400, max: 0, step: 5, default: -90 },
|
|
||||||
falloff: { label: "Falloff", min: 0.5, max: 6, step: 0.1, default: 2.6 },
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { SEEDANCE_SHOWCASE } from "@/lib/landing-data";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-colors";
|
||||||
|
import { useTunerStore } from "@/lib/tuner";
|
||||||
|
import {
|
||||||
|
wallLayoutTuner,
|
||||||
|
wallLookTuner,
|
||||||
|
wallMotionTuner,
|
||||||
|
} from "./hero-tunables";
|
||||||
|
|
||||||
|
// The render wall: two film-strip rows of showcase clips forming one
|
||||||
|
// yawed, pitched wall behind the wordmark. Rows sit at stepped depths and
|
||||||
|
// drift sideways at speeds proportional to their depth — real parallax,
|
||||||
|
// like passing a wall of screens — and each row loops seamlessly (panels
|
||||||
|
// wrap around offscreen). Near row is full strength; deeper rows dim into
|
||||||
|
// the background so the type always owns the composition, and panels
|
||||||
|
// vignette out toward the screen edges.
|
||||||
|
//
|
||||||
|
// A drag anywhere throws the wall with momentum (the hero container feeds
|
||||||
|
// drag state in via the WallDrag ref, since the canvas itself is
|
||||||
|
// pointer-events: none); the pointer also tilts the whole wall a few
|
||||||
|
// degrees. Unattended it drifts at a slow walking pace.
|
||||||
|
//
|
||||||
|
// The scene is fitted 1 world unit == 1 CSS px at z=0, so layout below is
|
||||||
|
// in pixels. Each clip decodes once; panels showing the same clip share
|
||||||
|
// one VideoTexture.
|
||||||
|
|
||||||
|
// Drag state written by the hero container's pointer handlers and consumed
|
||||||
|
// here every frame.
|
||||||
|
export type WallDrag = {
|
||||||
|
dragging: boolean;
|
||||||
|
dx: number; // accumulated drag pixels since the wall last consumed them
|
||||||
|
vel: number; // smoothed drag velocity, px/s
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createWallDrag(): WallDrag {
|
||||||
|
return { dragging: false, dx: 0, vel: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_COUNT = 2;
|
||||||
|
const FOV = 30; // must match FittedCamera in hero-wordmark.tsx
|
||||||
|
|
||||||
|
type WallPanel = {
|
||||||
|
clip: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
x0: number; // resting position of the panel center along its row
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
row: number;
|
||||||
|
length: number; // total loop length of the row this panel rides
|
||||||
|
};
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||||
|
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroWall({
|
||||||
|
pointer,
|
||||||
|
drag,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
pointer: React.RefObject<{ x: number; y: number; active: boolean }>;
|
||||||
|
drag: React.RefObject<WallDrag>;
|
||||||
|
colors: ThemeColors;
|
||||||
|
}) {
|
||||||
|
const size = useThree((s) => s.size);
|
||||||
|
const rigRef = useRef<THREE.Group>(null);
|
||||||
|
const groupRefs = useRef<(THREE.Group | null)[]>([]);
|
||||||
|
const state = useRef({
|
||||||
|
offset: 0,
|
||||||
|
speed: 0,
|
||||||
|
wasDragging: false,
|
||||||
|
started: false,
|
||||||
|
});
|
||||||
|
const tilt = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const dark = useMemo(() => {
|
||||||
|
const c = new THREE.Color(colors.bg);
|
||||||
|
return c.r + c.g + c.b < 1.5;
|
||||||
|
}, [colors.bg]);
|
||||||
|
|
||||||
|
// One <video> and one shared texture per clip.
|
||||||
|
const videos = useMemo(
|
||||||
|
() =>
|
||||||
|
SEEDANCE_SHOWCASE.map((clip) => {
|
||||||
|
const v = document.createElement("video");
|
||||||
|
v.src = clip.src;
|
||||||
|
v.muted = true;
|
||||||
|
v.loop = true;
|
||||||
|
v.playsInline = true;
|
||||||
|
v.crossOrigin = "anonymous";
|
||||||
|
v.preload = "auto";
|
||||||
|
return v;
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const textures = useMemo(
|
||||||
|
() =>
|
||||||
|
videos.map((v, i) => {
|
||||||
|
const t = new THREE.VideoTexture(v);
|
||||||
|
t.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
// Cover-fit the source into the panel plane (GL has no object-cover):
|
||||||
|
// crop the longer axis via repeat/offset once dimensions are known.
|
||||||
|
const fit = () => {
|
||||||
|
const va = v.videoWidth / v.videoHeight || 16 / 9;
|
||||||
|
const pa = SEEDANCE_SHOWCASE[i].aspect;
|
||||||
|
if (va > pa) {
|
||||||
|
t.repeat.set(pa / va, 1);
|
||||||
|
t.offset.set((1 - pa / va) / 2, 0);
|
||||||
|
} else {
|
||||||
|
t.repeat.set(1, va / pa);
|
||||||
|
t.offset.set(0, (1 - va / pa) / 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (v.readyState >= 1) fit();
|
||||||
|
else v.addEventListener("loadedmetadata", fit, { once: true });
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
[videos],
|
||||||
|
);
|
||||||
|
// Video lifecycle is keyed to the videos/textures alone. Re-arm sources
|
||||||
|
// on every run: React strict mode (and any remount) runs the cleanup,
|
||||||
|
// which unloads the shared <video> elements the memo still holds.
|
||||||
|
useEffect(() => {
|
||||||
|
videos.forEach((v, i) => {
|
||||||
|
if (!v.getAttribute("src")) {
|
||||||
|
v.src = SEEDANCE_SHOWCASE[i].src;
|
||||||
|
v.load();
|
||||||
|
}
|
||||||
|
v.play().catch(() => {});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
textures.forEach((t) => t.dispose());
|
||||||
|
videos.forEach((v) => {
|
||||||
|
v.pause();
|
||||||
|
v.removeAttribute("src");
|
||||||
|
v.load();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [videos, textures]);
|
||||||
|
|
||||||
|
// Layout tunables change the wall's structure — debounce a rebuild.
|
||||||
|
const [layoutVersion, setLayoutVersion] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let last = JSON.stringify(wallLayoutTuner.read());
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const unsubscribe = useTunerStore.subscribe(() => {
|
||||||
|
const now = JSON.stringify(wallLayoutTuner.read());
|
||||||
|
if (now === last) return;
|
||||||
|
last = now;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => setLayoutVersion((v) => v + 1), 250);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Wall structure: per row, panels are laid end to end with a uniform gap
|
||||||
|
// until they cover the yawed viewport plus one panel of margin, so the
|
||||||
|
// wrap-around teleport always happens offscreen. Rows shrink and recede
|
||||||
|
// with depth; each row draws from its own disjoint slice of the clip
|
||||||
|
// pool, so no clip ever appears in more than one row.
|
||||||
|
const layout = useMemo(() => {
|
||||||
|
const t = wallLayoutTuner.read();
|
||||||
|
const nearH = Math.min(320, Math.max(110, size.height * t.rowHeight));
|
||||||
|
const yFracs = [t.yNear, t.yMid];
|
||||||
|
const clipCount = SEEDANCE_SHOWCASE.length;
|
||||||
|
const perRow = Math.floor(clipCount / ROW_COUNT);
|
||||||
|
const extra = clipCount % ROW_COUNT;
|
||||||
|
const panels: WallPanel[] = [];
|
||||||
|
for (let i = 0; i < ROW_COUNT; i++) {
|
||||||
|
// Remainder clips go to the deeper rows: their panels are smaller, so
|
||||||
|
// they need more of them and repeat soonest without the bigger slice.
|
||||||
|
const extraBefore = Math.max(0, i - (ROW_COUNT - extra));
|
||||||
|
const rowStart = i * perRow + extraBefore;
|
||||||
|
const rowClips = Math.max(1, perRow + (i >= ROW_COUNT - extra ? 1 : 0));
|
||||||
|
const h = nearH * Math.pow(t.rowScale, i);
|
||||||
|
const span = size.width * 1.5 + h * 2.6;
|
||||||
|
let x = 0;
|
||||||
|
let k = 0;
|
||||||
|
const rowPanels: WallPanel[] = [];
|
||||||
|
while (x < span) {
|
||||||
|
const clip = (rowStart + (k % rowClips)) % clipCount;
|
||||||
|
const w = h * SEEDANCE_SHOWCASE[clip].aspect;
|
||||||
|
rowPanels.push({
|
||||||
|
clip,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
x0: x + w / 2,
|
||||||
|
y: size.height * yFracs[i],
|
||||||
|
z: -t.zStep * i,
|
||||||
|
row: i,
|
||||||
|
length: 0,
|
||||||
|
});
|
||||||
|
x += w + t.gap;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
// The row is a loop, so the seam (last panel wrapping around to meet
|
||||||
|
// the first) is an adjacency too. When the cycle length doesn't divide
|
||||||
|
// the panel count, the seam can pair a clip with itself — bump the
|
||||||
|
// last panel one step along the slice, which is distinct from both
|
||||||
|
// neighbors whenever the slice holds 3+ clips.
|
||||||
|
const last = rowPanels[rowPanels.length - 1];
|
||||||
|
if (rowPanels.length > 1 && rowClips >= 3 && last.clip === rowPanels[0].clip) {
|
||||||
|
const left = last.x0 - last.w / 2;
|
||||||
|
last.clip = rowStart + ((last.clip - rowStart + 1) % rowClips);
|
||||||
|
last.w = last.h * SEEDANCE_SHOWCASE[last.clip].aspect;
|
||||||
|
last.x0 = left + last.w / 2;
|
||||||
|
x = left + last.w + t.gap;
|
||||||
|
}
|
||||||
|
for (const p of rowPanels) p.length = x;
|
||||||
|
panels.push(...rowPanels);
|
||||||
|
}
|
||||||
|
const frames = panels.map(
|
||||||
|
(p) => new THREE.EdgesGeometry(new THREE.PlaneGeometry(p.w, p.h)),
|
||||||
|
);
|
||||||
|
return { panels, frames };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [size.width, size.height, layoutVersion]);
|
||||||
|
useEffect(() => () => layout.frames.forEach((f) => f.dispose()), [layout]);
|
||||||
|
|
||||||
|
// Per-panel materials (panels share clip textures but dim and vignette
|
||||||
|
// independently, so each needs its own color/opacity).
|
||||||
|
const materials = useMemo(
|
||||||
|
() =>
|
||||||
|
layout.panels.map(
|
||||||
|
(p) =>
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
map: textures[p.clip],
|
||||||
|
transparent: true,
|
||||||
|
toneMapped: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[textures, layout],
|
||||||
|
);
|
||||||
|
const frameMaterials = useMemo(
|
||||||
|
() =>
|
||||||
|
layout.panels.map(
|
||||||
|
() => new THREE.LineBasicMaterial({ transparent: true }),
|
||||||
|
),
|
||||||
|
[layout],
|
||||||
|
);
|
||||||
|
// Frame color follows the theme imperatively — recreating the materials
|
||||||
|
// on a color change would churn the panel meshes for nothing.
|
||||||
|
useEffect(() => {
|
||||||
|
frameMaterials.forEach((m) => m.color.set(colors.lineStrong));
|
||||||
|
}, [frameMaterials, colors.lineStrong]);
|
||||||
|
useEffect(() => () => materials.forEach((m) => m.dispose()), [materials]);
|
||||||
|
useEffect(
|
||||||
|
() => () => frameMaterials.forEach((m) => m.dispose()),
|
||||||
|
[frameMaterials],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-panel video readiness, eased: until a clip has decodable frames its
|
||||||
|
// panels stay as empty hairline skeletons, then the footage fades in —
|
||||||
|
// no black rectangles and no pop-in on slow connections.
|
||||||
|
const videoAlpha = useMemo(
|
||||||
|
() => new Float32Array(layout.panels.length),
|
||||||
|
[layout],
|
||||||
|
);
|
||||||
|
|
||||||
|
useFrame((st3, delta) => {
|
||||||
|
const dt = Math.min(delta, 0.05);
|
||||||
|
const st = state.current;
|
||||||
|
const d = drag.current;
|
||||||
|
const mt = wallMotionTuner.read();
|
||||||
|
const lk = wallLookTuner.read();
|
||||||
|
|
||||||
|
// Mount: the wall sweeps in fast and eases down to its walking pace.
|
||||||
|
if (!st.started) {
|
||||||
|
st.started = true;
|
||||||
|
st.speed = mt.speed * mt.introBurst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset advances in near-row pixels; deeper rows consume it scaled by
|
||||||
|
// their parallax factor, so a drag maps 1:1 to the near row's motion.
|
||||||
|
if (d?.dragging) {
|
||||||
|
st.offset -= d.dx;
|
||||||
|
d.dx = 0;
|
||||||
|
st.wasDragging = true;
|
||||||
|
} else {
|
||||||
|
if (st.wasDragging && d) {
|
||||||
|
st.wasDragging = false;
|
||||||
|
st.speed = Math.max(-mt.dragMax, Math.min(mt.dragMax, d.vel));
|
||||||
|
}
|
||||||
|
st.offset += st.speed * dt;
|
||||||
|
st.speed += (mt.speed - st.speed) * (1 - Math.exp(-1.1 * dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer tilt, critically damped; unattended, a slow Lissajous drift
|
||||||
|
// keeps the perspective alive.
|
||||||
|
const p = pointer.current ?? { x: 0, y: 0, active: false };
|
||||||
|
const t = st3.clock.elapsedTime;
|
||||||
|
const nx = p.active
|
||||||
|
? p.x / (size.width / 2)
|
||||||
|
: Math.sin(t * 0.13) * 0.35;
|
||||||
|
const ny = p.active
|
||||||
|
? p.y / (size.height / 2)
|
||||||
|
: Math.cos(t * 0.09) * 0.3;
|
||||||
|
const k = 1 - Math.exp(-4 * dt);
|
||||||
|
tilt.current.x += (nx - tilt.current.x) * k;
|
||||||
|
tilt.current.y += (ny - tilt.current.y) * k;
|
||||||
|
|
||||||
|
const yaw =
|
||||||
|
THREE.MathUtils.degToRad(mt.yawDeg) + tilt.current.x * mt.parallax;
|
||||||
|
const pitch =
|
||||||
|
THREE.MathUtils.degToRad(mt.pitchDeg) - tilt.current.y * mt.parallax * 0.6;
|
||||||
|
rigRef.current?.rotation.set(pitch, yaw, 0);
|
||||||
|
|
||||||
|
const intro = smoothstep(0, 1, Math.min(1, t / 1.4));
|
||||||
|
const dist = size.height / 2 / Math.tan(THREE.MathUtils.degToRad(FOV / 2));
|
||||||
|
const cosY = Math.cos(yaw);
|
||||||
|
const sinY = Math.sin(yaw);
|
||||||
|
const dims = [lk.dimNear, lk.dimMid];
|
||||||
|
|
||||||
|
for (let i = 0; i < layout.panels.length; i++) {
|
||||||
|
const g = groupRefs.current[i];
|
||||||
|
if (!g) continue;
|
||||||
|
const c = layout.panels[i];
|
||||||
|
|
||||||
|
// Odd rows ride the shared offset mirrored, so the top strip slides
|
||||||
|
// against the near one (and a drag counter-scrolls them).
|
||||||
|
const f = Math.pow(mt.speedStep, c.row) * (c.row % 2 ? -1 : 1);
|
||||||
|
let lx = (c.x0 - st.offset * f) % c.length;
|
||||||
|
if (lx < 0) lx += c.length;
|
||||||
|
lx -= c.length / 2;
|
||||||
|
g.position.set(lx, c.y, c.z);
|
||||||
|
|
||||||
|
// Screen-space vignette: project the panel center through the yaw and
|
||||||
|
// fade panels out toward (and past) the viewport edges, so the wall
|
||||||
|
// dissolves into the background instead of ending in a hard cut.
|
||||||
|
const wx = cosY * lx + sinY * c.z;
|
||||||
|
const wz = -sinY * lx + cosY * c.z;
|
||||||
|
const projX = wx * (dist / (dist - wz));
|
||||||
|
const edge = Math.abs(projX) / (size.width / 2);
|
||||||
|
const vig = 1 - lk.edgeFade * smoothstep(0.72, 1.3, edge);
|
||||||
|
|
||||||
|
const ready = videos[c.clip].readyState >= 2 ? 1 : 0;
|
||||||
|
videoAlpha[i] += (ready - videoAlpha[i]) * (1 - Math.exp(-3 * dt));
|
||||||
|
const va = videoAlpha[i];
|
||||||
|
|
||||||
|
// Depth dimming: dark theme crushes deep rows toward black (the bg);
|
||||||
|
// light theme washes them toward the paper via opacity instead.
|
||||||
|
const dim = dims[c.row];
|
||||||
|
const mat = materials[i];
|
||||||
|
if (dark) {
|
||||||
|
mat.color.setScalar(dim * vig * va);
|
||||||
|
mat.opacity = intro;
|
||||||
|
} else {
|
||||||
|
// Light theme recedes via opacity toward the paper — but only for
|
||||||
|
// depth: the near row keeps nearly full ink (sqrt softens its dim)
|
||||||
|
// and the wash applies per-row, else everything goes pastel.
|
||||||
|
mat.color.setScalar(1);
|
||||||
|
mat.opacity =
|
||||||
|
intro * Math.sqrt(dim) * vig * va * (1 - lk.washLight * c.row);
|
||||||
|
}
|
||||||
|
frameMaterials[i].opacity = lk.frameAlpha * vig * intro;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={rigRef}>
|
||||||
|
{layout.panels.map((c, i) => (
|
||||||
|
<group
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
groupRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
position={[0, c.y, c.z]}
|
||||||
|
>
|
||||||
|
<mesh material={materials[i]}>
|
||||||
|
<planeGeometry args={[c.w, c.h]} />
|
||||||
|
</mesh>
|
||||||
|
{/* Hairline frame, matching the site's bordered viewport idiom. */}
|
||||||
|
<lineSegments geometry={layout.frames[i]} material={frameMaterials[i]} />
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Component, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
Component,
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
||||||
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
|
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
|
||||||
import { useTunerStore } from "@/lib/tuner";
|
import HeroWall, { createWallDrag } from "./hero-wall";
|
||||||
import {
|
|
||||||
sampleWordmark,
|
|
||||||
WordmarkSim,
|
|
||||||
type SampledWordmark,
|
|
||||||
} from "./wordmark-sim";
|
|
||||||
import {
|
|
||||||
formationTuner,
|
|
||||||
physicsTuner,
|
|
||||||
lightTuner,
|
|
||||||
materialTuner,
|
|
||||||
haloTuner,
|
|
||||||
} from "./hero-tunables";
|
|
||||||
|
|
||||||
const WORDMARK = "artcraft";
|
|
||||||
const FONT_WEIGHT = 700;
|
|
||||||
|
|
||||||
type PointerState = {
|
type PointerState = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -35,17 +12,20 @@ type PointerState = {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The hero wordmark: "artcraft" built from small 3D balls. The cursor is both
|
// The hero: the wordmark set in the site's display type, floating over a
|
||||||
// a point light and a push force — the balls part around it, pop toward the
|
// perspective render wall — two film-strip rows of Seedance takes forming
|
||||||
// viewer, and rejoin on their springs. On mount they assemble from a
|
// one yawed wall behind the word, drifting sideways at parallax speeds.
|
||||||
// scattered cloud. Server HTML, reduced-motion visitors, and WebGL failures
|
// The type is real DOM text (crisp at any size, selectable by crawlers);
|
||||||
// all get the static display-type wordmark instead.
|
// the wall is the WebGL layer behind it. Dragging anywhere throws the wall
|
||||||
|
// with momentum; the pointer tilts it a few degrees. Server HTML,
|
||||||
|
// reduced-motion visitors, and WebGL failures all get the plain wordmark
|
||||||
|
// on the page background.
|
||||||
export default function HeroWordmark() {
|
export default function HeroWordmark() {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [colors, setColors] = useState<ThemeColors | null>(null);
|
const [colors, setColors] = useState<ThemeColors | null>(null);
|
||||||
const [sample, setSample] = useState<SampledWordmark | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const pointerRef = useRef<PointerState>({ x: 0, y: 0, active: false });
|
const pointerRef = useRef<PointerState>({ x: 0, y: 0, active: false });
|
||||||
|
const wallDragRef = useRef(createWallDrag());
|
||||||
|
|
||||||
// Gate: motion allowed and the tab actually foregrounded (a canvas born in
|
// Gate: motion allowed and the tab actually foregrounded (a canvas born in
|
||||||
// a hidden tab can come up blank).
|
// a hidden tab can come up blank).
|
||||||
@@ -68,134 +48,137 @@ export default function HeroWordmark() {
|
|||||||
return watchThemeColors(setColors);
|
return watchThemeColors(setColors);
|
||||||
}, [ready]);
|
}, [ready]);
|
||||||
|
|
||||||
// Rasterize + sample once the display font is really loaded; re-sample when
|
// Pointer in scene coordinates (origin at container center, y up), plus
|
||||||
// the container width changes meaningfully (not on mobile URL-bar jitter)
|
// drag bookkeeping for the wall: pressing anywhere grabs the wall and
|
||||||
// or when a Formation tunable moves in the dev tuner.
|
// throws it, while pointer movement tilts the whole perspective.
|
||||||
useEffect(() => {
|
|
||||||
if (!ready) return;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
let lastWidth = 0;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
const build = async (force = false) => {
|
|
||||||
const width = container.clientWidth;
|
|
||||||
if (!width || (!force && width === lastWidth)) return;
|
|
||||||
lastWidth = width;
|
|
||||||
|
|
||||||
const family = getComputedStyle(container).fontFamily;
|
|
||||||
try {
|
|
||||||
await document.fonts.load(`${FONT_WEIGHT} 100px ${family}`);
|
|
||||||
} catch {
|
|
||||||
// Fall through — canvas will use the fallback font, still legible.
|
|
||||||
}
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const { ballScale, ...formation } = formationTuner.read();
|
|
||||||
const spacing = Math.min(18, Math.max(11, width / 62)) * ballScale;
|
|
||||||
setSample(
|
|
||||||
sampleWordmark({
|
|
||||||
text: WORDMARK,
|
|
||||||
fontFamily: family,
|
|
||||||
fontWeight: FONT_WEIGHT,
|
|
||||||
targetWidth: Math.min(width * 0.88, 1280),
|
|
||||||
spacing,
|
|
||||||
formation,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
build();
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(build, 350);
|
|
||||||
});
|
|
||||||
ro.observe(container);
|
|
||||||
|
|
||||||
// Formation tunables change the arrangement itself — debounce a rebuild.
|
|
||||||
let lastFormation = JSON.stringify(formationTuner.read());
|
|
||||||
const unsubscribe = useTunerStore.subscribe(() => {
|
|
||||||
const now = JSON.stringify(formationTuner.read());
|
|
||||||
if (now === lastFormation) return;
|
|
||||||
lastFormation = now;
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => build(true), 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
ro.disconnect();
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [ready]);
|
|
||||||
|
|
||||||
// Pointer in text-plane coordinates (origin at container center, y up).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
const dragLast = { x: 0, t: 0 };
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
pointerRef.current.x = e.clientX - rect.left - rect.width / 2;
|
pointerRef.current.x = e.clientX - rect.left - rect.width / 2;
|
||||||
pointerRef.current.y = -(e.clientY - rect.top - rect.height / 2);
|
pointerRef.current.y = -(e.clientY - rect.top - rect.height / 2);
|
||||||
pointerRef.current.active = true;
|
pointerRef.current.active = true;
|
||||||
|
const d = wallDragRef.current;
|
||||||
|
if (d.dragging) {
|
||||||
|
const now = performance.now();
|
||||||
|
const dx = e.clientX - dragLast.x;
|
||||||
|
const dt = Math.max(8, now - dragLast.t) / 1000;
|
||||||
|
d.dx += dx;
|
||||||
|
d.vel = d.vel * 0.75 + (-dx / dt) * 0.25;
|
||||||
|
dragLast.x = e.clientX;
|
||||||
|
dragLast.t = now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
onMove(e);
|
||||||
|
const d = wallDragRef.current;
|
||||||
|
d.dragging = true;
|
||||||
|
d.dx = 0;
|
||||||
|
d.vel = 0;
|
||||||
|
dragLast.x = e.clientX;
|
||||||
|
dragLast.t = performance.now();
|
||||||
|
container.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
wallDragRef.current.dragging = false;
|
||||||
};
|
};
|
||||||
const onLeave = () => {
|
const onLeave = () => {
|
||||||
pointerRef.current.active = false;
|
pointerRef.current.active = false;
|
||||||
|
wallDragRef.current.dragging = false;
|
||||||
};
|
};
|
||||||
container.addEventListener("pointermove", onMove);
|
container.addEventListener("pointermove", onMove);
|
||||||
container.addEventListener("pointerdown", onMove);
|
container.addEventListener("pointerdown", onDown);
|
||||||
|
container.addEventListener("pointerup", onUp);
|
||||||
container.addEventListener("pointerleave", onLeave);
|
container.addEventListener("pointerleave", onLeave);
|
||||||
container.addEventListener("pointercancel", onLeave);
|
container.addEventListener("pointercancel", onLeave);
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener("pointermove", onMove);
|
container.removeEventListener("pointermove", onMove);
|
||||||
container.removeEventListener("pointerdown", onMove);
|
container.removeEventListener("pointerdown", onDown);
|
||||||
|
container.removeEventListener("pointerup", onUp);
|
||||||
container.removeEventListener("pointerleave", onLeave);
|
container.removeEventListener("pointerleave", onLeave);
|
||||||
container.removeEventListener("pointercancel", onLeave);
|
container.removeEventListener("pointercancel", onLeave);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const active = ready && colors !== null && sample !== null;
|
const active = ready && colors !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="font-display relative h-[52vh] min-h-[340px] w-full touch-pan-y select-none md:h-[62vh]"
|
className="relative h-[64svh] min-h-110 w-full touch-pan-y select-none"
|
||||||
>
|
>
|
||||||
{/* Static wordmark: the server-rendered face of the hero, replaced by
|
{active && (
|
||||||
the ball field once it's live. */}
|
|
||||||
<p
|
|
||||||
aria-hidden={active || undefined}
|
|
||||||
className={`absolute inset-0 flex items-center justify-center font-semibold lowercase tracking-[-0.05em] text-ink-strong ${
|
|
||||||
active ? "invisible" : ""
|
|
||||||
}`}
|
|
||||||
style={{ fontSize: "clamp(4rem, 13.5vw, 13rem)" }}
|
|
||||||
>
|
|
||||||
{WORDMARK}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{ready && colors && sample && (
|
|
||||||
<CanvasBoundary>
|
<CanvasBoundary>
|
||||||
<Canvas
|
<Canvas
|
||||||
aria-hidden
|
aria-hidden
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
gl={{
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
}}
|
||||||
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
|
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
<FittedCamera />
|
<FittedCamera />
|
||||||
<BallField sample={sample} colors={colors} pointer={pointerRef} />
|
<HeroWall pointer={pointerRef} drag={wallDragRef} colors={colors} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</CanvasBoundary>
|
</CanvasBoundary>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The wordmark, justified flush to the hero width (the big-type
|
||||||
|
poster treatment): SVG text with textLength stretches the word
|
||||||
|
edge-to-edge at any viewport, so the height rides the width via
|
||||||
|
the viewBox aspect. Still real selectable/crawlable text. The
|
||||||
|
soft drop-shadow is a legibility halo lifting the type off the
|
||||||
|
busy footage, not decoration (vw units so it scales with the
|
||||||
|
word). */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center px-4 text-ink-strong md:px-6">
|
||||||
|
<svg
|
||||||
|
className="w-[62%]"
|
||||||
|
viewBox="0 0 720 74"
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
filter:
|
||||||
|
"drop-shadow(0 0 0.3vw color-mix(in srgb, var(--bg) 60%, transparent)) drop-shadow(0 0.2vw 1.2vw color-mix(in srgb, var(--bg) 45%, transparent))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
x="360"
|
||||||
|
y="72"
|
||||||
|
textAnchor="middle"
|
||||||
|
textLength="780"
|
||||||
|
lengthAdjust="spacingAndGlyphs"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
"var(--font-archivo-black), var(--font-archivo), system-ui, sans-serif",
|
||||||
|
fontSize: "100px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ARTCRAFT
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-1.5 z-10 flex items-center justify-between px-6 md:px-10"
|
||||||
|
>
|
||||||
|
<p className="hud-label text-faint">Rendered with Seedance 2.5</p>
|
||||||
|
<p className="hud-label hidden text-faint sm:block">drag to scroll</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perspective camera fitted so 1 world unit == 1 CSS pixel on the z=0 plane:
|
// Perspective camera fitted so 1 world unit == 1 CSS pixel on the z=0 plane:
|
||||||
// raster coordinates, sim coordinates, and pointer coordinates all agree.
|
// wall layout and pointer coordinates all agree. FOV must match hero-wall's
|
||||||
|
// vignette projection.
|
||||||
function FittedCamera() {
|
function FittedCamera() {
|
||||||
const camera = useThree((s) => s.camera) as THREE.PerspectiveCamera;
|
const camera = useThree((s) => s.camera) as THREE.PerspectiveCamera;
|
||||||
const size = useThree((s) => s.size);
|
const size = useThree((s) => s.size);
|
||||||
@@ -204,278 +187,13 @@ function FittedCamera() {
|
|||||||
const dist = size.height / 2 / Math.tan(THREE.MathUtils.degToRad(fov / 2));
|
const dist = size.height / 2 / Math.tan(THREE.MathUtils.degToRad(fov / 2));
|
||||||
camera.fov = fov;
|
camera.fov = fov;
|
||||||
camera.position.set(0, 0, dist);
|
camera.position.set(0, 0, dist);
|
||||||
camera.near = Math.max(1, dist - 900);
|
camera.near = Math.max(1, dist - 1200);
|
||||||
camera.far = dist + 900;
|
camera.far = dist + 1200;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}, [camera, size.height]);
|
}, [camera, size.height]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Studio reflections without any network fetch: three's procedural room,
|
|
||||||
// prefiltered once per WebGL context. This is what gives the dark "car
|
|
||||||
// paint" balls something to mirror.
|
|
||||||
function StudioEnvironment() {
|
|
||||||
const gl = useThree((s) => s.gl);
|
|
||||||
const scene = useThree((s) => s.scene);
|
|
||||||
useEffect(() => {
|
|
||||||
const pmrem = new THREE.PMREMGenerator(gl);
|
|
||||||
const env = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
||||||
scene.environment = env;
|
|
||||||
return () => {
|
|
||||||
scene.environment = null;
|
|
||||||
env.dispose();
|
|
||||||
pmrem.dispose();
|
|
||||||
};
|
|
||||||
}, [gl, scene]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HALO_VERTEX = /* glsl */ `
|
|
||||||
varying vec2 vUv;
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const HALO_FRAGMENT = /* glsl */ `
|
|
||||||
varying vec2 vUv;
|
|
||||||
uniform vec3 uColor;
|
|
||||||
uniform float uAlpha;
|
|
||||||
uniform float uFalloff;
|
|
||||||
void main() {
|
|
||||||
float d = distance(vUv, vec2(0.5)) * 2.0;
|
|
||||||
float a = pow(clamp(1.0 - d, 0.0, 1.0), uFalloff);
|
|
||||||
gl_FragColor = vec4(uColor, a * uAlpha);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function BallField({
|
|
||||||
sample,
|
|
||||||
colors,
|
|
||||||
pointer,
|
|
||||||
}: {
|
|
||||||
sample: SampledWordmark;
|
|
||||||
colors: ThemeColors;
|
|
||||||
pointer: React.RefObject<PointerState>;
|
|
||||||
}) {
|
|
||||||
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
||||||
const lightRef = useRef<THREE.PointLight>(null);
|
|
||||||
const haloRef = useRef<THREE.Mesh>(null);
|
|
||||||
const ambientRef = useRef<THREE.AmbientLight>(null);
|
|
||||||
const keyRef = useRef<THREE.DirectionalLight>(null);
|
|
||||||
const counterRef = useRef<THREE.DirectionalLight>(null);
|
|
||||||
const fillRef = useRef<THREE.DirectionalLight>(null);
|
|
||||||
const lightPos = useRef(new THREE.Vector3(0, 120, 0));
|
|
||||||
const sim = useMemo(
|
|
||||||
() => new WordmarkSim(sample, physicsTuner.read().scatter),
|
|
||||||
[sample],
|
|
||||||
);
|
|
||||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
|
||||||
// Reactive so recoloring reruns when the accent share slider moves.
|
|
||||||
const accentShare = useTunerStore(
|
|
||||||
(s) => s.values["material.accentShare"] ?? 0.05,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dark = useMemo(() => {
|
|
||||||
const c = new THREE.Color(colors.bg);
|
|
||||||
return c.r + c.g + c.b < 1.5;
|
|
||||||
}, [colors.bg]);
|
|
||||||
|
|
||||||
// The halo sits behind the text plane, so the balls eclipse it: bodies go
|
|
||||||
// to silhouette against the glow while their rims catch the back light.
|
|
||||||
const haloMaterial = useMemo(
|
|
||||||
() =>
|
|
||||||
new THREE.ShaderMaterial({
|
|
||||||
vertexShader: HALO_VERTEX,
|
|
||||||
fragmentShader: HALO_FRAGMENT,
|
|
||||||
uniforms: {
|
|
||||||
uColor: {
|
|
||||||
value: new THREE.Color(dark ? "#a8c4ff" : colors.accent),
|
|
||||||
},
|
|
||||||
uAlpha: { value: dark ? 0.5 : 0.14 },
|
|
||||||
uFalloff: { value: 2.6 },
|
|
||||||
},
|
|
||||||
transparent: true,
|
|
||||||
depthWrite: false,
|
|
||||||
blending: dark ? THREE.AdditiveBlending : THREE.NormalBlending,
|
|
||||||
}),
|
|
||||||
[dark, colors.accent],
|
|
||||||
);
|
|
||||||
useEffect(() => () => haloMaterial.dispose(), [haloMaterial]);
|
|
||||||
|
|
||||||
// Per-ball colors. Light theme: ink with slight tonal variety. Dark theme:
|
|
||||||
// near-black lacquer, so shape comes from reflections and the back rim
|
|
||||||
// instead of albedo. Both keep sparse brand-accent balls.
|
|
||||||
useEffect(() => {
|
|
||||||
const mesh = meshRef.current;
|
|
||||||
if (!mesh) return;
|
|
||||||
const bg = new THREE.Color(colors.bg);
|
|
||||||
const ink = new THREE.Color(colors.ink);
|
|
||||||
const accent = new THREE.Color(colors.accent);
|
|
||||||
const base = dark
|
|
||||||
? bg.clone().lerp(ink, 0.10)
|
|
||||||
: ink.clone();
|
|
||||||
const c = new THREE.Color();
|
|
||||||
const rand = seeded(4242);
|
|
||||||
for (let i = 0; i < sim.n; i++) {
|
|
||||||
if (rand() < accentShare) {
|
|
||||||
c.copy(accent);
|
|
||||||
if (dark) c.multiplyScalar(0.8);
|
|
||||||
} else {
|
|
||||||
c.copy(base).lerp(ink, rand() * (dark ? 0.08 : 0.16));
|
|
||||||
}
|
|
||||||
mesh.setColorAt(i, c);
|
|
||||||
}
|
|
||||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
|
||||||
}, [colors, sim, dark, accentShare]);
|
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
|
||||||
const mesh = meshRef.current;
|
|
||||||
if (!mesh) return;
|
|
||||||
const p = pointer.current;
|
|
||||||
|
|
||||||
// Live tuner values — read every frame so slider changes apply instantly.
|
|
||||||
const phys = physicsTuner.read();
|
|
||||||
const lt = lightTuner.read();
|
|
||||||
const mt = materialTuner.read();
|
|
||||||
const ht = haloTuner.read();
|
|
||||||
|
|
||||||
sim.step(delta, p.x, p.y, p.active, phys);
|
|
||||||
|
|
||||||
for (let i = 0; i < sim.n; i++) {
|
|
||||||
dummy.position.set(sim.x[i], sim.y[i], sim.z[i]);
|
|
||||||
dummy.scale.setScalar(sim.r[i]);
|
|
||||||
dummy.updateMatrix();
|
|
||||||
mesh.setMatrixAt(i, dummy.matrix);
|
|
||||||
}
|
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
|
||||||
|
|
||||||
// The cursor light (and its halo) chase the pointer; unattended they
|
|
||||||
// wander a slow Lissajous path so the relief never goes flat.
|
|
||||||
const t = state.clock.elapsedTime;
|
|
||||||
const targetX = p.active ? p.x : Math.sin(t * 0.22) * 320;
|
|
||||||
const targetY = p.active ? p.y : Math.cos(t * 0.17) * 130;
|
|
||||||
const k = 1 - Math.exp(-6 * delta);
|
|
||||||
lightPos.current.x += (targetX - lightPos.current.x) * k;
|
|
||||||
lightPos.current.y += (targetY - lightPos.current.y) * k;
|
|
||||||
lightRef.current?.position.set(
|
|
||||||
lightPos.current.x,
|
|
||||||
lightPos.current.y,
|
|
||||||
lt.lightZ,
|
|
||||||
);
|
|
||||||
haloRef.current?.position.set(
|
|
||||||
lightPos.current.x,
|
|
||||||
lightPos.current.y,
|
|
||||||
ht.z,
|
|
||||||
);
|
|
||||||
|
|
||||||
// At rest the word sits in near-eclipse; approaching with the cursor
|
|
||||||
// brings the light up to full.
|
|
||||||
const light = lightRef.current;
|
|
||||||
if (light) {
|
|
||||||
light.distance = lt.lightRange;
|
|
||||||
const full = dark ? lt.intensityDark : lt.intensityLight;
|
|
||||||
const target = p.active ? full : full * lt.idleDim;
|
|
||||||
light.intensity += (target - light.intensity) * k;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rig + material + halo bindings.
|
|
||||||
if (ambientRef.current) {
|
|
||||||
ambientRef.current.intensity = dark ? lt.ambientDark : lt.ambientLight;
|
|
||||||
}
|
|
||||||
if (keyRef.current) {
|
|
||||||
keyRef.current.intensity = dark ? lt.keyIntensity : lt.dirLight;
|
|
||||||
}
|
|
||||||
if (counterRef.current) counterRef.current.intensity = lt.counterIntensity;
|
|
||||||
if (fillRef.current) fillRef.current.intensity = lt.frontFill;
|
|
||||||
|
|
||||||
const mat = mesh.material as THREE.MeshPhysicalMaterial &
|
|
||||||
THREE.MeshStandardMaterial;
|
|
||||||
if (dark) {
|
|
||||||
mat.roughness = mt.roughnessDark;
|
|
||||||
mat.metalness = mt.metalnessDark;
|
|
||||||
mat.clearcoat = mt.clearcoat;
|
|
||||||
mat.clearcoatRoughness = mt.clearcoatRoughness;
|
|
||||||
} else {
|
|
||||||
mat.roughness = mt.roughnessLight;
|
|
||||||
mat.metalness = mt.metalnessLight;
|
|
||||||
mat.envMapIntensity = mt.envLight;
|
|
||||||
}
|
|
||||||
|
|
||||||
haloMaterial.uniforms.uAlpha.value = dark ? ht.alphaDark : ht.alphaLight;
|
|
||||||
haloMaterial.uniforms.uFalloff.value = ht.falloff;
|
|
||||||
haloRef.current?.scale.setScalar(ht.size / 720);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* No environment in dark mode: three applies scene.environment at
|
|
||||||
effectively full strength here regardless of envMapIntensity, and
|
|
||||||
any env flood washes the eclipse out. The rim lights are the only
|
|
||||||
broad sources; the cursor light does the revealing. */}
|
|
||||||
{!dark && <StudioEnvironment />}
|
|
||||||
|
|
||||||
{dark ? (
|
|
||||||
// Eclipse rig: almost no front light. A cool key from behind-above
|
|
||||||
// rims the top edges, a faint counter-rim catches the lower-right,
|
|
||||||
// and the cursor light does the actual revealing.
|
|
||||||
<>
|
|
||||||
<ambientLight ref={ambientRef} intensity={0.05} />
|
|
||||||
<directionalLight ref={keyRef} position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
|
|
||||||
<directionalLight ref={counterRef} position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
|
|
||||||
<directionalLight ref={fillRef} position={[0, -60, 520]} intensity={0.06} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ambientLight ref={ambientRef} intensity={0.55} />
|
|
||||||
<directionalLight ref={keyRef} position={[-260, 340, 420]} intensity={0.75} />
|
|
||||||
<directionalLight position={[180, 260, -420]} intensity={0.5} color="#eef3ff" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cursor light: physical inverse-square falloff, so nearby balls are
|
|
||||||
genuinely brighter than distant ones. Intensity is candela-scale
|
|
||||||
because the scene is measured in pixels. */}
|
|
||||||
<pointLight
|
|
||||||
ref={lightRef}
|
|
||||||
position={[0, 120, 130]}
|
|
||||||
intensity={dark ? 42000 : 30000}
|
|
||||||
decay={2}
|
|
||||||
distance={900}
|
|
||||||
color={dark ? "#d6e4ff" : "#ffffff"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<mesh ref={haloRef} position={[0, 120, -90]} material={haloMaterial}>
|
|
||||||
<planeGeometry args={[720, 720]} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
<instancedMesh
|
|
||||||
ref={meshRef}
|
|
||||||
args={[undefined, undefined, sim.n]}
|
|
||||||
frustumCulled={false}
|
|
||||||
>
|
|
||||||
<sphereGeometry args={[1, 24, 16]} />
|
|
||||||
{dark ? (
|
|
||||||
<meshPhysicalMaterial
|
|
||||||
roughness={0.42}
|
|
||||||
metalness={0.5}
|
|
||||||
clearcoat={0.9}
|
|
||||||
clearcoatRoughness={0.22}
|
|
||||||
envMapIntensity={0.05}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<meshStandardMaterial
|
|
||||||
roughness={0.3}
|
|
||||||
metalness={0.15}
|
|
||||||
envMapIntensity={0.55}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</instancedMesh>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class CanvasBoundary extends Component<
|
class CanvasBoundary extends Component<
|
||||||
{ children: ReactNode },
|
{ children: ReactNode },
|
||||||
{ failed: boolean }
|
{ failed: boolean }
|
||||||
@@ -490,14 +208,3 @@ class CanvasBoundary extends Component<
|
|||||||
return this.state.failed ? null : this.props.children;
|
return this.state.failed ? null : this.props.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seeded(seed: number): () => number {
|
|
||||||
let a = seed >>> 0;
|
|
||||||
return () => {
|
|
||||||
a |= 0;
|
|
||||||
a = (a + 0x6d2b79f5) | 0;
|
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -9,15 +9,16 @@ import HeroViewport from "./hero-viewport";
|
|||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
return (
|
return (
|
||||||
<section className="relative">
|
<section className="relative">
|
||||||
{/* Full-bleed ball-field wordmark — the brand as an object you can
|
{/* Full-bleed hero — crisp display-type wordmark over the render
|
||||||
shove apart and watch reassemble. */}
|
wall: rows of Seedance takes forming one perspective wall that
|
||||||
|
drifts behind the word and answers the cursor. */}
|
||||||
<HeroWordmark />
|
<HeroWordmark />
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-[1280px] border-x border-line">
|
<div className="relative mx-auto max-w-[1280px] border-x border-line">
|
||||||
<div className="flex items-center justify-between gap-4 border-y border-line px-6 py-3 md:px-10">
|
<div className="flex items-center justify-between gap-4 border-y border-line px-6 py-3 md:px-10">
|
||||||
<p className="hud-label text-muted">Open-source AI studio</p>
|
<p className="hud-label text-muted">Open-source AI studio</p>
|
||||||
<p className="hud-label text-accent-ink">
|
<p className="hud-label text-accent-ink">
|
||||||
Now with Seedance 2.0, Nano Banana 2 & more
|
Now with Seedance 2.5, Nano Banana 2 & more
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,437 +0,0 @@
|
|||||||
// Wordmark ball field: the brand name rasterized to a mask, then filled with
|
|
||||||
// small 3D balls by multi-pass dart throwing — big balls land first, smaller
|
|
||||||
// ones settle into the gaps, like a pile that collided into the letter shape
|
|
||||||
// (a faked impression, not a physics solve; it must run on laptops/phones).
|
|
||||||
// The balls separate under the cursor's push and rejoin on their home
|
|
||||||
// springs; a 3D collision pass keeps them from intersecting.
|
|
||||||
//
|
|
||||||
// Units are CSS pixels at the text's z=0 plane — the camera is fitted so one
|
|
||||||
// world unit projects to one pixel there, which keeps pointer math trivial.
|
|
||||||
|
|
||||||
export type Ball = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
z: number;
|
|
||||||
r: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SampledWordmark = {
|
|
||||||
balls: Ball[];
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// All knobs of the arrangement — registered with the dev tuner in
|
|
||||||
// hero-tunables.ts. Size factors are multiples of `spacing`.
|
|
||||||
export type FormationParams = {
|
|
||||||
sizeMin: number;
|
|
||||||
sizeMax: number;
|
|
||||||
sizeNoiseScale: number; // px wavelength of the size clusters
|
|
||||||
sizeRoughen: number; // 0..1 per-ball white-noise contribution
|
|
||||||
gap: number; // px clearance between resting balls
|
|
||||||
depth: number; // slab thickness, × spacing
|
|
||||||
density: number; // dart-attempt multiplier
|
|
||||||
edgeMargin: number; // 0..1 — how far inside the stroke big balls stay
|
|
||||||
seed: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ————————————————————————————— sampling —————————————————————————————
|
|
||||||
|
|
||||||
export function sampleWordmark(opts: {
|
|
||||||
text: string;
|
|
||||||
fontFamily: string;
|
|
||||||
fontWeight: number;
|
|
||||||
targetWidth: number;
|
|
||||||
/** Base unit in px — ball sizes and slab depth scale from this. */
|
|
||||||
spacing: number;
|
|
||||||
formation: FormationParams;
|
|
||||||
}): SampledWordmark {
|
|
||||||
const { text, fontFamily, fontWeight, targetWidth, spacing, formation } =
|
|
||||||
opts;
|
|
||||||
|
|
||||||
// Each glyph is measured and drawn individually with a small manual gap:
|
|
||||||
// no shaping ever runs across letters, so the font can't fuse tight pairs
|
|
||||||
// ("ft") into a ligature that would blob together once quantized to balls.
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
|
|
||||||
const glyphs = [...text];
|
|
||||||
const GAP_EM = 0.05;
|
|
||||||
|
|
||||||
ctx.font = `${fontWeight} 100px ${fontFamily}`;
|
|
||||||
const width100 = glyphs.reduce((w, g) => w + ctx.measureText(g).width, 0)
|
|
||||||
+ 100 * GAP_EM * (glyphs.length - 1);
|
|
||||||
const fontPx = (100 * targetWidth) / Math.max(1, width100);
|
|
||||||
const gapPx = fontPx * GAP_EM;
|
|
||||||
|
|
||||||
ctx.font = `${fontWeight} ${fontPx}px ${fontFamily}`;
|
|
||||||
let ascent = 0;
|
|
||||||
let descent = 0;
|
|
||||||
for (const g of glyphs) {
|
|
||||||
const m = ctx.measureText(g);
|
|
||||||
ascent = Math.max(ascent, m.actualBoundingBoxAscent);
|
|
||||||
descent = Math.max(descent, m.actualBoundingBoxDescent);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pad = spacing;
|
|
||||||
const width = Math.ceil(targetWidth + pad * 2);
|
|
||||||
const height = Math.ceil(ascent + descent + pad * 2);
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
ctx.font = `${fontWeight} ${fontPx}px ${fontFamily}`;
|
|
||||||
ctx.fillStyle = "#fff";
|
|
||||||
let penX = pad;
|
|
||||||
for (const g of glyphs) {
|
|
||||||
ctx.fillText(g, penX, pad + ascent);
|
|
||||||
penX += ctx.measureText(g).width + gapPx;
|
|
||||||
}
|
|
||||||
const alpha = ctx.getImageData(0, 0, width, height).data;
|
|
||||||
const covered = (x: number, y: number) => {
|
|
||||||
if (x < 0 || y < 0 || x >= width || y >= height) return false;
|
|
||||||
return alpha[(y * width + x) * 4 + 3] > 127;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fast "random point inside the ink" sampling: collect covered pixels on a
|
|
||||||
// coarse stride once, then darts pick from that list with subpixel jitter.
|
|
||||||
const inkCells: number[] = [];
|
|
||||||
for (let y = 0; y < height; y += 2) {
|
|
||||||
for (let x = 0; x < width; x += 2) {
|
|
||||||
if (covered(x, y)) inkCells.push(y * width + x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!inkCells.length) return { balls: [], width, height };
|
|
||||||
|
|
||||||
const rand = mulberry32(formation.seed);
|
|
||||||
const sizeNoise = makeValueNoise(formation.seed + 41);
|
|
||||||
const rMin = spacing * formation.sizeMin;
|
|
||||||
const rMax = spacing * formation.sizeMax;
|
|
||||||
const halfDepth = (spacing * formation.depth) / 2;
|
|
||||||
|
|
||||||
// Organic sizing: smooth spatial noise drifts the size in clusters across
|
|
||||||
// the word; white noise roughens it per ball.
|
|
||||||
const desiredRadius = (x: number, y: number) => {
|
|
||||||
const n = Math.pow(
|
|
||||||
sizeNoise(x / formation.sizeNoiseScale, y / formation.sizeNoiseScale),
|
|
||||||
1.15,
|
|
||||||
);
|
|
||||||
const roughened =
|
|
||||||
n * (1 - formation.sizeRoughen) + rand() * formation.sizeRoughen;
|
|
||||||
return rMin + (rMax - rMin) * roughened;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A ball only counts as "inside the letter" if its center plus four cross
|
|
||||||
// points at ~edgeMargin·r are on ink — big balls stay inside the strokes,
|
|
||||||
// small ones may nestle right up to the edge, keeping letterforms crisp.
|
|
||||||
const insideStroke = (x: number, y: number, r: number) => {
|
|
||||||
const m = r * formation.edgeMargin;
|
|
||||||
return (
|
|
||||||
covered(Math.round(x), Math.round(y)) &&
|
|
||||||
covered(Math.round(x + m), Math.round(y)) &&
|
|
||||||
covered(Math.round(x - m), Math.round(y)) &&
|
|
||||||
covered(Math.round(x), Math.round(y + m)) &&
|
|
||||||
covered(Math.round(x), Math.round(y - m))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dart throwing with a 3D hash for overlap rejection. Three passes with a
|
|
||||||
// descending size floor: the big balls claim their spots first and the
|
|
||||||
// smaller ones settle into the leftover gaps — the arrangement reads like
|
|
||||||
// a settled pile instead of a grid.
|
|
||||||
const balls: Ball[] = [];
|
|
||||||
const cell = rMax * 2 + formation.gap;
|
|
||||||
const grid = new Map<number, number[]>();
|
|
||||||
const key = (x: number, y: number, z: number) =>
|
|
||||||
(Math.floor(x / cell) * 73856093) ^
|
|
||||||
(Math.floor(y / cell) * 19349663) ^
|
|
||||||
(Math.floor(z / cell) * 83492791);
|
|
||||||
|
|
||||||
const fits = (x: number, y: number, z: number, r: number) => {
|
|
||||||
for (let gz = -1; gz <= 1; gz++) {
|
|
||||||
for (let gy = -1; gy <= 1; gy++) {
|
|
||||||
for (let gx = -1; gx <= 1; gx++) {
|
|
||||||
const bucket = grid.get(
|
|
||||||
key(x + gx * cell, y + gy * cell, z + gz * cell),
|
|
||||||
);
|
|
||||||
if (!bucket) continue;
|
|
||||||
for (const j of bucket) {
|
|
||||||
const o = balls[j];
|
|
||||||
const dx = o.x - x;
|
|
||||||
const dy = o.y - y;
|
|
||||||
const dz = o.z - z;
|
|
||||||
const min = o.r + r + formation.gap;
|
|
||||||
if (dx * dx + dy * dy + dz * dz < min * min) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const passes = [0.7, 0.35, 0]; // descending floor on the size range
|
|
||||||
const attemptsPerPass = Math.floor(
|
|
||||||
inkCells.length * 0.9 * formation.density,
|
|
||||||
);
|
|
||||||
for (const floor of passes) {
|
|
||||||
for (let a = 0; a < attemptsPerPass; a++) {
|
|
||||||
const c = inkCells[(rand() * inkCells.length) | 0];
|
|
||||||
const x = (c % width) + rand() * 2 - 1;
|
|
||||||
const y = Math.floor(c / width) + rand() * 2 - 1;
|
|
||||||
const z = (rand() * 2 - 1) * halfDepth;
|
|
||||||
let r = desiredRadius(x, y);
|
|
||||||
const passMin = rMin + (rMax - rMin) * floor;
|
|
||||||
if (r < passMin) r = passMin + (r - rMin) * 0.2;
|
|
||||||
if (!insideStroke(x, y, r)) continue;
|
|
||||||
if (!fits(x, y, z, r)) continue;
|
|
||||||
balls.push({ x, y, z, r });
|
|
||||||
const k = key(x, y, z);
|
|
||||||
const bucket = grid.get(k);
|
|
||||||
if (bucket) bucket.push(balls.length - 1);
|
|
||||||
else grid.set(k, [balls.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { balls, width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic bilinear value noise, 0..1 — smooth variation across space.
|
|
||||||
function makeValueNoise(seed: number): (x: number, y: number) => number {
|
|
||||||
const hash = (ix: number, iy: number) => {
|
|
||||||
let h = ix * 374761393 + iy * 668265263 + seed * 2246822519;
|
|
||||||
h = (h ^ (h >>> 13)) * 1274126177;
|
|
||||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
|
||||||
};
|
|
||||||
const smooth = (t: number) => t * t * (3 - 2 * t);
|
|
||||||
return (x, y) => {
|
|
||||||
const ix = Math.floor(x);
|
|
||||||
const iy = Math.floor(y);
|
|
||||||
const fx = smooth(x - ix);
|
|
||||||
const fy = smooth(y - iy);
|
|
||||||
const a = hash(ix, iy);
|
|
||||||
const b = hash(ix + 1, iy);
|
|
||||||
const c = hash(ix, iy + 1);
|
|
||||||
const d = hash(ix + 1, iy + 1);
|
|
||||||
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mulberry32(seed: number): () => number {
|
|
||||||
let a = seed >>> 0;
|
|
||||||
return () => {
|
|
||||||
a |= 0;
|
|
||||||
a = (a + 0x6d2b79f5) | 0;
|
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ————————————————————————————— simulation —————————————————————————————
|
|
||||||
|
|
||||||
export type SimParams = {
|
|
||||||
spring: number; // pull toward home, 1/s²
|
|
||||||
damping: number; // exponential velocity decay, 1/s
|
|
||||||
pushRadius: number; // cursor influence radius, px
|
|
||||||
pushStrength: number; // px/s² at cursor center
|
|
||||||
swirl: number; // tangential (xy) fraction of the push
|
|
||||||
popZ: number; // fraction of the push biased toward the viewer
|
|
||||||
bobAmp: number; // idle breathing amplitude, px
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_PARAMS: SimParams = {
|
|
||||||
spring: 46,
|
|
||||||
damping: 5.2,
|
|
||||||
pushRadius: 180,
|
|
||||||
pushStrength: 23000,
|
|
||||||
swirl: 0.55,
|
|
||||||
popZ: 0.6,
|
|
||||||
bobAmp: 1.4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class WordmarkSim {
|
|
||||||
readonly n: number;
|
|
||||||
readonly r: Float32Array;
|
|
||||||
readonly x: Float32Array;
|
|
||||||
readonly y: Float32Array;
|
|
||||||
readonly z: Float32Array;
|
|
||||||
private readonly hx: Float32Array;
|
|
||||||
private readonly hy: Float32Array;
|
|
||||||
private readonly hz: Float32Array;
|
|
||||||
private readonly vx: Float32Array;
|
|
||||||
private readonly vy: Float32Array;
|
|
||||||
private readonly vz: Float32Array;
|
|
||||||
private readonly phase: Float32Array;
|
|
||||||
private readonly cell: number;
|
|
||||||
private readonly grid = new Map<number, number[]>();
|
|
||||||
private time = 0;
|
|
||||||
|
|
||||||
// Balls are handed over in raster coordinates; homes are re-centered so the
|
|
||||||
// wordmark sits centered on the origin. Live positions start scattered in a
|
|
||||||
// loose shell so the mount plays an "assemble" — the balls fly in and join.
|
|
||||||
constructor(sample: SampledWordmark, scatter = 480) {
|
|
||||||
const { balls, width, height } = sample;
|
|
||||||
this.n = balls.length;
|
|
||||||
this.r = new Float32Array(this.n);
|
|
||||||
this.x = new Float32Array(this.n);
|
|
||||||
this.y = new Float32Array(this.n);
|
|
||||||
this.z = new Float32Array(this.n);
|
|
||||||
this.hx = new Float32Array(this.n);
|
|
||||||
this.hy = new Float32Array(this.n);
|
|
||||||
this.hz = new Float32Array(this.n);
|
|
||||||
this.vx = new Float32Array(this.n);
|
|
||||||
this.vy = new Float32Array(this.n);
|
|
||||||
this.vz = new Float32Array(this.n);
|
|
||||||
this.phase = new Float32Array(this.n);
|
|
||||||
|
|
||||||
let rMax = 1;
|
|
||||||
const rand = mulberry32(7177);
|
|
||||||
for (let i = 0; i < this.n; i++) {
|
|
||||||
const b = balls[i];
|
|
||||||
this.hx[i] = b.x - width / 2;
|
|
||||||
this.hy[i] = -(b.y - height / 2);
|
|
||||||
this.hz[i] = b.z;
|
|
||||||
const dir = randomUnit(rand);
|
|
||||||
const dist = scatter * (0.55 + rand() * 0.9);
|
|
||||||
this.x[i] = this.hx[i] + dir[0] * dist;
|
|
||||||
this.y[i] = this.hy[i] + dir[1] * dist * 0.7;
|
|
||||||
this.z[i] = this.hz[i] + dir[2] * dist * 0.55;
|
|
||||||
this.r[i] = b.r;
|
|
||||||
if (b.r > rMax) rMax = b.r;
|
|
||||||
this.phase[i] = (i * 2.399963) % (Math.PI * 2);
|
|
||||||
}
|
|
||||||
this.cell = rMax * 2 + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
step(
|
|
||||||
dt: number,
|
|
||||||
pointerX: number,
|
|
||||||
pointerY: number,
|
|
||||||
pointerActive: boolean,
|
|
||||||
params: SimParams = DEFAULT_PARAMS,
|
|
||||||
) {
|
|
||||||
const clamped = Math.min(dt, 1 / 30);
|
|
||||||
this.time += clamped;
|
|
||||||
const { spring, damping, pushRadius, pushStrength, swirl, popZ, bobAmp } =
|
|
||||||
params;
|
|
||||||
const decay = Math.exp(-damping * clamped);
|
|
||||||
const t = this.time;
|
|
||||||
|
|
||||||
for (let i = 0; i < this.n; i++) {
|
|
||||||
const bob = bobAmp;
|
|
||||||
const homeX = this.hx[i] + Math.sin(t * 0.9 + this.phase[i]) * bob;
|
|
||||||
const homeY = this.hy[i] + Math.cos(t * 0.7 + this.phase[i] * 1.7) * bob;
|
|
||||||
const homeZ = this.hz[i] + Math.sin(t * 0.8 + this.phase[i] * 2.3) * bob;
|
|
||||||
let fx = (homeX - this.x[i]) * spring;
|
|
||||||
let fy = (homeY - this.y[i]) * spring;
|
|
||||||
let fz = (homeZ - this.z[i]) * spring;
|
|
||||||
|
|
||||||
if (pointerActive) {
|
|
||||||
// The cursor lives on the z=0 plane; distance is measured in 3D so
|
|
||||||
// front-layer balls feel it sooner than back-layer ones.
|
|
||||||
const dx = this.x[i] - pointerX;
|
|
||||||
const dy = this.y[i] - pointerY;
|
|
||||||
const dz = this.z[i];
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
||||||
if (dist < pushRadius) {
|
|
||||||
const f = 1 - dist / pushRadius;
|
|
||||||
const inv = dist > 1e-4 ? 1 / dist : 0;
|
|
||||||
const nx = dx * inv;
|
|
||||||
const ny = dy * inv;
|
|
||||||
const nz = dz * inv;
|
|
||||||
const s = f * f * pushStrength;
|
|
||||||
fx += (nx + -ny * swirl) * s;
|
|
||||||
fy += (ny + nx * swirl) * s;
|
|
||||||
// Radial z plus a toward-the-viewer bias: the crowd parts *and*
|
|
||||||
// pops out of the page instead of only sliding sideways.
|
|
||||||
fz += (nz + popZ) * s * 0.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.vx[i] = this.vx[i] * decay + fx * clamped;
|
|
||||||
this.vy[i] = this.vy[i] * decay + fy * clamped;
|
|
||||||
this.vz[i] = this.vz[i] * decay + fz * clamped;
|
|
||||||
this.x[i] += this.vx[i] * clamped;
|
|
||||||
this.y[i] += this.vy[i] * clamped;
|
|
||||||
this.z[i] += this.vz[i] * clamped;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resolveCollisions();
|
|
||||||
}
|
|
||||||
|
|
||||||
// One positional-relaxation pass over a 3D spatial hash. The rest pose is
|
|
||||||
// already gap-free by construction; this keeps shoved balls from tunneling
|
|
||||||
// through neighbors, which is what makes them read as solid.
|
|
||||||
private resolveCollisions() {
|
|
||||||
const { grid, cell } = this;
|
|
||||||
grid.clear();
|
|
||||||
const key = (x: number, y: number, z: number) =>
|
|
||||||
(Math.floor(x / cell) * 73856093) ^
|
|
||||||
(Math.floor(y / cell) * 19349663) ^
|
|
||||||
(Math.floor(z / cell) * 83492791);
|
|
||||||
|
|
||||||
for (let i = 0; i < this.n; i++) {
|
|
||||||
const k = key(this.x[i], this.y[i], this.z[i]);
|
|
||||||
const bucket = grid.get(k);
|
|
||||||
if (bucket) bucket.push(i);
|
|
||||||
else grid.set(k, [i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < this.n; i++) {
|
|
||||||
for (let gz = -1; gz <= 1; gz++) {
|
|
||||||
for (let gy = -1; gy <= 1; gy++) {
|
|
||||||
for (let gx = -1; gx <= 1; gx++) {
|
|
||||||
const bucket = grid.get(
|
|
||||||
key(
|
|
||||||
this.x[i] + gx * cell,
|
|
||||||
this.y[i] + gy * cell,
|
|
||||||
this.z[i] + gz * cell,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!bucket) continue;
|
|
||||||
for (const j of bucket) {
|
|
||||||
if (j <= i) continue;
|
|
||||||
const dx = this.x[j] - this.x[i];
|
|
||||||
const dy = this.y[j] - this.y[i];
|
|
||||||
const dz = this.z[j] - this.z[i];
|
|
||||||
const minDist = this.r[i] + this.r[j] + 0.4;
|
|
||||||
const d2 = dx * dx + dy * dy + dz * dz;
|
|
||||||
if (d2 >= minDist * minDist || d2 < 1e-6) continue;
|
|
||||||
const d = Math.sqrt(d2);
|
|
||||||
const push = (minDist - d) / d;
|
|
||||||
const px = dx * push * 0.5;
|
|
||||||
const py = dy * push * 0.5;
|
|
||||||
const pz = dz * push * 0.5;
|
|
||||||
this.x[i] -= px;
|
|
||||||
this.y[i] -= py;
|
|
||||||
this.z[i] -= pz;
|
|
||||||
this.x[j] += px;
|
|
||||||
this.y[j] += py;
|
|
||||||
this.z[j] += pz;
|
|
||||||
const nx = dx / d;
|
|
||||||
const ny = dy / d;
|
|
||||||
const nz = dz / d;
|
|
||||||
const rel =
|
|
||||||
(this.vx[j] - this.vx[i]) * nx +
|
|
||||||
(this.vy[j] - this.vy[i]) * ny +
|
|
||||||
(this.vz[j] - this.vz[i]) * nz;
|
|
||||||
if (rel < 0) {
|
|
||||||
const imp = rel * 0.5;
|
|
||||||
this.vx[i] += nx * imp;
|
|
||||||
this.vy[i] += ny * imp;
|
|
||||||
this.vz[i] += nz * imp;
|
|
||||||
this.vx[j] -= nx * imp;
|
|
||||||
this.vy[j] -= ny * imp;
|
|
||||||
this.vz[j] -= nz * imp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomUnit(rand: () => number): [number, number, number] {
|
|
||||||
const theta = rand() * Math.PI * 2;
|
|
||||||
const cosPhi = rand() * 2 - 1;
|
|
||||||
const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
|
|
||||||
return [Math.cos(theta) * sinPhi, Math.sin(theta) * sinPhi, cosPhi];
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@ type ButtonVariant =
|
|||||||
type ButtonSize = "sm" | "md" | "lg";
|
type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
const BASE_CLASSES =
|
const BASE_CLASSES =
|
||||||
"inline-flex w-fit items-center justify-center gap-2 rounded-none font-mono font-bold uppercase tracking-[0.15em] whitespace-nowrap transition-colors duration-150";
|
"inline-flex w-fit items-center justify-center gap-2 rounded-none font-mono font-bold uppercase tracking-[0.12em] whitespace-nowrap transition-colors duration-150";
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||||
primary: "bg-invert-bg text-invert-fg transition-opacity hover:opacity-80",
|
primary: "bg-invert-bg text-invert-fg transition-opacity hover:opacity-80",
|
||||||
@@ -43,7 +43,8 @@ const SIZE_CLASSES: Record<ButtonSize, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
extends
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel"> {
|
Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel"> {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
@@ -86,10 +87,17 @@ export function Button({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
const anchorProps = rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
|
const anchorProps =
|
||||||
|
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||||
if (external || /^(https?:\/\/|mailto:)/.test(href)) {
|
if (external || /^(https?:\/\/|mailto:)/.test(href)) {
|
||||||
return (
|
return (
|
||||||
<a href={href} target={target} rel={rel} className={classes} {...anchorProps}>
|
<a
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
rel={rel}
|
||||||
|
className={classes}
|
||||||
|
{...anchorProps}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,30 @@ export const FEATURES: Feature[] = [
|
|||||||
export const HERO_VIDEO_URL =
|
export const HERO_VIDEO_URL =
|
||||||
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/website-commercial-2026.05.mp4";
|
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/website-commercial-2026.05.mp4";
|
||||||
|
|
||||||
|
// Clips for the hero's render wall. `aspect` is width/height of the panel
|
||||||
|
// plane (the texture is cover-fitted, so any source aspect works).
|
||||||
|
// Served through the /ext-media same-origin proxy (see next.config.ts) —
|
||||||
|
// WebGL video textures require CORS-clean sources. PLACEHOLDERS: these
|
||||||
|
// currently reuse the feature footage — swap each path for an exported
|
||||||
|
// Seedance 2.5 clip when the renders are ready; the wall adapts to any
|
||||||
|
// count and mix of aspects.
|
||||||
|
export type SeedanceClip = {
|
||||||
|
src: string;
|
||||||
|
aspect: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEEDANCE_SHOWCASE: SeedanceClip[] = [
|
||||||
|
{ src: "/videos/1.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/2.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/3.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/4.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/5.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/6.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/7.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/8.mp4", aspect: 16 / 9 },
|
||||||
|
{ src: "/videos/9.mp4", aspect: 16 / 9 },
|
||||||
|
];
|
||||||
|
|
||||||
export const MADE_WITH_YOUTUBE_IDS = [
|
export const MADE_WITH_YOUTUBE_IDS = [
|
||||||
"HDdsKJl92H4",
|
"HDdsKJl92H4",
|
||||||
"oqoCWdOwr2U",
|
"oqoCWdOwr2U",
|
||||||
@@ -79,7 +103,7 @@ export const MADE_WITH_YOUTUBE_IDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const TICKER_ITEMS = [
|
export const TICKER_ITEMS = [
|
||||||
"Seedance 2.0",
|
"Seedance 2.5",
|
||||||
"Nano Banana 2",
|
"Nano Banana 2",
|
||||||
"Image to Location",
|
"Image to Location",
|
||||||
"3D Compositing",
|
"3D Compositing",
|
||||||
|
|||||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user