mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:22:29 +00:00
Webapp stylings (#1916)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, MinusIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -14,8 +14,8 @@ function Checkbox({
|
||||
data-slot="checkbox"
|
||||
checked={checked}
|
||||
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",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -30,7 +30,7 @@ function Checkbox({
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -59,7 +59,9 @@ export function UserSignups() {
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
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);
|
||||
} else {
|
||||
setError(resp.errorMessage || "Failed to load users");
|
||||
@@ -169,7 +171,9 @@ export function UserSignups() {
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
<IconRefresh
|
||||
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -188,7 +192,8 @@ export function UserSignups() {
|
||||
All Users
|
||||
{!isLoading && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||
({users.length}{nextCursor != null ? "+" : ""})
|
||||
({users.length}
|
||||
{nextCursor != null ? "+" : ""})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
@@ -234,15 +239,33 @@ export function UserSignups() {
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="size-4 rounded-[4px]" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-28" /></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>
|
||||
<TableCell>
|
||||
<Skeleton className="size-4 rounded-[3px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -305,12 +328,21 @@ export function UserSignups() {
|
||||
</Link>
|
||||
</TableCell>
|
||||
<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 className="text-sm">
|
||||
<span className="text-muted-foreground">{user.email_address}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{user.email_address}
|
||||
</span>
|
||||
{!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
|
||||
</Badge>
|
||||
)}
|
||||
@@ -330,40 +362,65 @@ export function UserSignups() {
|
||||
{user.maybe_source}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">—</span>
|
||||
<span className="text-muted-foreground/30">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{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}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">—</span>
|
||||
<span className="text-muted-foreground/30">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{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
|
||||
</Badge>
|
||||
)}
|
||||
{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
|
||||
</Badge>
|
||||
)}
|
||||
{!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
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Button 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">
|
||||
<Button
|
||||
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" />
|
||||
View Profile
|
||||
</a>
|
||||
@@ -380,7 +437,9 @@ export function UserSignups() {
|
||||
Loading more...
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">Scroll for more</span>
|
||||
<span className="text-muted-foreground/50">
|
||||
Scroll for more
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<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"
|
||||
/>
|
||||
</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() {
|
||||
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
|
||||
|
||||
className="animate-spin text-4xl text-primary/80" />
|
||||
|
||||
className="animate-spin text-4xl text-white/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function ProtectedContent() {
|
||||
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
|
||||
>
|
||||
{!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>
|
||||
<Outlet />
|
||||
</PageTransition>
|
||||
|
||||
@@ -60,15 +60,15 @@ export function GoogleLoginButton({
|
||||
className={`group relative w-full h-10 transition-transform duration-150 active:scale-95 ${className}`}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin text-lg text-gray-600" />
|
||||
<LoaderCircleIcon className="animate-spin text-lg text-black/60" />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const AuthLayout = () => {
|
||||
}
|
||||
|
||||
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
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
@@ -48,16 +48,7 @@ export const AuthLayout = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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]">
|
||||
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden border border-white/15 bg-[#101014] lg:min-h-[640px]">
|
||||
{/* Form pane — only the inner content swaps per route (via Outlet), so
|
||||
this pane and the showcase beside it stay mounted across the
|
||||
login/signup toggle. */}
|
||||
@@ -66,7 +57,7 @@ export const AuthLayout = () => {
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
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 />
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ const FEATURED_VIDEO_URL =
|
||||
export const AuthShowcase = () => {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-2 overflow-hidden bg-black rounded-2xl"
|
||||
className="absolute inset-2 overflow-hidden bg-black"
|
||||
style={{ containerType: "size" }}
|
||||
>
|
||||
{/* Vimeo background embed (no controls) scaled to cover the pane. The
|
||||
@@ -30,7 +30,7 @@ export const AuthShowcase = () => {
|
||||
|
||||
{/* Caption */}
|
||||
<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
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold leading-tight">
|
||||
|
||||
@@ -107,7 +107,7 @@ export const SignupForm = ({
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -123,7 +123,7 @@ export const SignupForm = ({
|
||||
className="space-y-4"
|
||||
>
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -132,13 +132,13 @@ export const SignupForm = ({
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
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 className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-semibold text-white/70">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
@@ -148,7 +148,7 @@ export const SignupForm = ({
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
@@ -163,12 +163,12 @@ export const SignupForm = ({
|
||||
|
||||
<Reveal className="pt-2">
|
||||
<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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
@@ -190,7 +190,7 @@ export const SignupForm = ({
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</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
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
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}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
@@ -60,16 +60,6 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Off-center ambient glow, feels designed, not generic */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
|
||||
/>
|
||||
|
||||
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-11 sm:pb-9">
|
||||
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||
Buy <span className="text-primary">credits</span>
|
||||
@@ -89,23 +79,21 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
|
||||
onClick={() => handlePurchase(pack)}
|
||||
disabled={purchasingId !== null}
|
||||
className={twMerge(
|
||||
"group relative flex flex-col gap-6 rounded-2xl border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"group relative flex flex-col gap-6 border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
|
||||
isPopular
|
||||
? "border-primary/50 bg-primary/[0.07] hover:border-primary"
|
||||
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]",
|
||||
? "border-white/60 bg-white/[0.06] hover:border-white"
|
||||
: "border-white/15 bg-white/[0.02] hover:border-white/30 hover:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{pack.badge && (
|
||||
<span className="absolute -top-2.5 right-3 rounded-full bg-primary px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-lg">
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<CoinsIcon
|
||||
|
||||
className="text-primary text-lg" />
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center bg-white/10">
|
||||
<CoinsIcon className="text-white text-lg" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
${pack.priceUsd}
|
||||
</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 ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Buy
|
||||
<ArrowRightIcon
|
||||
|
||||
className="text-xs transition-transform group-hover:translate-x-0.5" />
|
||||
<ArrowRightIcon className="text-xs transition-transform group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
+15
-22
@@ -80,10 +80,10 @@ export function CreateMediaPageShell({
|
||||
|
||||
if (!authChecked) {
|
||||
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
|
||||
|
||||
className="animate-spin text-4xl text-primary/80" />
|
||||
|
||||
className="animate-spin text-4xl text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function CreateMediaPageShell({
|
||||
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} />
|
||||
|
||||
{/* Flex (not absolute) keeps the toggle cluster from overlapping
|
||||
@@ -150,27 +150,20 @@ export function CreateMediaPageShell({
|
||||
}
|
||||
|
||||
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} />
|
||||
|
||||
{/* Decorative background, empty state only. Not rendered on mobile
|
||||
devices: the glow orbs use filter: blur(120px) and the truchet uses
|
||||
mask-image, both of which are full-screen GPU layers that iOS Safari
|
||||
must re-rasterize when an overlay (mobile menu, modal) composites over
|
||||
them, causing multi-second hangs. (This is distinct from
|
||||
backdrop-filter.) We gate on the actual device (react-device-detect's
|
||||
user-agent check) rather than a viewport breakpoint so the layers
|
||||
never mount on a real phone/tablet, and a narrow desktop window still
|
||||
gets the decoration. */}
|
||||
devices: the truchet uses mask-image, a full-screen GPU layer that
|
||||
iOS Safari must re-rasterize when an overlay (mobile menu, modal)
|
||||
composites over it, causing multi-second hangs. (This is distinct
|
||||
from backdrop-filter.) We gate on the actual device
|
||||
(react-device-detect's user-agent check) rather than a viewport
|
||||
breakpoint so the layers never mount on a real phone/tablet, and a
|
||||
narrow desktop window still gets the decoration. */}
|
||||
{!hasContent && !isMobile && (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
{glowOrbs}
|
||||
<div
|
||||
aria-hidden
|
||||
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">
|
||||
{!hasContent && (
|
||||
<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">
|
||||
{emptyStateTitle}
|
||||
</h1>
|
||||
@@ -219,7 +212,7 @@ export function CreateMediaPageShell({
|
||||
{hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
|
||||
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)" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ export function GallerySelectionDownloadBar({
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
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
|
||||
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 { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
@@ -85,7 +92,7 @@ function CardRecreateButton({
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
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
|
||||
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
|
||||
@@ -103,7 +110,7 @@ function CardRecreateButton({
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
|
||||
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
|
||||
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
|
||||
@@ -136,7 +143,7 @@ function CardActions({
|
||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||
|
||||
const buttonClass =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
|
||||
"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 (
|
||||
<>
|
||||
@@ -164,7 +171,7 @@ function CardActions({
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
>
|
||||
<VideoIcon className="text-sm" />
|
||||
<VideoIcon className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
+3
-3
@@ -103,8 +103,8 @@ function RowRecreateButton({
|
||||
aria-label="Recreate"
|
||||
className={
|
||||
kind === "pending"
|
||||
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
? "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 text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
}
|
||||
>
|
||||
<DynamicIcon
|
||||
@@ -152,7 +152,7 @@ function GalleryRowActions({
|
||||
} = actions;
|
||||
|
||||
const buttonClass =
|
||||
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
"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 (
|
||||
<>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function InsufficientCreditsModal() {
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
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}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
@@ -137,16 +137,6 @@ export function InsufficientCreditsModal() {
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<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">
|
||||
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||
{copy.heading}
|
||||
@@ -161,10 +151,8 @@ export function InsufficientCreditsModal() {
|
||||
key={perk}
|
||||
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">
|
||||
<CheckIcon
|
||||
|
||||
className="text-[9px] text-primary" />
|
||||
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
|
||||
<CheckIcon className="text-[9px] text-white" />
|
||||
</span>
|
||||
<span>{perk}</span>
|
||||
</li>
|
||||
@@ -176,10 +164,7 @@ export function InsufficientCreditsModal() {
|
||||
<>
|
||||
{!isOnBiggestPlan && (
|
||||
<Link to="/pricing" onClick={close} className="block">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
||||
>
|
||||
<Button variant="primary" className="w-full h-10">
|
||||
Upgrade plan
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -187,17 +172,14 @@ export function InsufficientCreditsModal() {
|
||||
<Button
|
||||
variant={isOnBiggestPlan ? "primary" : "secondary"}
|
||||
onClick={handleBuyCredits}
|
||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
||||
className="w-full h-10"
|
||||
>
|
||||
Buy more credits
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/pricing" onClick={close} className="block">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
||||
>
|
||||
<Button variant="primary" className="w-full h-10">
|
||||
Upgrade plan
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -4,12 +4,26 @@ import { Button } from "@storyteller/ui-button";
|
||||
import { toast } from "../toast/toast";
|
||||
import { Gravatar } from "@storyteller/ui-gravatar";
|
||||
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 {
|
||||
getContextImageThumbnail,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
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 { getContextImageThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
@@ -29,16 +43,12 @@ import {
|
||||
|
||||
// ── InfoRow (private to this file; other consumers do not need it) ─────────
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
|
||||
<span className="text-sm text-white/60 font-medium">{label}</span>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 last:border-0">
|
||||
<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">
|
||||
{value}
|
||||
</span>
|
||||
@@ -84,7 +94,9 @@ export function LightboxDetails({
|
||||
const promptCopy = useCopyFeedback();
|
||||
const shareCopy = useCopyFeedback();
|
||||
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 stopAudio = useCallback(() => {
|
||||
@@ -174,14 +186,14 @@ export function LightboxDetails({
|
||||
!!createdAt;
|
||||
|
||||
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 && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -189,20 +201,20 @@ export function LightboxDetails({
|
||||
{promptData.loading ? (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 bg-white/10 rounded" />
|
||||
<div className="h-20 w-full bg-white/10 rounded-lg" />
|
||||
<div className="h-4 w-20 bg-white/10" />
|
||||
<div className="h-20 w-full bg-white/10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-32 bg-white/10 rounded" />
|
||||
<div className="h-10 w-full bg-white/10 rounded-lg" />
|
||||
<div className="h-10 w-full bg-white/10 rounded-lg" />
|
||||
<div className="h-4 w-32 bg-white/10" />
|
||||
<div className="h-10 w-full bg-white/10" />
|
||||
<div className="h-10 w-full bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{creator && (
|
||||
<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 ? (
|
||||
<Gravatar
|
||||
@@ -213,10 +225,10 @@ export function LightboxDetails({
|
||||
backgroundIndex={
|
||||
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 />
|
||||
</div>
|
||||
)}
|
||||
@@ -224,7 +236,7 @@ export function LightboxDetails({
|
||||
<span className="text-white text-sm font-semibold leading-none">
|
||||
{creator.display_name}
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
@@ -234,7 +246,7 @@ export function LightboxDetails({
|
||||
{promptData.hasToken && (
|
||||
<div className="space-y-2">
|
||||
<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 />
|
||||
<span>Prompt</span>
|
||||
</div>
|
||||
@@ -252,7 +264,7 @@ export function LightboxDetails({
|
||||
)}
|
||||
</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
|
||||
ref={promptRef}
|
||||
className={!isPromptExpanded ? "line-clamp-4" : ""}
|
||||
@@ -274,73 +286,78 @@ export function LightboxDetails({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{promptData.contextImages && promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
|
||||
<ImageIcon />
|
||||
<span>Reference Media</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map((contextImage, index) => {
|
||||
const isAudio = contextImage.semantic === "audioref";
|
||||
const isVideoRef = contextImage.semantic === "vid_ref";
|
||||
const { thumbnail } = getContextImageThumbnail(
|
||||
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>
|
||||
{promptData.contextImages &&
|
||||
promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||
<ImageIcon />
|
||||
<span>Reference Media</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map((contextImage, index) => {
|
||||
const isAudio = contextImage.semantic === "audioref";
|
||||
const isVideoRef = contextImage.semantic === "vid_ref";
|
||||
const { thumbnail } = getContextImageThumbnail(
|
||||
contextImage,
|
||||
{ size: THUMBNAIL_SIZES.SMALL },
|
||||
);
|
||||
}
|
||||
const isPlayingThis =
|
||||
playingAudioToken === contextImage.media_token;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={contextImage.media_token}
|
||||
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 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>
|
||||
);
|
||||
})}
|
||||
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 border border-white/15 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={contextImage.media_token}
|
||||
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>
|
||||
)}
|
||||
)}
|
||||
|
||||
{hasInfoSection && (
|
||||
<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 />
|
||||
<span>Information</span>
|
||||
</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 && (
|
||||
<InfoRow
|
||||
label="Model"
|
||||
@@ -421,7 +438,7 @@ export function LightboxDetails({
|
||||
)}
|
||||
</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">
|
||||
<Button
|
||||
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);
|
||||
}
|
||||
}}
|
||||
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
|
||||
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
|
||||
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
|
||||
@@ -462,7 +479,7 @@ export function LightboxDetails({
|
||||
{onMakeVideo && (
|
||||
<Button
|
||||
icon={VideoIcon}
|
||||
className="w-full shadow-lg shadow-brand-primary/20"
|
||||
className="w-full"
|
||||
variant="primary"
|
||||
onClick={onMakeVideo}
|
||||
>
|
||||
@@ -512,7 +529,7 @@ export function LightboxDetails({
|
||||
{showDownloadAppCta && (
|
||||
<Button
|
||||
icon={ArrowDownToLineIcon}
|
||||
className="w-full shadow-lg shadow-brand-primary/20"
|
||||
className="w-full"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
window.location.href = "/download";
|
||||
|
||||
@@ -357,13 +357,13 @@ export function Lightbox({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
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"
|
||||
showClose={false}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row h-full">
|
||||
{/* 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 ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-base-fg/60">Media not available</span>
|
||||
@@ -419,8 +419,8 @@ export function Lightbox({
|
||||
</video>
|
||||
) : isAudio ? (
|
||||
<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="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="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 border border-white/15 bg-white/10">
|
||||
<MusicIcon
|
||||
|
||||
className="text-2xl text-white/70" />
|
||||
@@ -485,9 +485,9 @@ export function Lightbox({
|
||||
key={`${url}-thumb-${idx}`}
|
||||
type="button"
|
||||
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
|
||||
? "border-primary-400 opacity-100"
|
||||
? "border-white opacity-100"
|
||||
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
@@ -518,7 +518,7 @@ export function Lightbox({
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
<ChevronLeftIcon className="text-lg" />
|
||||
@@ -530,7 +530,7 @@ export function Lightbox({
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
<ChevronRightIcon className="text-lg" />
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
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 {
|
||||
CharactersApi,
|
||||
Character,
|
||||
MediaUploadApi,
|
||||
} from "@storyteller/api";
|
||||
ArrowLeftIcon,
|
||||
EyeIcon,
|
||||
LoaderCircleIcon,
|
||||
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 { v4 as uuidv4 } from "uuid";
|
||||
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 { SettingsDrawer } from "./mobile/SettingsDrawer";
|
||||
|
||||
@@ -324,7 +333,7 @@ const CharacterListView = ({
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
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
|
||||
@@ -336,7 +345,7 @@ const CharacterListView = ({
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
|
||||
<div
|
||||
className="h-3 w-2/3 rounded bg-white/[0.08]"
|
||||
className="h-3 w-2/3 bg-white/[0.08]"
|
||||
style={{
|
||||
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
|
||||
}}
|
||||
@@ -356,10 +365,10 @@ const CharacterListView = ({
|
||||
{/* Create New card */}
|
||||
<button
|
||||
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">
|
||||
<PlusIcon className="text-lg" />
|
||||
<PlusIcon className="text-lg" />
|
||||
<span className="text-sm font-medium">Create New</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -368,7 +377,7 @@ const CharacterListView = ({
|
||||
{pendingCharacters.map((pending) => (
|
||||
<div
|
||||
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]">
|
||||
{pending.previewUrl ? (
|
||||
@@ -379,13 +388,11 @@ const CharacterListView = ({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/20">
|
||||
<UsersIcon className="text-2xl" />
|
||||
<UsersIcon className="text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="text-lg text-white/80 animate-spin" />
|
||||
<LoaderCircleIcon className="text-lg text-white/80 animate-spin" />
|
||||
<span className="text-xs font-medium text-white/80">
|
||||
Creating...
|
||||
</span>
|
||||
@@ -405,7 +412,7 @@ const CharacterListView = ({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
onClick={() => onSelectCharacter?.(character)}
|
||||
@@ -421,9 +428,7 @@ const CharacterListView = ({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/20">
|
||||
<UsersIcon
|
||||
|
||||
className="text-2xl" />
|
||||
<UsersIcon className="text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -445,9 +450,9 @@ const CharacterListView = ({
|
||||
setPreviewCharacter(character);
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
{isUserCreated && (
|
||||
@@ -457,22 +462,18 @@ const CharacterListView = ({
|
||||
e.stopPropagation();
|
||||
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
|
||||
|
||||
className="text-[10px]" />
|
||||
<PenIcon className="text-[10px]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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
|
||||
|
||||
className="text-[10px]" />
|
||||
<Trash2Icon className="text-[10px]" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -486,28 +487,25 @@ const CharacterListView = ({
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm rounded-xl border border-white/10 bg-[#1e1e22] p-6 shadow-2xl">
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60">
|
||||
<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">
|
||||
Delete character?
|
||||
</h3>
|
||||
<p className="mb-5 text-sm text-white/70">
|
||||
This will permanently delete{" "}
|
||||
<strong>{confirmDelete.name}</strong>. This action cannot be
|
||||
undone.
|
||||
This will permanently delete <strong>{confirmDelete.name}</strong>
|
||||
. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="border-none"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(confirmDelete)}
|
||||
loading={isDeleting}
|
||||
disabled={isDeleting}
|
||||
@@ -608,7 +606,7 @@ const EditCharacterView = ({
|
||||
|
||||
{/* Avatar preview */}
|
||||
{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
|
||||
src={character.maybe_avatar.cdn_url}
|
||||
alt={character.name}
|
||||
@@ -619,7 +617,10 @@ const EditCharacterView = ({
|
||||
|
||||
{/* Name input */}
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -628,14 +629,18 @@ const EditCharacterView = ({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Character name"
|
||||
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>
|
||||
|
||||
{/* Description input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="edit-character-description" className="text-sm font-medium text-white/80">
|
||||
Description <span className="font-normal text-white/40">(optional)</span>
|
||||
<label
|
||||
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>
|
||||
<textarea
|
||||
id="edit-character-description"
|
||||
@@ -644,14 +649,14 @@ const EditCharacterView = ({
|
||||
placeholder="Description..."
|
||||
rows={3}
|
||||
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>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -859,8 +864,8 @@ const NewCharacterView = ({
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
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",
|
||||
isDragging && "border-blue-400 bg-blue-500/10",
|
||||
"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-white/60 bg-white/10",
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -880,9 +885,7 @@ const NewCharacterView = ({
|
||||
/>
|
||||
{!images[0]!.mediaToken && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="text-white animate-spin" />
|
||||
<LoaderCircleIcon className="text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -890,16 +893,14 @@ const NewCharacterView = ({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
|
||||
<UploadIcon
|
||||
|
||||
className="mb-2 text-xl text-white/40" />
|
||||
<UploadIcon className="mb-2 text-xl text-white/40" />
|
||||
<p className="text-sm">Upload reference image</p>
|
||||
<p className="mb-3 text-xs text-white/40">
|
||||
Click or drag an image here
|
||||
@@ -918,7 +919,10 @@ const NewCharacterView = ({
|
||||
|
||||
{/* Name input */}
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -927,14 +931,18 @@ const NewCharacterView = ({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Character name"
|
||||
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>
|
||||
|
||||
{/* Description input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="character-description" className="text-sm font-medium text-white/80">
|
||||
Description <span className="font-normal text-white/40">(optional)</span>
|
||||
<label
|
||||
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>
|
||||
<textarea
|
||||
id="character-description"
|
||||
@@ -943,14 +951,14 @@ const NewCharacterView = ({
|
||||
placeholder="Description..."
|
||||
rows={3}
|
||||
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>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" className="border-none" onClick={onBack}>
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
type CSSProperties,
|
||||
type SyntheticEvent,
|
||||
} 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 { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
@@ -234,10 +240,7 @@ export const ImagePromptRow = ({
|
||||
)}
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={twMerge(
|
||||
"glass flex flex-col sm:flex-row rounded-2xl sm:rounded-t-2xl sm:rounded-b-none",
|
||||
className,
|
||||
)}
|
||||
className={twMerge("glass flex flex-col sm:flex-row", className)}
|
||||
onMouseDown={stopIfInside}
|
||||
onClick={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 grow flex-col gap-1 min-w-32">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
{sectionLabel}
|
||||
{showCount && (
|
||||
<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="flex grow flex-col gap-1 sm:p-2">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
End Frame{" "}
|
||||
<span className="text-xs text-white/60">(optional)</span>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
End Frame <span className="text-white/60">(optional)</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[13px] text-white/60">
|
||||
@@ -336,7 +338,7 @@ export const ImagePromptRow = ({
|
||||
<div className="flex items-center gap-2">
|
||||
{endFrameImage ? (
|
||||
<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)}
|
||||
>
|
||||
<img
|
||||
@@ -349,17 +351,14 @@ export const ImagePromptRow = ({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
) : 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">
|
||||
<LoaderCircleIcon
|
||||
|
||||
spin
|
||||
className="h-5 w-5 text-white" />
|
||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
|
||||
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<AddButton
|
||||
@@ -385,7 +384,7 @@ export const ImagePromptRow = ({
|
||||
<img
|
||||
src={previewImage.fullUrl || previewImage.url}
|
||||
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>
|
||||
)}
|
||||
@@ -397,7 +396,7 @@ export const ImagePromptRow = ({
|
||||
// ── Sub-components ───────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
// mobile that tooltip auto-opens from the emulated mouseenter fired on
|
||||
@@ -414,9 +413,7 @@ export const AddButton = ({
|
||||
const isMobile = useIsMobile();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const plus = (
|
||||
<PlusIcon className="text-2xl text-white/80" />
|
||||
);
|
||||
const plus = <PlusIcon className="text-2xl text-white/80" />;
|
||||
|
||||
if (!onPickFromLibrary) {
|
||||
return (
|
||||
@@ -460,7 +457,7 @@ export const AddButton = ({
|
||||
<Button
|
||||
variant="action"
|
||||
icon={ImagesIcon}
|
||||
className="w-full bg-white/15 hover:bg-white/20"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setDrawerOpen(false);
|
||||
onPickFromLibrary();
|
||||
@@ -495,7 +492,7 @@ export const AddButton = ({
|
||||
variant="action"
|
||||
onClick={onPickFromLibrary}
|
||||
icon={ImagesIcon}
|
||||
className="w-full bg-white/15 hover:bg-white/20"
|
||||
className="w-full"
|
||||
>
|
||||
Pick from library
|
||||
</Button>
|
||||
@@ -519,7 +516,7 @@ const ImageThumbnail = ({
|
||||
onPreview?: (image: RefImage) => void;
|
||||
}) => (
|
||||
<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)}
|
||||
>
|
||||
<img
|
||||
@@ -532,9 +529,9 @@ const ImageThumbnail = ({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -572,11 +569,11 @@ const SortableImage = ({
|
||||
{...(allowReorder ? { ...attributes, ...listeners } : {})}
|
||||
onClick={() => onPreview?.(image)}
|
||||
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
|
||||
? "cursor-move hover:border-white/80"
|
||||
: "cursor-pointer hover:border-white/80",
|
||||
isDragging && "opacity-50 shadow-lg",
|
||||
? "cursor-move hover:border-white/40"
|
||||
: "cursor-pointer hover:border-white/40",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
@@ -591,9 +588,9 @@ const SortableImage = ({
|
||||
}}
|
||||
onMouseDown={(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>
|
||||
</div>
|
||||
);
|
||||
@@ -603,7 +600,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
|
||||
const previewUrl = useMemo(() => URL.createObjectURL(file), [file]);
|
||||
useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]);
|
||||
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">
|
||||
<img
|
||||
src={previewUrl}
|
||||
@@ -612,10 +609,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<LoaderCircleIcon
|
||||
|
||||
spin
|
||||
className="h-6 w-6 text-white" />
|
||||
<LoaderCircleIcon spin className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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 { twMerge } from "tailwind-merge";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
@@ -214,10 +221,7 @@ export const MediaReferenceRow = ({
|
||||
onChange={handleAudioUpload}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass flex flex-col sm:flex-row rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
className={twMerge("glass flex flex-col sm:flex-row", className)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(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 flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<VideoIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<VideoIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
Video Ref
|
||||
<span className="font-semibold text-white/60">
|
||||
({referenceVideos.length}/{maxVideoCount})
|
||||
@@ -248,11 +252,8 @@ export const MediaReferenceRow = ({
|
||||
/>
|
||||
))}
|
||||
{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">
|
||||
<LoaderCircleIcon
|
||||
|
||||
spin
|
||||
className="h-5 w-5 text-white/60" />
|
||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
|
||||
<LoaderCircleIcon spin className="h-5 w-5 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
{canAddVideo && (
|
||||
@@ -275,8 +276,8 @@ export const MediaReferenceRow = ({
|
||||
<div className="flex grow gap-2 px-3 py-2">
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<MusicIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<MusicIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
Audio Ref
|
||||
<span className="font-semibold text-white/60">
|
||||
({referenceAudios.length}/{maxAudioCount})
|
||||
@@ -297,11 +298,8 @@ export const MediaReferenceRow = ({
|
||||
/>
|
||||
))}
|
||||
{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">
|
||||
<LoaderCircleIcon
|
||||
|
||||
spin
|
||||
className="h-5 w-5 text-white/60" />
|
||||
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
|
||||
<LoaderCircleIcon spin className="h-5 w-5 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
{canAddAudio && (
|
||||
@@ -328,7 +326,7 @@ const VideoRefTile = ({
|
||||
video: RefVideo;
|
||||
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
|
||||
src={video.url}
|
||||
muted
|
||||
@@ -343,9 +341,9 @@ const VideoRefTile = ({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -382,7 +380,7 @@ const AudioRefTile = ({
|
||||
);
|
||||
|
||||
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
|
||||
onClick={handleTogglePlay}
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
@@ -403,9 +401,9 @@ const AudioRefTile = ({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
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 { GenerateIconButton } from "@storyteller/ui-button";
|
||||
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
|
||||
// reference mode where the user can see (and remove) them.
|
||||
const isKeyframeMode = !!isVideo && !isReferenceMode;
|
||||
const dropAcceptsImages =
|
||||
!!supportsImagePrompts && maxImagePromptCount > 0;
|
||||
const dropAcceptsImages = !!supportsImagePrompts && maxImagePromptCount > 0;
|
||||
const dropAcceptsVideos =
|
||||
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
|
||||
const dropAcceptsAudio =
|
||||
@@ -344,8 +349,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
const deckAddActions: DeckAddAction[] = [];
|
||||
if (
|
||||
supportsImagePrompts &&
|
||||
referenceImages.length + deck.uploadingImages.length <
|
||||
maxImagePromptCount
|
||||
referenceImages.length + deck.uploadingImages.length < maxImagePromptCount
|
||||
) {
|
||||
deckAddActions.push({
|
||||
key: "upload-image",
|
||||
@@ -769,8 +773,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass rounded-2xl p-3 sm:p-4 !transition-all duration-200",
|
||||
isFocused && "ring-1 ring-primary",
|
||||
"glass p-3 sm:p-4 !transition-all duration-200",
|
||||
isFocused && "border-primary",
|
||||
)}
|
||||
{...drop.dropZoneProps}
|
||||
>
|
||||
@@ -849,8 +853,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
/>
|
||||
|
||||
{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="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
|
||||
<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 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
|
||||
Mentions
|
||||
</div>
|
||||
{filteredMentionItems.map((item, i) => (
|
||||
@@ -868,7 +872,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
}}
|
||||
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 === "character") &&
|
||||
item.preview ? (
|
||||
@@ -885,13 +889,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : item.type === "character" ? (
|
||||
<UsersIcon
|
||||
|
||||
className="h-3.5 w-3.5 text-white/60" />
|
||||
<UsersIcon className="h-3.5 w-3.5 text-white/60" />
|
||||
) : (
|
||||
<DynamicIcon
|
||||
icon={
|
||||
item.type === "video" ? VideoIcon : MusicIcon
|
||||
item.type === "video"
|
||||
? VideoIcon
|
||||
: MusicIcon
|
||||
}
|
||||
className="h-3.5 w-3.5 text-white/60"
|
||||
/>
|
||||
|
||||
@@ -245,15 +245,15 @@ export function PromptBoxDropOverlay({
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
rejected ? "border-red-400/80" : "border-primary",
|
||||
"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-white/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{kinds.map((kind, i) => (
|
||||
<div
|
||||
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` }}
|
||||
>
|
||||
<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
|
||||
// (@storyteller/ui-promptbox) fullscreen feature but lives here because the
|
||||
// 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
|
||||
// continuous across open/close.
|
||||
|
||||
@@ -32,7 +32,7 @@ export const PromptFullscreenButton = ({
|
||||
type="button"
|
||||
aria-label="Expand prompt to focus mode"
|
||||
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
|
||||
|
||||
@@ -96,7 +96,6 @@ export const PromptFullscreenModal = ({
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
className="w-full max-w-4xl"
|
||||
backdropClassName="backdrop-blur-md"
|
||||
>
|
||||
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
|
||||
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}
|
||||
onClick={() => !item.disabled && onSelect(item)}
|
||||
className={twMerge(
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors",
|
||||
item.selected ? "bg-primary/15" : "hover:bg-white/5",
|
||||
"flex w-full items-center gap-3 px-3 py-3 text-left transition-colors",
|
||||
item.selected ? "bg-white/10" : "hover:bg-white/5",
|
||||
item.disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -42,8 +42,8 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
|
||||
{item.trailing}
|
||||
{item.selected && (
|
||||
<CheckIcon
|
||||
|
||||
className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
|
||||
className="h-3.5 w-3.5 shrink-0 text-white" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface DrawerSectionProps {
|
||||
export function DrawerSection({ label, children }: DrawerSectionProps) {
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
{children}
|
||||
|
||||
+5
-5
@@ -33,14 +33,14 @@ export function MobileCountStepper({
|
||||
};
|
||||
|
||||
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
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
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>
|
||||
<span className="min-w-10 text-center text-sm font-semibold tabular-nums text-base-fg">
|
||||
{value}/{maxValue}
|
||||
@@ -49,9 +49,9 @@ export function MobileCountStepper({
|
||||
type="button"
|
||||
onClick={() => step(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>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function MobileFieldButton({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function MobilePromptForm({
|
||||
{frames}
|
||||
{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
|
||||
value={prompt}
|
||||
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">
|
||||
{countField}
|
||||
<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}
|
||||
disabled={!prompt.trim() || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
|
||||
@@ -64,13 +64,13 @@ export function SettingsDrawer({
|
||||
aria-describedby={undefined}
|
||||
modal={modal}
|
||||
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
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
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" />
|
||||
</button>
|
||||
@@ -104,7 +104,7 @@ export function SettingsDrawer({
|
||||
onPointerUp={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"}>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -156,7 +156,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
||||
maxLength={USERNAME_MAX}
|
||||
autoComplete="username"
|
||||
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
|
||||
type="password"
|
||||
@@ -164,7 +164,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Confirm 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} />}
|
||||
<FormActions
|
||||
@@ -262,7 +262,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
||||
maxLength={EMAIL_MAX}
|
||||
autoComplete="email"
|
||||
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
|
||||
type="password"
|
||||
@@ -270,7 +270,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Confirm 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} />}
|
||||
<FormActions
|
||||
@@ -388,7 +388,7 @@ function PasswordForm({
|
||||
placeholder="Current password"
|
||||
autoComplete="current-password"
|
||||
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">
|
||||
@@ -400,7 +400,7 @@ function PasswordForm({
|
||||
minLength={PASSWORD_MIN}
|
||||
autoComplete="new-password"
|
||||
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
|
||||
type="password"
|
||||
@@ -411,7 +411,7 @@ function PasswordForm({
|
||||
}
|
||||
minLength={PASSWORD_MIN}
|
||||
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>
|
||||
{error && <FormError message={error} />}
|
||||
@@ -450,7 +450,9 @@ function SectionShell({
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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">
|
||||
{isEditing ? editingDescription : collapsedDescription}
|
||||
</p>
|
||||
@@ -480,7 +482,7 @@ function SectionShell({
|
||||
function CurrentValue({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white truncate">{value}</span>
|
||||
@@ -514,11 +516,7 @@ function FormActions({
|
||||
className="h-9 px-4"
|
||||
disabled={submitting || disabled}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
{submitting ? <LoaderCircleIcon className="animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useEffect, useState } from "react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
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 {
|
||||
UserApiKeysApi,
|
||||
@@ -17,7 +23,7 @@ const DESCRIPTION_MAX = 512;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
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 {
|
||||
user: UserInfo;
|
||||
@@ -97,7 +103,9 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<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">
|
||||
Create and manage keys to access the API programmatically.
|
||||
</p>
|
||||
@@ -131,7 +139,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
|
||||
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-xs opacity-60">
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs opacity-60">
|
||||
@@ -169,7 +177,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
|
||||
onClick={() => load(false)}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Load more"
|
||||
)}
|
||||
@@ -279,16 +287,16 @@ function NewKeyReveal({
|
||||
};
|
||||
|
||||
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">
|
||||
<KeyIcon className="opacity-80" />
|
||||
<KeyIcon className="opacity-80" />
|
||||
<span>API key created</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-80">
|
||||
Copy your key now. For security, you won't be able to see it again.
|
||||
</p>
|
||||
<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}
|
||||
</code>
|
||||
<Button
|
||||
@@ -334,7 +342,7 @@ function ApiKeyRow({
|
||||
onRequestDelete: () => void;
|
||||
}) {
|
||||
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 min-w-0 flex-col gap-0.5">
|
||||
<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">
|
||||
{/* Truncated prefix, display-only — the full secret is only shown on
|
||||
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}…
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -497,13 +505,13 @@ function DeleteConfirmModal({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-9 px-4 bg-red-500 hover:bg-red-600"
|
||||
variant="destructive"
|
||||
className="h-9 px-4"
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
@@ -543,7 +551,7 @@ function FormActions({
|
||||
disabled={submitting || disabled}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
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="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">
|
||||
<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>
|
||||
<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">
|
||||
@@ -53,8 +55,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={twMerge(
|
||||
"h-9 shrink-0 rounded-lg px-3 text-left transition-colors sm:w-full sm:px-2",
|
||||
tab === t.id ? "bg-[#63636B]/20" : "hover:bg-white/[0.04]",
|
||||
"h-9 shrink-0 px-3 text-left transition-colors sm:w-full sm:px-2",
|
||||
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">
|
||||
@@ -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="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 className="h-full p-4 text-sm sm:p-3 sm:ps-0">
|
||||
{tab === "general" && <GeneralPanel />}
|
||||
@@ -94,7 +100,9 @@ function GeneralPanel() {
|
||||
<div className="space-y-4 text-base-fg">
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
<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">
|
||||
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
|
||||
@@ -109,7 +117,9 @@ function GeneralPanel() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
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.
|
||||
@@ -123,7 +133,9 @@ function GeneralPanel() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
When on (default), the model picker groups models into submenus by
|
||||
family, like Seedance or Veo. When off, every model shows in one
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
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 { DynamicIcon, DiscordIcon } from "@storyteller/icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
@@ -33,7 +50,9 @@ type NavItem = {
|
||||
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.
|
||||
const CREATE_ITEMS_STATIC: NavItem[] = [
|
||||
@@ -150,13 +169,15 @@ function NavMenuItem({
|
||||
interactive, kept subtle to stay within the "restrained chrome" lane. */}
|
||||
<DynamicIcon
|
||||
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>
|
||||
{item.badge && (
|
||||
<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 ${
|
||||
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
|
||||
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"
|
||||
? "border-purple-400/40 text-purple-300"
|
||||
: "border-amber-400/40 text-amber-300"
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
@@ -173,7 +194,7 @@ function NavMenuItem({
|
||||
{active && (
|
||||
<motion.span
|
||||
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={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
@@ -259,7 +280,7 @@ export function AppSidebar() {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src="/images/artcraft-logo.png"
|
||||
src="/images/artcraft-logo-2.png"
|
||||
alt="ArtCraft"
|
||||
className="h-5 w-auto shrink-0"
|
||||
/>
|
||||
@@ -318,7 +339,7 @@ export function AppSidebar() {
|
||||
onClick={() =>
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function LibraryFoldersNav({
|
||||
type="button"
|
||||
onClick={() => openNewFolderModal(null)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -125,7 +125,7 @@ export function LibraryFoldersNav({
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
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
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function LibraryTagsNav({
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
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
|
||||
|
||||
@@ -103,7 +103,7 @@ export function LibraryTagsNav({
|
||||
className="pl-5"
|
||||
>
|
||||
<TagIcon
|
||||
|
||||
|
||||
className="text-violet-400" />
|
||||
<span className="truncate">{tag.value}</span>
|
||||
<span className="ml-auto text-[10px] text-sidebar-foreground/40 transition-opacity group-hover/menu-item:opacity-0">
|
||||
@@ -123,7 +123,7 @@ export function LibraryTagsNav({
|
||||
});
|
||||
}}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -32,10 +32,8 @@ export function MobileBottomNav() {
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={twMerge(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors active:scale-95",
|
||||
active
|
||||
? "text-primary"
|
||||
: "text-base-fg/55 hover:text-base-fg/80",
|
||||
"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 ? "text-white" : "text-base-fg/55 hover:text-base-fg/80",
|
||||
)}
|
||||
>
|
||||
<DynamicIcon icon={item.icon} className="h-5 w-5" />
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SignupCtaModal() {
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
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}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
@@ -81,16 +81,6 @@ export function SignupCtaModal() {
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<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">
|
||||
<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.
|
||||
@@ -105,10 +95,8 @@ export function SignupCtaModal() {
|
||||
key={perk}
|
||||
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">
|
||||
<CheckIcon
|
||||
|
||||
className="text-[9px] text-primary" />
|
||||
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
|
||||
<CheckIcon className="text-[9px] text-white" />
|
||||
</span>
|
||||
<span>{perk}</span>
|
||||
</li>
|
||||
@@ -117,16 +105,13 @@ export function SignupCtaModal() {
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to={`/signup?from=${from}`} onClick={close} className="block">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full h-10 text-sm font-semibold rounded-full"
|
||||
>
|
||||
<Button variant="primary" className="w-full h-10">
|
||||
Create account with email
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -135,7 +120,7 @@ export function SignupCtaModal() {
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -88,10 +88,10 @@ export const ToastContainer = () => {
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
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
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
@@ -107,7 +107,7 @@ export const ToastContainer = () => {
|
||||
onClick={() => dismiss(toast.id)}
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ActiveSceneTitle() {
|
||||
onClick={startEdit}
|
||||
title="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>
|
||||
<PencilIcon
|
||||
@@ -129,7 +129,7 @@ export function ActiveSceneTitle() {
|
||||
className="ml-2 shrink-0 text-sm opacity-50" />
|
||||
</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}
|
||||
</div>
|
||||
)}
|
||||
@@ -157,8 +157,8 @@ export function ActiveSceneTitle() {
|
||||
disabled={isSubmitting}
|
||||
maxLength={120}
|
||||
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",
|
||||
isSubmitting && "outline outline-2 outline-brand-secondary",
|
||||
"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-1 outline-white/30",
|
||||
!isValid && "border-danger focus:outline-danger focus:outline-2",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
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 { Button } from "@storyteller/ui-button";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
@@ -99,9 +106,9 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
<>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
@@ -118,7 +125,7 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
variant="primary"
|
||||
icon={GemIcon}
|
||||
onClick={() => navigate("/pricing")}
|
||||
className="h-8 px-3 text-sm font-semibold rounded-lg"
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
@@ -128,14 +135,14 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
@@ -154,9 +161,9 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
>
|
||||
<MenuItems
|
||||
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">
|
||||
{user.display_name || user.username}
|
||||
</p>
|
||||
@@ -166,13 +173,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<SettingsIcon
|
||||
|
||||
className="text-[11px] text-white/50" />
|
||||
<SettingsIcon className="text-[11px] text-white/50" />
|
||||
Settings
|
||||
</button>
|
||||
)}
|
||||
@@ -182,13 +187,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
<button
|
||||
onClick={() => navigate("/support")}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<LifeBuoyIcon
|
||||
|
||||
className="text-[11px] text-white/50" />
|
||||
<LifeBuoyIcon className="text-[11px] text-white/50" />
|
||||
Support
|
||||
</button>
|
||||
)}
|
||||
@@ -201,13 +204,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
<button
|
||||
onClick={() => navigate("/referrals")}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<GiftIcon
|
||||
|
||||
className="text-[11px] text-white/50" />
|
||||
<GiftIcon className="text-[11px] text-white/50" />
|
||||
Referrals
|
||||
</button>
|
||||
)}
|
||||
@@ -220,13 +221,11 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<DiscordIcon
|
||||
|
||||
className="text-[11px] text-white/50" />
|
||||
<DiscordIcon className="text-[11px] text-white/50" />
|
||||
Join Discord
|
||||
</a>
|
||||
)}
|
||||
@@ -252,20 +251,20 @@ export function TopBarActions({ className }: { className?: string }) {
|
||||
<>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
@@ -297,16 +296,14 @@ function CreditsChip({
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="end"
|
||||
triggerIcon={
|
||||
<CoinsIcon className="text-primary text-[11px]" />
|
||||
}
|
||||
triggerIcon={<CoinsIcon className="text-primary text-[11px]" />}
|
||||
triggerLabel={
|
||||
<span className="whitespace-nowrap text-sm font-medium">
|
||||
{credits.toLocaleString()}
|
||||
</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"
|
||||
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
|
||||
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-[#101014] border border-white/15 text-white rounded-[3px]"
|
||||
>
|
||||
{(close) => (
|
||||
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
|
||||
@@ -325,7 +322,7 @@ function CreditsChip({
|
||||
</button>
|
||||
</div>
|
||||
<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()}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
|
||||
@@ -77,7 +77,7 @@ export function Breadcrumbs() {
|
||||
if (crumbs.length === 0) return null;
|
||||
|
||||
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) => {
|
||||
const isLast = i === crumbs.length - 1;
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
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 { Modal } from "@storyteller/ui-modal";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -180,7 +191,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
|
||||
delay={300}
|
||||
>
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -220,7 +231,7 @@ const RecreateTaskButton = ({
|
||||
delay={300}
|
||||
>
|
||||
<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"
|
||||
disabled={isRecreating}
|
||||
onClick={handleRecreate}
|
||||
@@ -255,13 +266,13 @@ const InProgressCard = ({
|
||||
: null;
|
||||
|
||||
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 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
|
||||
|
||||
className="animate-spin text-base-fg/60"
|
||||
size="lg" />
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
@@ -285,9 +296,9 @@ const InProgressCard = ({
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
className="h-1.5 rounded bg-primary-400"
|
||||
className="h-1.5 bg-white"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, task.progress))}%`,
|
||||
}}
|
||||
@@ -307,7 +318,7 @@ const InProgressCard = ({
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -334,12 +345,12 @@ const CompletedCard = ({
|
||||
}) => {
|
||||
return (
|
||||
<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}
|
||||
role={onClick ? "button" : undefined}
|
||||
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 ? (
|
||||
<img
|
||||
src={task.thumbnailUrl}
|
||||
@@ -379,7 +390,7 @@ const CompletedCard = ({
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -403,13 +414,10 @@ const FailedCard = ({
|
||||
}) => {
|
||||
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
|
||||
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 h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
|
||||
<CircleAlertIcon
|
||||
|
||||
className="text-red-400"
|
||||
size="lg" />
|
||||
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10">
|
||||
<CircleAlertIcon className="text-red-400" size="lg" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
@@ -423,7 +431,7 @@ const FailedCard = ({
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
</span>
|
||||
{task.failureReason && (
|
||||
@@ -471,7 +479,7 @@ const FailedCard = ({
|
||||
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
|
||||
{onDismiss && (
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1042,7 +1050,7 @@ export const TaskQueue = () => {
|
||||
<div>
|
||||
{inProgress.length > 0 && (
|
||||
<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
|
||||
</div>
|
||||
{inProgress.map((t) => (
|
||||
@@ -1057,14 +1065,14 @@ export const TaskQueue = () => {
|
||||
{failed.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<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
|
||||
</div>
|
||||
<button
|
||||
className="text-xs tracking-wide text-red-400/70 transition-colors hover:text-red-300"
|
||||
onClick={() => handleClearFailed()}
|
||||
>
|
||||
<XIcon className="mr-1" />
|
||||
<XIcon className="mr-1" />
|
||||
Clear failed
|
||||
</button>
|
||||
</div>
|
||||
@@ -1080,14 +1088,14 @@ export const TaskQueue = () => {
|
||||
{completed.length > 0 && (
|
||||
<div>
|
||||
<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
|
||||
</div>
|
||||
<button
|
||||
className="text-xs tracking-wide text-base-fg/50 transition-colors hover:text-base-fg/100"
|
||||
onClick={() => handleClearCompleted()}
|
||||
>
|
||||
<XIcon className="mr-1" />
|
||||
<XIcon className="mr-1" />
|
||||
Clear completed
|
||||
</button>
|
||||
</div>
|
||||
@@ -1122,23 +1130,21 @@ export const TaskQueue = () => {
|
||||
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
|
||||
<div className="relative task-queue-trigger">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
<PopoverMenu
|
||||
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"
|
||||
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#1a1a1a] border border-white/[0.08] mt-2 rounded-xl"
|
||||
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-[#101014] border border-white/15 mt-2 rounded-[3px]"
|
||||
position="bottom"
|
||||
align="end"
|
||||
triggerIcon={
|
||||
inProgressCount > 0 ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin text-[11px]" />
|
||||
<LoaderCircleIcon className="animate-spin text-[11px]" />
|
||||
) : (
|
||||
<ListChecksIcon className="text-[11px]" />
|
||||
<ListChecksIcon className="text-[11px]" />
|
||||
)
|
||||
}
|
||||
onOpenChange={handleOpenChange}
|
||||
@@ -1175,36 +1181,36 @@ export const TaskQueue = () => {
|
||||
showClose={false}
|
||||
>
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold">Task Queue</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<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()}
|
||||
>
|
||||
<BrushIcon className="mr-1.5" />
|
||||
<BrushIcon className="mr-1.5" />
|
||||
Clear completed
|
||||
</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()}
|
||||
>
|
||||
<Trash2Icon className="mr-1.5" />
|
||||
<Trash2Icon className="mr-1.5" />
|
||||
Clear stale
|
||||
</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()}
|
||||
>
|
||||
<Trash2Icon className="mr-1.5" />
|
||||
<Trash2Icon className="mr-1.5" />
|
||||
Clear failed
|
||||
</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()}
|
||||
>
|
||||
<BombIcon className="mr-1.5" />
|
||||
<BombIcon className="mr-1.5" />
|
||||
Remove all
|
||||
</Button>
|
||||
<div className="mr-2 h-4 w-[1px] bg-base-fg/10" />
|
||||
|
||||
@@ -24,7 +24,7 @@ export function TopBar() {
|
||||
const showSelectToggle = showViewToggle;
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center gap-2 min-w-0 shrink-0">
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
@@ -35,8 +35,8 @@ export function TopBar() {
|
||||
<img
|
||||
src={
|
||||
isMobile
|
||||
? "/images/artcraft-icon.png"
|
||||
: "/images/artcraft-logo.png"
|
||||
? "/images/artcraft-icon-2.png"
|
||||
: "/images/artcraft-logo-2.png"
|
||||
}
|
||||
alt="ArtCraft"
|
||||
className={isMobile ? "h-5 w-auto" : "h-5 w-auto"}
|
||||
|
||||
@@ -53,16 +53,16 @@ const SheetContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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",
|
||||
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" &&
|
||||
"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" &&
|
||||
"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" &&
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -234,7 +234,7 @@ const Sidebar = React.forwardRef<
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[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
|
||||
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}
|
||||
</div>
|
||||
@@ -274,7 +274,7 @@ const SidebarTrigger = React.forwardRef<
|
||||
toggleSidebar();
|
||||
}}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -323,7 +323,7 @@ const SidebarInset = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -397,7 +397,7 @@ const SidebarGroupLabel = React.forwardRef<
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
@@ -447,7 +447,7 @@ const SidebarMenuItem = React.forwardRef<
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -7,24 +7,19 @@ import Seo from "../../components/seo";
|
||||
|
||||
const CheckoutCancel = () => {
|
||||
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
|
||||
title="Checkout Cancelled - ArtCraft"
|
||||
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)]">
|
||||
{/* Cancel Card */}
|
||||
<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 */}
|
||||
<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
|
||||
|
||||
className="text-5xl text-white/50" />
|
||||
@@ -41,7 +36,7 @@ const CheckoutCancel = () => {
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
<span className="text-white font-medium">
|
||||
Changed your mind?
|
||||
@@ -56,14 +51,14 @@ const CheckoutCancel = () => {
|
||||
<Button
|
||||
as="link"
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span className="text-white/70 text-sm">
|
||||
Need help deciding?
|
||||
@@ -73,7 +68,7 @@ const CheckoutCancel = () => {
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
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 />
|
||||
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 { Button } from "@storyteller/ui-button";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
@@ -59,35 +69,27 @@ const CheckoutSuccess = () => {
|
||||
|
||||
if (isCheckingOnboarding) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] 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="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-white/20 border-t-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full bg-[#101014] text-white">
|
||||
<div className="relative min-h-full bg-ui-background text-white">
|
||||
<Seo
|
||||
title="Payment Successful - ArtCraft"
|
||||
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)]">
|
||||
{/* Success Card */}
|
||||
<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 */}
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<CircleCheckIcon
|
||||
|
||||
className="text-5xl text-green-400" />
|
||||
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
|
||||
<CircleCheckIcon className="text-5xl text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,12 +104,10 @@ const CheckoutSuccess = () => {
|
||||
</p>
|
||||
|
||||
{/* 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="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<WandSparklesIcon
|
||||
|
||||
className="text-primary text-sm" />
|
||||
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
|
||||
<WandSparklesIcon className="text-white/70 text-sm" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-white">
|
||||
Start creating right here
|
||||
@@ -130,30 +130,26 @@ const CheckoutSuccess = () => {
|
||||
<Link
|
||||
key={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
|
||||
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">
|
||||
{item.label}
|
||||
</span>
|
||||
<ArrowRightIcon
|
||||
|
||||
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
||||
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<RocketIcon
|
||||
|
||||
className="text-primary text-sm" />
|
||||
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
|
||||
<RocketIcon className="text-white/70 text-sm" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-white">Next Steps</h2>
|
||||
</div>
|
||||
@@ -166,7 +162,7 @@ const CheckoutSuccess = () => {
|
||||
"Start creating with all premium features!",
|
||||
].map((step, idx) => (
|
||||
<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}
|
||||
</div>
|
||||
<span className="text-white/80">{step}</span>
|
||||
@@ -181,16 +177,16 @@ const CheckoutSuccess = () => {
|
||||
<Button
|
||||
as="link"
|
||||
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
|
||||
</Button>
|
||||
</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">
|
||||
<MonitorIcon className="text-2xl" />
|
||||
<MonitorIcon className="text-2xl" />
|
||||
</div>
|
||||
ArtCraft is a desktop application. <br />
|
||||
Please head to your computer to download and start creating.
|
||||
@@ -204,21 +200,21 @@ const CheckoutSuccess = () => {
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
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
|
||||
</a>
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.MACOS}
|
||||
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
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<span className="text-white/70 text-sm">
|
||||
Join our community
|
||||
@@ -228,7 +224,7 @@ const CheckoutSuccess = () => {
|
||||
as="link"
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
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 />
|
||||
Join Discord
|
||||
|
||||
@@ -77,6 +77,16 @@ const AUDIO_REF_MAX_DURATION_SECONDS = 600;
|
||||
|
||||
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
|
||||
let _modelLookup = new Map<string, OmniGenAudioModelDetails>();
|
||||
|
||||
@@ -490,6 +500,7 @@ export default function CreateAudio() {
|
||||
icon={MicOffIcon}
|
||||
activeIcon={MicOffIcon}
|
||||
label="Instrumental"
|
||||
className={toolbarToggleClassName(ui.isInstrumental)}
|
||||
onClick={() => setUi({ isInstrumental: !ui.isInstrumental })}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -506,6 +517,7 @@ export default function CreateAudio() {
|
||||
icon={MicIcon}
|
||||
activeIcon={MicIcon}
|
||||
label="Keep lyrics"
|
||||
className={toolbarToggleClassName(ui.keepLyrics)}
|
||||
onClick={() => setUi({ keepLyrics: !ui.keepLyrics })}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -522,6 +534,7 @@ export default function CreateAudio() {
|
||||
icon={RepeatIcon}
|
||||
activeIcon={RepeatIcon}
|
||||
label="Loop"
|
||||
className={toolbarToggleClassName(ui.isLoopable)}
|
||||
onClick={() => setUi({ isLoopable: !ui.isLoopable })}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -630,7 +643,7 @@ export default function CreateAudio() {
|
||||
settingsFields={
|
||||
<>
|
||||
{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
|
||||
value={ui.stylePrompt}
|
||||
onChange={setStylePrompt}
|
||||
@@ -774,7 +787,7 @@ export default function CreateAudio() {
|
||||
variant="primary"
|
||||
onClick={openSignupCta}
|
||||
icon={SparklesIcon}
|
||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
||||
className="h-12 px-6"
|
||||
>
|
||||
Sign up to create
|
||||
</Button>
|
||||
@@ -953,7 +966,7 @@ function MobileSliderRow({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,6 @@ export const AspectRatioIcon = ({
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
rx={1.5}
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
@@ -680,7 +680,7 @@ export default function CreateImage() {
|
||||
variant="primary"
|
||||
onClick={openSignupCta}
|
||||
icon={SparklesIcon}
|
||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
||||
className="h-12 px-6"
|
||||
>
|
||||
Sign up to create
|
||||
</Button>
|
||||
|
||||
+66
-64
@@ -2,7 +2,11 @@ import { useRef, useState } from "react";
|
||||
import { BoxIcon, ImageIcon, LoaderCircleIcon, XIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
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
|
||||
// file — the mobile form band. The desktop prompt box renders the same store
|
||||
@@ -53,59 +57,61 @@ export function MeshInputsRow({
|
||||
return (
|
||||
// Title on the left, upload slots right-aligned + top-aligned (matches
|
||||
// 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 items-center gap-2 text-white/90">
|
||||
<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>
|
||||
<span className="text-[13px] text-white/60">{subtitle}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-end gap-3">
|
||||
{showMultiView && (
|
||||
<>
|
||||
<ImageSlot
|
||||
label="Front"
|
||||
image={frontImage}
|
||||
onChange={(img) => onChange({ frontImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("front")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="Back"
|
||||
image={backImage}
|
||||
onChange={(img) => onChange({ backImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("back")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="Left"
|
||||
image={leftImage}
|
||||
onChange={(img) => onChange({ leftImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("left")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="Right"
|
||||
image={rightImage}
|
||||
onChange={(img) => onChange({ rightImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("right")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<ImageSlot
|
||||
label="Front"
|
||||
image={frontImage}
|
||||
onChange={(img) => onChange({ frontImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("front")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="Back"
|
||||
image={backImage}
|
||||
onChange={(img) => onChange({ backImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("back")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="Left"
|
||||
image={leftImage}
|
||||
onChange={(img) => onChange({ leftImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("left")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="Right"
|
||||
image={rightImage}
|
||||
onChange={(img) => onChange({ rightImage: img })}
|
||||
onPickFromLibrary={
|
||||
onPickSlotFromLibrary
|
||||
? () => onPickSlotFromLibrary("right")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showMeshInput && (
|
||||
<MeshFileSlot
|
||||
mesh={inputMesh}
|
||||
@@ -120,7 +126,7 @@ export function MeshInputsRow({
|
||||
// ── Slots ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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({
|
||||
label,
|
||||
@@ -132,7 +138,9 @@ function SlotShell({
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -171,7 +179,7 @@ function ImageSlot({
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
{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
|
||||
src={image.url}
|
||||
alt={`${label} view`}
|
||||
@@ -179,17 +187,14 @@ function ImageSlot({
|
||||
/>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
) : uploading ? (
|
||||
<div className={SLOT_CLASS}>
|
||||
<LoaderCircleIcon
|
||||
|
||||
spin
|
||||
className="h-5 w-5 text-white" />
|
||||
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<AddButton
|
||||
@@ -231,28 +236,25 @@ function MeshFileSlot({
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
{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">
|
||||
<BoxIcon className="h-5 w-5 text-white/80" />
|
||||
<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" />
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
) : uploading ? (
|
||||
<div className={SLOT_CLASS}>
|
||||
<LoaderCircleIcon
|
||||
|
||||
spin
|
||||
className="h-5 w-5 text-white" />
|
||||
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={SLOT_CLASS}
|
||||
>
|
||||
<BoxIcon className="text-xl text-white/80" />
|
||||
<BoxIcon className="text-xl text-white/80" />
|
||||
</button>
|
||||
)}
|
||||
</SlotShell>
|
||||
|
||||
@@ -625,7 +625,7 @@ export default function CreateObject() {
|
||||
variant="primary"
|
||||
onClick={openSignupCta}
|
||||
icon={SparklesIcon}
|
||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
||||
className="h-12 px-6"
|
||||
>
|
||||
Sign up to create
|
||||
</Button>
|
||||
|
||||
@@ -247,16 +247,16 @@ export default function CreateVFX() {
|
||||
|
||||
if (!authChecked) {
|
||||
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
|
||||
|
||||
|
||||
className="animate-spin text-4xl text-primary/80" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
title="Background Change - ArtCraft"
|
||||
description="Swap the backdrop of a video using a reference image."
|
||||
@@ -304,7 +304,7 @@ export default function CreateVFX() {
|
||||
|
||||
<div
|
||||
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)" }}
|
||||
/>
|
||||
|
||||
@@ -338,7 +338,7 @@ interface EmptyStateProps {
|
||||
|
||||
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
|
||||
<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" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white">{title}</h3>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 { CharactersApi, FilterMediaClasses } from "@storyteller/api";
|
||||
import type { OmniGenVideoModelInfo } from "@storyteller/api";
|
||||
@@ -1487,10 +1492,8 @@ export default function CreateVideo() {
|
||||
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
|
||||
banner={
|
||||
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">
|
||||
<InfoIcon
|
||||
|
||||
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||
<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 className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||
<span>
|
||||
This model can't generate from text alone — add a starting
|
||||
frame to animate your prompt.
|
||||
@@ -1506,13 +1509,13 @@ export default function CreateVideo() {
|
||||
key={item.label}
|
||||
type="button"
|
||||
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
|
||||
? "border-primary bg-primary/15"
|
||||
? "border-white bg-white/10"
|
||||
: "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}
|
||||
</span>
|
||||
<span className="block text-xs text-base-fg/55">
|
||||
@@ -1566,9 +1569,9 @@ export default function CreateVideo() {
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
<AudioLinesIcon className="h-4 w-4" />
|
||||
<AudioLinesIcon className="h-4 w-4" />
|
||||
Audio
|
||||
</span>
|
||||
<ToggleButton
|
||||
@@ -1584,7 +1587,7 @@ export default function CreateVideo() {
|
||||
<>
|
||||
<MobileFieldButton
|
||||
label="Output"
|
||||
icon={<ClockIcon className="h-4 w-4" />}
|
||||
icon={<ClockIcon className="h-4 w-4" />}
|
||||
value={outputSummary}
|
||||
onClick={() => setIsOutputDrawerOpen(true)}
|
||||
/>
|
||||
@@ -1653,7 +1656,7 @@ export default function CreateVideo() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -1664,14 +1667,6 @@ export default function CreateVideo() {
|
||||
|
||||
// ── 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 (
|
||||
<CreateMediaPageShell
|
||||
title="Create Video - ArtCraft"
|
||||
@@ -1688,7 +1683,7 @@ export default function CreateVideo() {
|
||||
variant="primary"
|
||||
onClick={openSignupCta}
|
||||
icon={SparklesIcon}
|
||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
||||
className="h-12 px-6"
|
||||
>
|
||||
Sign up to create
|
||||
</Button>
|
||||
@@ -1697,7 +1692,6 @@ export default function CreateVideo() {
|
||||
bottomOffset={promptHeight + 24}
|
||||
modelItems={modelItems}
|
||||
onModelChange={handleModelChange}
|
||||
glowOrbs={videoGlowOrbs}
|
||||
promptForm={mobileForm}
|
||||
gridContent={
|
||||
<GenerationGallery
|
||||
@@ -1726,7 +1720,7 @@ export default function CreateVideo() {
|
||||
}}
|
||||
>
|
||||
{/* {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" />
|
||||
<span>
|
||||
Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages.
|
||||
@@ -1735,10 +1729,8 @@ export default function CreateVideo() {
|
||||
</div>
|
||||
)} */}
|
||||
{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">
|
||||
<InfoIcon
|
||||
|
||||
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||
<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 className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||
<span>
|
||||
This model can't generate from text alone — add a starting
|
||||
frame to animate your prompt.
|
||||
@@ -1900,11 +1892,7 @@ export default function CreateVideo() {
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
panelTitle="Duration"
|
||||
triggerIcon={
|
||||
<ClockIcon
|
||||
|
||||
className="h-3.5 w-3.5" />
|
||||
}
|
||||
triggerIcon={<ClockIcon className="h-3.5 w-3.5" />}
|
||||
triggerLabel={`${effectiveDuration}s`}
|
||||
>
|
||||
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
|
||||
@@ -1948,7 +1936,7 @@ export default function CreateVideo() {
|
||||
}
|
||||
className={
|
||||
generateWithSound
|
||||
? "bg-primary/40 hover:bg-primary/50 border-primary/30"
|
||||
? "bg-white/10 hover:bg-white/15 border-white/40"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1973,7 +1961,7 @@ export default function CreateVideo() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</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).
|
||||
|
||||
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({
|
||||
video,
|
||||
@@ -62,13 +62,17 @@ export function ReferenceVideoSlot({
|
||||
return (
|
||||
// Title on the left, upload slot right-aligned + top-aligned (matches
|
||||
// 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 items-center gap-2 text-white/90">
|
||||
<VideoIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-sm font-medium">Reference video</span>
|
||||
<VideoIcon className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
Reference video
|
||||
</span>
|
||||
</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>
|
||||
<input
|
||||
type="file"
|
||||
@@ -78,7 +82,7 @@ export function ReferenceVideoSlot({
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
{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
|
||||
src={video.url}
|
||||
muted
|
||||
@@ -87,9 +91,9 @@ export function ReferenceVideoSlot({
|
||||
/>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
) : uploading ? (
|
||||
@@ -97,8 +101,11 @@ export function ReferenceVideoSlot({
|
||||
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => inputRef.current?.click()} className={SLOT_CLASS}>
|
||||
<PlusIcon className="text-xl text-white/80" />
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={SLOT_CLASS}
|
||||
>
|
||||
<PlusIcon className="text-xl text-white/80" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -478,7 +478,7 @@ export default function CreateWorld() {
|
||||
variant="primary"
|
||||
onClick={openSignupCta}
|
||||
icon={SparklesIcon}
|
||||
className="h-12 px-6 text-base font-semibold rounded-full"
|
||||
className="h-12 px-6"
|
||||
>
|
||||
Sign up to create
|
||||
</Button>
|
||||
|
||||
@@ -41,22 +41,14 @@ const ForgotPassword = () => {
|
||||
};
|
||||
|
||||
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
|
||||
title="Reset Password - ArtCraft"
|
||||
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">
|
||||
<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 ? (
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
@@ -80,13 +72,13 @@ const ForgotPassword = () => {
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -95,21 +87,19 @@ const ForgotPassword = () => {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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 className="pt-2">
|
||||
<Button
|
||||
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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Send Reset Code"
|
||||
)}
|
||||
@@ -119,8 +109,8 @@ const ForgotPassword = () => {
|
||||
</>
|
||||
) : (
|
||||
<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">
|
||||
<MailIcon className="text-2xl" />
|
||||
<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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">Check your email</h3>
|
||||
<p className="text-white/60 text-sm mb-6">
|
||||
@@ -128,12 +118,12 @@ const ForgotPassword = () => {
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<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
|
||||
</Button>
|
||||
</Link>
|
||||
<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)}
|
||||
>
|
||||
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 { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
@@ -96,22 +102,14 @@ const VerifyReset = () => {
|
||||
};
|
||||
|
||||
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
|
||||
title="Verify Password Reset - ArtCraft"
|
||||
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">
|
||||
<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 ? (
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
@@ -137,14 +135,14 @@ const VerifyReset = () => {
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Code */}
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -154,7 +152,7 @@ const VerifyReset = () => {
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="Enter verification code"
|
||||
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 && (
|
||||
<p className="text-red-400 text-xs ml-1">
|
||||
@@ -165,7 +163,7 @@ const VerifyReset = () => {
|
||||
|
||||
{/* New Password */}
|
||||
<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
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -176,7 +174,7 @@ const VerifyReset = () => {
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
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
|
||||
type="button"
|
||||
@@ -197,7 +195,7 @@ const VerifyReset = () => {
|
||||
|
||||
{/* Verify New Password */}
|
||||
<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
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -208,7 +206,7 @@ const VerifyReset = () => {
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Enter new password again"
|
||||
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
|
||||
type="button"
|
||||
@@ -232,14 +230,12 @@ const VerifyReset = () => {
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Change Password"
|
||||
)}
|
||||
@@ -259,8 +255,8 @@ const VerifyReset = () => {
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<CircleCheckIcon className="text-2xl" />
|
||||
<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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
Password Reset Successfully
|
||||
@@ -271,7 +267,7 @@ const VerifyReset = () => {
|
||||
</p>
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
{redirectLabel}
|
||||
|
||||
@@ -43,9 +43,9 @@ export const ExtractionPanel = ({
|
||||
const burstAvailable = Number.isFinite(duration) && duration > 0;
|
||||
|
||||
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="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 className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
|
||||
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
|
||||
@@ -56,7 +56,7 @@ export const ExtractionPanel = ({
|
||||
icon={CameraIcon}
|
||||
onClick={onCaptureCurrent}
|
||||
disabled={disabled || isExtracting}
|
||||
className="w-full py-2 text-sm font-medium"
|
||||
className="w-full py-2"
|
||||
>
|
||||
Capture frame
|
||||
</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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayersIcon
|
||||
|
||||
className="text-xs text-base-fg/40" />
|
||||
<LayersIcon className="text-xs text-base-fg/40" />
|
||||
Burst capture
|
||||
</span>
|
||||
<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>
|
||||
<AnimatePresence initial={false}>
|
||||
{burstOpen && !disabled && burstAvailable && (
|
||||
@@ -108,10 +106,13 @@ export const ExtractionPanel = ({
|
||||
disabled={isExtracting}
|
||||
onChange={(e) =>
|
||||
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 className="block">
|
||||
@@ -126,18 +127,21 @@ export const ExtractionPanel = ({
|
||||
disabled={isExtracting}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
</div>
|
||||
{isExtracting && progress ? (
|
||||
<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
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
className="h-full bg-white transition-all"
|
||||
style={{
|
||||
width: `${(progress.done / progress.total) * 100}%`,
|
||||
}}
|
||||
@@ -148,10 +152,10 @@ export const ExtractionPanel = ({
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<XIcon className="text-xs" />
|
||||
<XIcon className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -159,7 +163,7 @@ export const ExtractionPanel = ({
|
||||
variant="action"
|
||||
onClick={onExtractBurst}
|
||||
disabled={isExtracting}
|
||||
className="w-full py-1.5 text-sm font-medium"
|
||||
className="w-full py-1.5"
|
||||
>
|
||||
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
|
||||
</Button>
|
||||
@@ -174,13 +178,17 @@ export const ExtractionPanel = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Duration</span>
|
||||
<span className="font-mono text-base-fg/70">
|
||||
{!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"}
|
||||
{!disabled && Number.isFinite(duration)
|
||||
? formatTime(duration)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Resolution</span>
|
||||
<span className="font-mono text-base-fg/70">
|
||||
{!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"}
|
||||
{!disabled && resolution
|
||||
? `${resolution.w} × ${resolution.h}`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,7 +436,7 @@ export default function FrameExtractor() {
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
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."
|
||||
@@ -452,7 +452,7 @@ export default function FrameExtractor() {
|
||||
variant="action"
|
||||
icon={RotateCwIcon}
|
||||
onClick={clearSource}
|
||||
className="px-3 py-1.5 text-sm"
|
||||
className="px-3 py-1.5"
|
||||
>
|
||||
Switch Video
|
||||
</Button>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const FramesGrid = ({
|
||||
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
|
||||
|
||||
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">
|
||||
<h3 className="text-sm font-semibold text-base-fg">
|
||||
Captured frames
|
||||
@@ -56,7 +56,7 @@ export const FramesGrid = ({
|
||||
<button
|
||||
onClick={onSaveAll}
|
||||
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
|
||||
icon={isSavingAll ? LoaderCircleIcon : SaveIcon}
|
||||
@@ -67,7 +67,7 @@ export const FramesGrid = ({
|
||||
)}
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -86,7 +86,7 @@ export const FramesGrid = ({
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
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">
|
||||
<img
|
||||
@@ -96,12 +96,12 @@ export const FramesGrid = ({
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<XIcon className="text-xs" />
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -158,14 +158,14 @@ const FrameAction = ({
|
||||
disabled,
|
||||
success,
|
||||
}: FrameActionProps) => (
|
||||
// fixed strategy: the card is overflow-hidden (rounded corners), which
|
||||
// would clip an absolutely-positioned tooltip.
|
||||
// fixed strategy: the card is overflow-hidden, which would clip an
|
||||
// absolutely-positioned tooltip.
|
||||
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
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
|
||||
? "text-green"
|
||||
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
|
||||
|
||||
@@ -24,11 +24,11 @@ export const VideoDropZone = ({
|
||||
};
|
||||
|
||||
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
|
||||
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",
|
||||
isDragActive && "border-primary/60 bg-primary/5",
|
||||
"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-white/60 bg-white/5",
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -65,7 +65,7 @@ export const VideoDropZone = ({
|
||||
variant="primary"
|
||||
icon={UploadIcon}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-1.5 text-sm font-medium"
|
||||
className="px-4 py-1.5"
|
||||
>
|
||||
Select video
|
||||
</Button>
|
||||
@@ -73,7 +73,7 @@ export const VideoDropZone = ({
|
||||
variant="action"
|
||||
icon={ImagesIcon}
|
||||
onClick={onPickFromLibrary}
|
||||
className="px-4 py-1.5 text-sm font-medium"
|
||||
className="px-4 py-1.5"
|
||||
>
|
||||
From library
|
||||
</Button>
|
||||
|
||||
@@ -228,7 +228,7 @@ export const VideoScrubber = ({
|
||||
];
|
||||
|
||||
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">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -243,7 +243,7 @@ export const VideoScrubber = ({
|
||||
{!isPlaying && !disabled && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<PlayIcon className="ml-0.5" />
|
||||
@@ -261,21 +261,21 @@ export const VideoScrubber = ({
|
||||
onPointerCancel={handleTimelinePointerUp}
|
||||
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
|
||||
className="h-full rounded-full bg-primary"
|
||||
className="h-full bg-white"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</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}%` }}
|
||||
/>
|
||||
{hoverRatio !== null &&
|
||||
Number.isFinite(duration) &&
|
||||
duration > 0 && (
|
||||
<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}%` }}
|
||||
>
|
||||
{formatTimePrecise(hoverRatio * duration)}
|
||||
@@ -289,7 +289,7 @@ export const VideoScrubber = ({
|
||||
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
|
||||
<button
|
||||
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}
|
||||
>
|
||||
<DynamicIcon icon={step.icon} className="text-xs" />
|
||||
@@ -299,7 +299,7 @@ export const VideoScrubber = ({
|
||||
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
@@ -309,7 +309,7 @@ export const VideoScrubber = ({
|
||||
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
|
||||
<button
|
||||
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}
|
||||
>
|
||||
<DynamicIcon icon={step.icon} className="text-xs" />
|
||||
@@ -322,7 +322,7 @@ export const VideoScrubber = ({
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<button
|
||||
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"}
|
||||
>
|
||||
<DynamicIcon
|
||||
@@ -340,7 +340,7 @@ export const VideoScrubber = ({
|
||||
const video = videoRef.current;
|
||||
if (video) video.volume = parseFloat(e.target.value);
|
||||
}}
|
||||
className="w-20 accent-primary"
|
||||
className="w-20 accent-white"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { ArrowRightIcon, BoxIcon, FilmIcon, GlobeIcon, GroupIcon, ImageIcon, ImagesIcon, MusicIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BoxIcon,
|
||||
FilmIcon,
|
||||
GlobeIcon,
|
||||
GroupIcon,
|
||||
ImageIcon,
|
||||
ImagesIcon,
|
||||
MusicIcon,
|
||||
VideoIcon,
|
||||
WandSparklesIcon,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import Seo from "../../components/seo";
|
||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||
import { TRANSITION_SPRING } from "../../lib/motion";
|
||||
|
||||
const MotionLink = motion(Link);
|
||||
|
||||
type AppCard = {
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
accent: string;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
iconTile: string;
|
||||
hoverBorder: string;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
@@ -26,72 +32,64 @@ const APPS: AppCard[] = [
|
||||
description: "Generate images from prompts and references.",
|
||||
href: "/create-image",
|
||||
icon: ImageIcon,
|
||||
accent: "from-blue-500/20 to-blue-500/0",
|
||||
iconBg: "bg-blue-500/25 border-blue-400/30",
|
||||
iconColor: "text-blue-300",
|
||||
iconTile: "border-blue-400/40 bg-blue-500/20 text-blue-300",
|
||||
hoverBorder: "hover:border-blue-400/60",
|
||||
},
|
||||
{
|
||||
label: "Video",
|
||||
description: "Generate cinematic clips from text or images.",
|
||||
href: "/create-video",
|
||||
icon: VideoIcon,
|
||||
accent: "from-purple-500/20 to-purple-500/0",
|
||||
iconBg: "bg-purple-500/25 border-purple-400/30",
|
||||
iconColor: "text-purple-300",
|
||||
iconTile: "border-purple-400/40 bg-purple-500/20 text-purple-300",
|
||||
hoverBorder: "hover:border-purple-400/60",
|
||||
},
|
||||
{
|
||||
label: "Audio",
|
||||
description: "Generate songs and sound effects from prompts.",
|
||||
href: "/create-audio",
|
||||
icon: MusicIcon,
|
||||
accent: "from-pink-500/20 to-pink-500/0",
|
||||
iconBg: "bg-pink-500/25 border-pink-400/30",
|
||||
iconColor: "text-pink-300",
|
||||
iconTile: "border-pink-400/40 bg-pink-500/20 text-pink-300",
|
||||
hoverBorder: "hover:border-pink-400/60",
|
||||
},
|
||||
{
|
||||
label: "3D Object",
|
||||
description: "Turn a prompt or image into a 3D model.",
|
||||
href: "/create-object",
|
||||
icon: BoxIcon,
|
||||
accent: "from-cyan-500/20 to-cyan-500/0",
|
||||
iconBg: "bg-cyan-500/25 border-cyan-400/30",
|
||||
iconColor: "text-cyan-300",
|
||||
iconTile: "border-cyan-400/40 bg-cyan-500/20 text-cyan-300",
|
||||
hoverBorder: "hover:border-cyan-400/60",
|
||||
},
|
||||
{
|
||||
label: "3D World",
|
||||
description: "Turn a prompt or image into an explorable world.",
|
||||
href: "/create-world",
|
||||
icon: GlobeIcon,
|
||||
accent: "from-teal-500/20 to-teal-500/0",
|
||||
iconBg: "bg-teal-500/25 border-teal-400/30",
|
||||
iconColor: "text-teal-300",
|
||||
iconTile: "border-teal-400/40 bg-teal-500/20 text-teal-300",
|
||||
hoverBorder: "hover:border-teal-400/60",
|
||||
},
|
||||
{
|
||||
label: "Edit 3D",
|
||||
description: "Compose 3D scenes and render with AI cameras.",
|
||||
href: "/edit-3d",
|
||||
icon: BoxIcon,
|
||||
accent: "from-amber-500/20 to-amber-500/0",
|
||||
iconBg: "bg-amber-500/25 border-amber-400/30",
|
||||
iconColor: "text-amber-300",
|
||||
iconTile: "border-amber-400/40 bg-amber-500/20 text-amber-300",
|
||||
hoverBorder: "hover:border-amber-400/60",
|
||||
},
|
||||
{
|
||||
label: "Background Change",
|
||||
description: "Swap or remove backgrounds with AI VFX.",
|
||||
href: "/background-change",
|
||||
icon: WandSparklesIcon,
|
||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
||||
iconBg: "bg-emerald-500/25 border-emerald-400/30",
|
||||
iconColor: "text-emerald-300",
|
||||
iconTile: "border-emerald-400/40 bg-emerald-500/20 text-emerald-300",
|
||||
hoverBorder: "hover:border-emerald-400/60",
|
||||
},
|
||||
{
|
||||
label: "Edit Video",
|
||||
description: "Trim, arrange, and edit clips on a timeline.",
|
||||
href: "/video-editor",
|
||||
icon: FilmIcon,
|
||||
accent: "from-rose-500/20 to-rose-500/0",
|
||||
iconBg: "bg-rose-500/25 border-rose-400/30",
|
||||
iconColor: "text-rose-300",
|
||||
iconTile: "border-rose-400/40 bg-rose-500/20 text-rose-300",
|
||||
hoverBorder: "hover:border-rose-400/60",
|
||||
badge: "BETA",
|
||||
},
|
||||
{
|
||||
@@ -99,9 +97,8 @@ const APPS: AppCard[] = [
|
||||
description: "Collect references and ideas to steer a generation.",
|
||||
href: "/moodboard",
|
||||
icon: GroupIcon,
|
||||
accent: "from-indigo-500/20 to-indigo-500/0",
|
||||
iconBg: "bg-indigo-500/25 border-indigo-400/30",
|
||||
iconColor: "text-indigo-300",
|
||||
iconTile: "border-indigo-400/40 bg-indigo-500/20 text-indigo-300",
|
||||
hoverBorder: "hover:border-indigo-400/60",
|
||||
badge: "BETA",
|
||||
},
|
||||
{
|
||||
@@ -109,27 +106,35 @@ const APPS: AppCard[] = [
|
||||
description: "Grab a still frame from any video.",
|
||||
href: "/frame-extractor",
|
||||
icon: ImagesIcon,
|
||||
accent: "from-orange-500/20 to-orange-500/0",
|
||||
iconBg: "bg-orange-500/25 border-orange-400/30",
|
||||
iconColor: "text-orange-300",
|
||||
iconTile: "border-orange-400/40 bg-orange-500/20 text-orange-300",
|
||||
hoverBorder: "hover:border-orange-400/60",
|
||||
badge: "NEW",
|
||||
},
|
||||
];
|
||||
|
||||
export function Home() {
|
||||
const reduceMotion = useReducedMotion();
|
||||
return (
|
||||
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
|
||||
<Seo
|
||||
title="ArtCraft - Create AI Images and Video"
|
||||
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?
|
||||
</Reveal>
|
||||
|
||||
<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
|
||||
</Reveal>
|
||||
<RevealGroup
|
||||
@@ -140,37 +145,36 @@ export function Home() {
|
||||
>
|
||||
{APPS.map((app) => (
|
||||
<Reveal key={app.href} y={20}>
|
||||
<MotionLink
|
||||
<Link
|
||||
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"
|
||||
whileHover={reduceMotion ? undefined : { y: -4 }}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.985 }}
|
||||
transition={TRANSITION_SPRING}
|
||||
className={`group relative flex h-full border rounded-[3px] border-white/10 bg-white/5 p-5 transition-colors duration-150 ${app.hoverBorder}`}
|
||||
>
|
||||
<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={`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" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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}
|
||||
</h3>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<ArrowRightIcon className="text-xs" />
|
||||
<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" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/55 leading-snug">
|
||||
@@ -178,7 +182,7 @@ export function Home() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</MotionLink>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</RevealGroup>
|
||||
|
||||
@@ -35,7 +35,31 @@ import {
|
||||
showActionReminder,
|
||||
isActionReminderOpen,
|
||||
} 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 { Lightbox } from "../../components/lightbox/lightbox";
|
||||
import { toast } from "../../components/toast/toast";
|
||||
@@ -988,7 +1012,7 @@ export default function Library() {
|
||||
// ── Not logged in / loading auth ──────────────────────────────────────────
|
||||
if (isLoggedIn === false) {
|
||||
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">
|
||||
<h1 className="text-3xl font-medium text-white">My Library</h1>
|
||||
<p className="text-white/60 text-lg max-w-md mx-auto">
|
||||
@@ -996,18 +1020,12 @@ export default function Library() {
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full bg-white text-black hover:bg-white/90 text-sm font-semibold px-6 py-2.5"
|
||||
>
|
||||
<Button variant="primary" className="px-6 py-2.5">
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full text-sm font-semibold px-6 py-2.5"
|
||||
>
|
||||
<Button variant="secondary" className="px-6 py-2.5">
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -1019,7 +1037,7 @@ export default function Library() {
|
||||
|
||||
if (isLoggedIn === null) {
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
@@ -1048,7 +1066,9 @@ export default function Library() {
|
||||
gridColumns={4}
|
||||
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}>
|
||||
{dateItems.map((item) => (
|
||||
<LibraryTile
|
||||
@@ -1084,7 +1104,7 @@ export default function Library() {
|
||||
// `select-none` blocks the blue text/image highlight when sweeping a
|
||||
// marquee or fat-fingering a click; native selection would otherwise also
|
||||
// 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}
|
||||
>
|
||||
<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
|
||||
// staying below the app topbar (z-20), so topbar popovers (credits,
|
||||
// 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">
|
||||
{/* Tabs + actions */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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
|
||||
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"
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab === "unsorted" && (
|
||||
<motion.span
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
<Grid3x3Icon
|
||||
|
||||
className="relative z-10 text-xs" />
|
||||
<Grid3x3Icon className="relative z-10 text-xs" />
|
||||
<span className="relative z-10">All Assets</span>
|
||||
</Link>
|
||||
<Link
|
||||
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"
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab === "folders" && (
|
||||
<motion.span
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
<FolderIcon
|
||||
|
||||
className="relative z-10 text-xs" />
|
||||
<FolderIcon className="relative z-10 text-xs" />
|
||||
<span className="relative z-10">Folders</span>
|
||||
</Link>
|
||||
<Link
|
||||
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"
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab === "folderless" && (
|
||||
<motion.span
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
<FolderOpenIcon
|
||||
|
||||
className="relative z-10 text-xs" />
|
||||
<FolderOpenIcon className="relative z-10 text-xs" />
|
||||
<span className="relative z-10">Unfoldered</span>
|
||||
</Link>
|
||||
<Link
|
||||
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"
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab === "tags" && (
|
||||
<motion.span
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
<TagIcon
|
||||
|
||||
className="relative z-10 text-xs" />
|
||||
<TagIcon className="relative z-10 text-xs" />
|
||||
<span className="relative z-10">Tags</span>
|
||||
</Link>
|
||||
</div>
|
||||
{(tab === "unsorted" || tab === "folderless") && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
|
||||
className={`text-sm ${initialLoading ? "animate-spin" : ""}`} />
|
||||
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{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) => (
|
||||
<button
|
||||
key={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
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{folderlessClass === filter.id && (
|
||||
<motion.span
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
@@ -1227,21 +1239,21 @@ export default function Library() {
|
||||
</div>
|
||||
)}
|
||||
{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) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
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
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{activeFilter === filter.id && (
|
||||
<motion.span
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
@@ -1261,7 +1273,7 @@ export default function Library() {
|
||||
variant="primary"
|
||||
icon={FolderPlusIcon}
|
||||
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
|
||||
</Button>
|
||||
@@ -1269,7 +1281,7 @@ export default function Library() {
|
||||
{tab === "tags" && !activeTagToken && tags.length > 0 && (
|
||||
<>
|
||||
{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"],
|
||||
@@ -1279,16 +1291,16 @@ export default function Library() {
|
||||
<button
|
||||
key={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
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tagSort === id && (
|
||||
<motion.span
|
||||
layoutId="library-tag-sort-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,
|
||||
@@ -1300,7 +1312,7 @@ export default function Library() {
|
||||
))}
|
||||
</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"],
|
||||
@@ -1311,16 +1323,16 @@ export default function Library() {
|
||||
key={id}
|
||||
onClick={() => setTagView(id)}
|
||||
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
|
||||
? "text-white"
|
||||
? "text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tagView === id && (
|
||||
<motion.span
|
||||
layoutId="library-tag-view-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,
|
||||
@@ -1368,17 +1380,17 @@ export default function Library() {
|
||||
</h1>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<PencilIcon className="text-xs" />
|
||||
<PencilIcon className="text-xs" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<FolderPlusIcon className="text-xs" />
|
||||
<FolderPlusIcon className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1404,17 +1416,17 @@ export default function Library() {
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<PencilIcon className="text-xs" />
|
||||
<PencilIcon className="text-xs" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2Icon className="text-xs" />
|
||||
<Trash2Icon className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1427,7 +1439,7 @@ export default function Library() {
|
||||
{tab === "folders" && currentSubfolders.length > 0 && (
|
||||
<div>
|
||||
{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
|
||||
</h3>
|
||||
)}
|
||||
@@ -1451,17 +1463,15 @@ export default function Library() {
|
||||
/* ── Folders tab, root: cards only (above) ── */
|
||||
currentSubfolders.length === 0 && (
|
||||
<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">
|
||||
<FolderOpenIcon
|
||||
|
||||
className="text-2xl text-white/40" />
|
||||
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
|
||||
<FolderOpenIcon className="text-2xl text-white/40" />
|
||||
</div>
|
||||
<p className="text-white/40 text-sm">No folders yet.</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={FolderPlusIcon}
|
||||
onClick={() => openNewFolderModal(null)}
|
||||
className="rounded-full text-sm px-4 py-2"
|
||||
className="px-4 py-2"
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
@@ -1482,18 +1492,12 @@ export default function Library() {
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/create-image">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full text-sm px-4 py-2"
|
||||
>
|
||||
<Button variant="primary" className="px-4 py-2">
|
||||
Create Image
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/create-video">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
|
||||
>
|
||||
<Button variant="secondary" className="px-4 py-2">
|
||||
Create Video
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -1517,10 +1521,8 @@ export default function Library() {
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<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">
|
||||
<TagsIcon
|
||||
|
||||
className="text-2xl text-white/40" />
|
||||
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
|
||||
<TagsIcon className="text-2xl text-white/40" />
|
||||
</div>
|
||||
<p className="text-white/40 text-sm">No tags yet.</p>
|
||||
<p className="text-white/30 text-xs max-w-xs text-center">
|
||||
@@ -1541,7 +1543,7 @@ export default function Library() {
|
||||
{sortedTags.map((t) => (
|
||||
<div
|
||||
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
|
||||
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"
|
||||
>
|
||||
<TagIcon
|
||||
|
||||
className="text-xs text-violet-400" />
|
||||
<TagIcon className="text-xs text-violet-400" />
|
||||
<span className="max-w-[14rem] truncate">{t.value}</span>
|
||||
<span className="text-xs text-white/40">
|
||||
{t.useCount}
|
||||
@@ -1576,9 +1576,9 @@ export default function Library() {
|
||||
}}
|
||||
aria-label={`Options for tag "${t.value}"`}
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
@@ -1609,7 +1609,7 @@ export default function Library() {
|
||||
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
|
||||
<div>
|
||||
<div
|
||||
className="h-4 w-24 rounded mb-3"
|
||||
className="h-4 w-24 mb-3"
|
||||
style={{
|
||||
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%)",
|
||||
@@ -1619,10 +1619,7 @@ export default function Library() {
|
||||
/>
|
||||
<div className={GRID_CLASS}>
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square rounded-lg overflow-hidden"
|
||||
>
|
||||
<div key={i} className="aspect-square overflow-hidden">
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
@@ -1648,18 +1645,12 @@ export default function Library() {
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/create-image">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-full text-sm px-4 py-2"
|
||||
>
|
||||
<Button variant="primary" className="px-4 py-2">
|
||||
Create Image
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/create-video">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
|
||||
>
|
||||
<Button variant="secondary" className="px-4 py-2">
|
||||
Create Video
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -1687,7 +1678,7 @@ export default function Library() {
|
||||
<div
|
||||
ref={marqueeRef}
|
||||
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
|
||||
@@ -1772,7 +1763,7 @@ export default function Library() {
|
||||
}}
|
||||
/>
|
||||
<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 }}
|
||||
>
|
||||
{(() => {
|
||||
@@ -1783,7 +1774,7 @@ export default function Library() {
|
||||
<>
|
||||
<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={() => {
|
||||
setFolderStar(
|
||||
contextMenu.folderId,
|
||||
@@ -1793,8 +1784,8 @@ export default function Library() {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<FolderColorRow
|
||||
@@ -1809,32 +1800,32 @@ export default function Library() {
|
||||
})()}
|
||||
<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={() => {
|
||||
openNewFolderModal(contextMenu.folderId);
|
||||
}}
|
||||
>
|
||||
<FolderPlusIcon className="w-4" />
|
||||
<FolderPlusIcon className="w-4" />
|
||||
<span>New subfolder</span>
|
||||
</button>
|
||||
<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)}
|
||||
>
|
||||
<PencilIcon className="w-4" />
|
||||
<PencilIcon className="w-4" />
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
<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={() => {
|
||||
const folderId = contextMenu.folderId;
|
||||
setContextMenu(null);
|
||||
confirmDeleteFolder(folderId);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="w-4" />
|
||||
<Trash2Icon className="w-4" />
|
||||
<span>Delete folder</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1855,27 +1846,27 @@ export default function Library() {
|
||||
}}
|
||||
/>
|
||||
<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 }}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<PencilIcon className="w-4" />
|
||||
<PencilIcon className="w-4" />
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
<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={() => {
|
||||
const token = tagContextMenu.tagToken;
|
||||
setTagContextMenu(null);
|
||||
confirmDeleteTag(token);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="w-4" />
|
||||
<Trash2Icon className="w-4" />
|
||||
<span>Delete tag</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2051,7 +2042,7 @@ const SEND_TO_TARGETS: {
|
||||
// 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.
|
||||
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 {
|
||||
allItems: GalleryItem[];
|
||||
@@ -2180,7 +2171,7 @@ function BulkSelectionBar({
|
||||
return (
|
||||
<div
|
||||
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={{
|
||||
// Center within the content area (viewport minus the app sidebar).
|
||||
left: "calc(50% + var(--ac-sidebar-offset, 0px) / 2)",
|
||||
@@ -2191,7 +2182,7 @@ function BulkSelectionBar({
|
||||
<BulkThumb key={si.id} item={si} />
|
||||
))}
|
||||
{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">
|
||||
+{selectedItems.length - 4}
|
||||
</span>
|
||||
@@ -2225,8 +2216,8 @@ function BulkSelectionBar({
|
||||
className="fixed inset-0 z-[59]"
|
||||
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="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
||||
<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 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||
Use as reference in
|
||||
</div>
|
||||
{SEND_TO_TARGETS.filter(
|
||||
@@ -2240,7 +2231,7 @@ function BulkSelectionBar({
|
||||
sendToPrompt(selectedItems, target.destination, navigate);
|
||||
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" />
|
||||
<span>{target.label}</span>
|
||||
@@ -2265,7 +2256,7 @@ function BulkSelectionBar({
|
||||
}}
|
||||
className={BAR_BUTTON_CLASS}
|
||||
>
|
||||
<ImagesIcon className="text-xs" />
|
||||
<ImagesIcon className="text-xs" />
|
||||
<span className="hidden sm:inline">Extract frames</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -2278,7 +2269,7 @@ function BulkSelectionBar({
|
||||
onClick={() => setPopoverOpen((v) => !v)}
|
||||
className={BAR_BUTTON_CLASS}
|
||||
>
|
||||
<FolderPlusIcon className="text-xs" />
|
||||
<FolderPlusIcon className="text-xs" />
|
||||
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
|
||||
<span className="hidden sm:inline">Add to folder</span>
|
||||
</button>
|
||||
@@ -2288,8 +2279,8 @@ function BulkSelectionBar({
|
||||
className="fixed inset-0 z-[59]"
|
||||
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="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
||||
<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 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||
Folders
|
||||
</div>
|
||||
{sortedFolders.length === 0 ? (
|
||||
@@ -2306,10 +2297,9 @@ function BulkSelectionBar({
|
||||
setPopoverOpen(false);
|
||||
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
|
||||
|
||||
className={
|
||||
folder.colorCode ? "text-xs" : "text-xs text-primary"
|
||||
}
|
||||
@@ -2317,7 +2307,8 @@ function BulkSelectionBar({
|
||||
folder.colorCode
|
||||
? { color: folder.colorCode }
|
||||
: undefined
|
||||
} />
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</button>
|
||||
))
|
||||
@@ -2329,9 +2320,9 @@ function BulkSelectionBar({
|
||||
setPopoverOpen(false);
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2353,7 +2344,7 @@ function BulkSelectionBar({
|
||||
}}
|
||||
className={BAR_BUTTON_CLASS}
|
||||
>
|
||||
<TagsIcon className="text-xs" />
|
||||
<TagsIcon className="text-xs" />
|
||||
<span className="hidden sm:inline">Add tags</span>
|
||||
</button>
|
||||
{tagsOpen && (
|
||||
@@ -2362,8 +2353,8 @@ function BulkSelectionBar({
|
||||
className="fixed inset-0 z-[59]"
|
||||
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="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
||||
<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 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
|
||||
Add tags
|
||||
</div>
|
||||
<TagChipInput
|
||||
@@ -2380,7 +2371,7 @@ function BulkSelectionBar({
|
||||
disabled={tagChips.length === 0}
|
||||
loading={addingTags}
|
||||
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"}
|
||||
</Button>
|
||||
@@ -2407,16 +2398,16 @@ function BulkSelectionBar({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
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 />
|
||||
</button>
|
||||
@@ -2438,7 +2429,7 @@ function BulkThumb({ item }: { item: GalleryItem }) {
|
||||
: ImageIcon;
|
||||
const showImage = !!item.thumbnail && !failed;
|
||||
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 ? (
|
||||
<img
|
||||
src={item.thumbnail!}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Input } from "@storyteller/ui-input";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
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 { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||
import { refreshSession } from "../../lib/session";
|
||||
@@ -76,7 +80,7 @@ const Login = () => {
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -93,7 +97,7 @@ const Login = () => {
|
||||
className="space-y-4"
|
||||
>
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -101,12 +105,12 @@ const Login = () => {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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 className="space-y-2">
|
||||
<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
|
||||
</label>
|
||||
<Link
|
||||
@@ -122,7 +126,7 @@ const Login = () => {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
@@ -137,12 +141,12 @@ const Login = () => {
|
||||
|
||||
<Reveal className="pt-2">
|
||||
<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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
@@ -157,9 +161,9 @@ const Login = () => {
|
||||
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
|
||||
<Reveal className="relative my-6 flex items-center justify-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>
|
||||
<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
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
@@ -129,9 +129,9 @@ export default function MediaPage() {
|
||||
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="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 */}
|
||||
<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 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingSpinner className="h-12 w-12 text-white/60" />
|
||||
|
||||
@@ -285,30 +285,25 @@ const Onboarding = () => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] 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="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-white/20 border-t-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "complete") {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white">
|
||||
<div className="relative min-h-screen bg-ui-background text-white">
|
||||
<Seo
|
||||
title="Setup Complete - ArtCraft"
|
||||
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)]">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-6">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<CircleCheckIcon
|
||||
|
||||
className="text-5xl text-green-400" />
|
||||
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
|
||||
<CircleCheckIcon className="text-5xl text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-medium mb-4 text-white">
|
||||
@@ -322,28 +317,20 @@ const Onboarding = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white">
|
||||
<div className="relative min-h-screen bg-ui-background text-white">
|
||||
<Seo
|
||||
title="Complete Your Setup - 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)]">
|
||||
<div className="max-w-md w-full">
|
||||
{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">
|
||||
<InfoIcon
|
||||
|
||||
className="text-blue-400 mt-1 flex-shrink-0 text-lg" />
|
||||
<div className="bg-white/5 border border-white/15 p-4 mb-6 flex items-start gap-3 animate-fade-in">
|
||||
<InfoIcon className="text-white/70 mt-1 flex-shrink-0 text-lg" />
|
||||
<div>
|
||||
<h3 className="text-blue-200 font-medium mb-1">
|
||||
Account Created
|
||||
</h3>
|
||||
<p className="text-blue-300/80 text-sm leading-relaxed">
|
||||
<h3 className="text-white font-medium mb-1">Account Created</h3>
|
||||
<p className="text-white/60 text-sm leading-relaxed">
|
||||
We've automatically created an account for you. Please
|
||||
complete your setup below to secure it.
|
||||
</p>
|
||||
@@ -351,7 +338,7 @@ const Onboarding = () => {
|
||||
</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">
|
||||
<h1 className="text-2xl font-semibold mb-2">
|
||||
{currentStep === "password" &&
|
||||
@@ -376,7 +363,7 @@ const Onboarding = () => {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -384,7 +371,7 @@ const Onboarding = () => {
|
||||
{currentStep === "password" && (
|
||||
<>
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -393,13 +380,13 @@ const Onboarding = () => {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -408,7 +395,7 @@ const Onboarding = () => {
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
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"
|
||||
minLength={8}
|
||||
/>
|
||||
@@ -418,7 +405,7 @@ const Onboarding = () => {
|
||||
|
||||
{currentStep === "email" && (
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -427,7 +414,7 @@ const Onboarding = () => {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -435,7 +422,7 @@ const Onboarding = () => {
|
||||
|
||||
{currentStep === "username" && (
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
@@ -443,7 +430,7 @@ const Onboarding = () => {
|
||||
id="username"
|
||||
value={username}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -452,13 +439,11 @@ const Onboarding = () => {
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
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}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
@@ -467,7 +452,7 @@ const Onboarding = () => {
|
||||
<Button
|
||||
type="button"
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
// - start from a blank canvas → BlankCanvasModal (from the lib)
|
||||
|
||||
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 { UploaderStates } from "@storyteller/common";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import {
|
||||
GalleryModal,
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
BlankCanvasModal,
|
||||
type BaseSelectorImage,
|
||||
@@ -141,10 +143,10 @@ export function BaseImageSelector({
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
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
|
||||
? "border-primary-400 bg-primary/10"
|
||||
: "border-ui-panel-border bg-ui-background hover:border-primary-400/50"
|
||||
? "border-white bg-white/10"
|
||||
: "border-white/15 bg-ui-background hover:border-white/40"
|
||||
} ${busy ? "pointer-events-none opacity-60" : ""}`}
|
||||
>
|
||||
<input
|
||||
@@ -154,7 +156,7 @@ export function BaseImageSelector({
|
||||
onChange={onFileInput}
|
||||
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
|
||||
icon={busy ? LoaderCircleIcon : PencilIcon}
|
||||
className={`text-xl ${busy ? "animate-spin" : ""}`}
|
||||
@@ -174,7 +176,7 @@ export function BaseImageSelector({
|
||||
setIsGalleryOpen(true);
|
||||
}}
|
||||
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 />
|
||||
Pick from Library
|
||||
@@ -186,7 +188,7 @@ export function BaseImageSelector({
|
||||
setIsBlankCanvasOpen(true);
|
||||
}}
|
||||
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 />
|
||||
Blank Canvas
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function PageDraw() {
|
||||
transform: "translateZ(0)",
|
||||
// Recolors the lib's `.pegboard-bg` canvas backdrop (the surface
|
||||
// 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
|
||||
// present, the usable canvas area's center sits above the geometric
|
||||
// center. Pull the side panels (toolbar + history) up by half the
|
||||
@@ -39,7 +39,7 @@ export default function PageDraw() {
|
||||
>
|
||||
<PageDrawLib
|
||||
adapter={adapter}
|
||||
backgroundClassName="bg-[#101014]"
|
||||
backgroundClassName="bg-ui-background"
|
||||
fillParentHeight={!isMobile}
|
||||
// Cost/help live in the webapp's own chrome, not over the canvas.
|
||||
showBottomRightControls={false}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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 { MediaFilesApi, PromptsApi } from "@storyteller/api";
|
||||
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
@@ -154,7 +161,7 @@ export function DemoOutputOverlay({
|
||||
|
||||
if (isExpanded) {
|
||||
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
|
||||
type="button"
|
||||
aria-label="Close expanded view"
|
||||
@@ -209,9 +216,9 @@ function ShowOutputPill({ onClick }: { onClick: () => void }) {
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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
|
||||
</button>
|
||||
);
|
||||
@@ -223,9 +230,9 @@ interface ViewToggleProps {
|
||||
}
|
||||
|
||||
// Single-button sliding toggle. The whole pill is the click target —
|
||||
// clicking anywhere on it flips between video and image. A primary-
|
||||
// coloured indicator translates between the two icons to show which
|
||||
// side is currently active.
|
||||
// clicking anywhere on it flips between video and image. A white
|
||||
// indicator translates between the two icons to show which side is
|
||||
// currently active.
|
||||
function ViewToggle({ view, onChange }: ViewToggleProps) {
|
||||
const next: View = view === "video" ? "image" : "video";
|
||||
return (
|
||||
@@ -233,11 +240,11 @@ function ViewToggle({ view, onChange }: ViewToggleProps) {
|
||||
type="button"
|
||||
aria-label={view === "video" ? "Switch to image" : "Switch to video"}
|
||||
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
|
||||
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={{
|
||||
transform: view === "image" ? "translateX(100%)" : "translateX(0%)",
|
||||
}}
|
||||
@@ -258,7 +265,7 @@ function ToggleIcon({ icon, active }: ToggleIconProps) {
|
||||
<span
|
||||
className={
|
||||
"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" />
|
||||
@@ -297,14 +304,12 @@ function Card({
|
||||
onToggleExpanded();
|
||||
};
|
||||
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 min-w-0 items-center gap-2">
|
||||
<WandSparklesIcon
|
||||
|
||||
className="h-3 w-3 shrink-0 text-primary" />
|
||||
<WandSparklesIcon className="h-3 w-3 shrink-0 text-primary" />
|
||||
<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
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-base-fg/50">
|
||||
@@ -322,9 +327,9 @@ function Card({
|
||||
? "Collapse to picture-in-picture"
|
||||
: "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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,8 +344,9 @@ function Card({
|
||||
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}
|
||||
<button
|
||||
@@ -349,8 +355,10 @@ function Card({
|
||||
e.stopPropagation();
|
||||
onToggleExpanded();
|
||||
}}
|
||||
aria-label={isExpanded ? "Collapse output view" : "Expand output view"}
|
||||
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"
|
||||
aria-label={
|
||||
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
|
||||
icon={isExpanded ? MinimizeIcon : MaximizeIcon}
|
||||
|
||||
@@ -63,9 +63,9 @@ function CollapsedPill({ onClick }: { onClick: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
@@ -79,13 +79,11 @@ interface ExpandedPanelProps {
|
||||
|
||||
function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
|
||||
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 gap-2">
|
||||
<LayersIcon
|
||||
|
||||
className="h-3 w-3 text-primary" />
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
|
||||
<LayersIcon className="h-3 w-3 text-primary" />
|
||||
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
|
||||
Other demo scenes
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,9 +91,9 @@ function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 p-3 sm:grid-cols-3">
|
||||
|
||||
@@ -386,7 +386,7 @@ function MobileGate() {
|
||||
return (
|
||||
<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 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" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// One reusable card for the edit-3D splash. The blank-scene variant
|
||||
// renders a dashed plus tile; the example variant renders a thumbnail
|
||||
// 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
|
||||
// consistent across the grid.
|
||||
|
||||
@@ -29,7 +29,7 @@ interface ExampleCardProps {
|
||||
type SceneSplashCardProps = BlankCardProps | ExampleCardProps;
|
||||
|
||||
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) {
|
||||
return (
|
||||
@@ -53,8 +53,8 @@ function CardThumbnail(props: SceneSplashCardProps) {
|
||||
if (props.variant === "blank") {
|
||||
return (
|
||||
<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">
|
||||
<PlusIcon className="text-sm" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -78,10 +78,7 @@ function ExampleThumbnail({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative aspect-video overflow-hidden bg-gradient-to-br",
|
||||
accentClass,
|
||||
)}
|
||||
className={twMerge("relative aspect-video overflow-hidden", accentClass)}
|
||||
>
|
||||
{previewUrl && (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -125,7 +122,7 @@ function CardCaption({
|
||||
// - previewUrl: the rendered output image (the "after"), shown by default
|
||||
// - hoverUrl: the prompt's first reference image (the "before"), shown
|
||||
// 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): {
|
||||
previewUrl: string | null;
|
||||
hoverUrl: string | null;
|
||||
@@ -157,7 +154,7 @@ function useSceneCardImages(outputToken: string): {
|
||||
promptResp?.data?.maybe_context_images?.[0]?.media_links?.cdn_url;
|
||||
if (firstRef) setHoverUrl(firstRef);
|
||||
} 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
|
||||
// SignupCtaModal (bg-[#161618], border-white/10, off-center primary
|
||||
// glow, font-display heading) so the editor's onboarding modal feels
|
||||
// SignupCtaModal (flat near-black surface, hairline border,
|
||||
// font-display heading) so the editor's onboarding modal feels
|
||||
// like the rest of the webapp, not a one-off.
|
||||
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
@@ -22,7 +22,7 @@ export function SceneSplashModal({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
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}
|
||||
backdropClassName="!bg-black/80"
|
||||
closeOnOutsideClick
|
||||
@@ -32,11 +32,7 @@ export function SceneSplashModal({
|
||||
<div className="relative overflow-hidden">
|
||||
<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"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/15"
|
||||
/>
|
||||
|
||||
<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;
|
||||
title: 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.
|
||||
accentClass: string;
|
||||
sceneToken: string;
|
||||
@@ -27,7 +27,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
|
||||
id: "lone-drifter",
|
||||
title: "Lone Drifter",
|
||||
description: "Sundown standoff, dust on the wind",
|
||||
accentClass: "from-orange-500/30 to-amber-700/20",
|
||||
accentClass: "bg-orange-500/15",
|
||||
sceneToken: "m_ywnjq1bdjw2163p456tczbg5p7ean8",
|
||||
outputToken: "m_9n2w5c4teefm21nhsceex3degeca9h",
|
||||
},
|
||||
@@ -35,7 +35,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
|
||||
id: "black-sails",
|
||||
title: "Black Sails",
|
||||
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,
|
||||
outputToken: PLACEHOLDER_OUTPUT_TOKEN,
|
||||
},
|
||||
@@ -43,7 +43,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
|
||||
id: "cold-brew-hero",
|
||||
title: "Cold Brew",
|
||||
description: "Studio bottle shot, soft rim light",
|
||||
accentClass: "from-amber-900/30 to-stone-700/20",
|
||||
accentClass: "bg-amber-500/15",
|
||||
sceneToken: "m_c8earcb2pnhb4n6pnbcfn01hg9vn9w",
|
||||
outputToken: "m_a5y9cvskk8xe2y4j3hjskrha86x65q",
|
||||
},
|
||||
|
||||
@@ -14,10 +14,10 @@ import { Reveal } from "../../components/motion/reveal";
|
||||
const SeedanceBanner = () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<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
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@ const SeedanceBanner = () => (
|
||||
</p>
|
||||
</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">
|
||||
<iframe
|
||||
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 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="text-[12px] font-semibold uppercase tracking-wider text-primary mb-1">
|
||||
<div className="bg-[#101014] border border-white/30 p-4">
|
||||
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white mb-1">
|
||||
Seedance video credits
|
||||
</div>
|
||||
<div className="text-white/55 text-sm leading-snug">
|
||||
Included with every paid ArtCraft plan
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#080808] border border-white/[0.08] p-4">
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-white/70 mb-1">
|
||||
<div className="bg-[#101014] border border-white/15 p-4">
|
||||
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 mb-1">
|
||||
First in the world
|
||||
</div>
|
||||
<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>
|
||||
<Button
|
||||
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}
|
||||
>
|
||||
<CoinsIcon className="text-primary text-[13px]" />
|
||||
<CoinsIcon className="text-[13px]" />
|
||||
Buy more credits
|
||||
</Button>
|
||||
</div>
|
||||
@@ -113,25 +113,12 @@ const Pricing = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full bg-[#101014] text-white">
|
||||
<div className="relative min-h-full bg-ui-background text-white">
|
||||
<Seo
|
||||
title="Pricing - ArtCraft"
|
||||
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">
|
||||
{isSeedanceRef ? (
|
||||
<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">
|
||||
<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">
|
||||
Invest in <span className="font-serif-italic">yourself</span>.
|
||||
</h1>
|
||||
@@ -187,12 +179,10 @@ const Pricing = () => {
|
||||
</p>
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
<CoinsIcon
|
||||
|
||||
className="text-primary text-[13px]" />
|
||||
<CoinsIcon className="text-[13px]" />
|
||||
Buy Credits
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function Referrals() {
|
||||
|
||||
if (!authChecked) {
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
@@ -133,25 +133,16 @@ export default function Referrals() {
|
||||
const profileLink = `${SHARE_ORIGIN}/?u=${encodeURIComponent(user.username)}`;
|
||||
|
||||
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
|
||||
title="Referrals - 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">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<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
|
||||
</span>
|
||||
<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="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
|
||||
</span>
|
||||
<div className="h-px w-10 bg-white/20" />
|
||||
</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>
|
||||
<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
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
) : 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}
|
||||
</div>
|
||||
) : codes.length === 0 ? (
|
||||
@@ -222,7 +213,7 @@ export default function Referrals() {
|
||||
<form onSubmit={handleCreate} className="mt-6 space-y-2">
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
@@ -240,14 +231,14 @@ export default function Referrals() {
|
||||
}
|
||||
disabled={atLimit || creating}
|
||||
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
|
||||
type="submit"
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,9 +266,9 @@ export default function Referrals() {
|
||||
|
||||
function ProfileLinkPanel({ profileLink }: { profileLink: string }) {
|
||||
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="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
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-white">
|
||||
@@ -316,13 +307,13 @@ function ReferralCodeRow({
|
||||
});
|
||||
|
||||
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-1 min-w-0">
|
||||
<div className="text-[15px] font-medium text-white truncate">
|
||||
{entry.code}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,7 +323,7 @@ function ReferralCodeRow({
|
||||
type="button"
|
||||
onClick={onCancelDelete}
|
||||
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
|
||||
</button>
|
||||
@@ -340,7 +331,7 @@ function ReferralCodeRow({
|
||||
type="button"
|
||||
onClick={onConfirmDelete}
|
||||
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"}
|
||||
</button>
|
||||
@@ -349,10 +340,10 @@ function ReferralCodeRow({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Trash2Icon className="text-[13px]" />
|
||||
<Trash2Icon className="text-[13px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,12 +372,12 @@ function CopyableLink({ value }: { value: string }) {
|
||||
value={value}
|
||||
readOnly
|
||||
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
|
||||
type="button"
|
||||
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
|
||||
icon={copied ? CheckIcon : CopyIcon}
|
||||
|
||||
@@ -25,10 +25,8 @@ const SetPassword = () => {
|
||||
// a password) from being misrouted here.
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#101014]">
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin text-4xl text-primary/80" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-ui-background">
|
||||
<LoaderCircleIcon className="animate-spin text-4xl text-white/80" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,22 +66,14 @@ const SetPassword = () => {
|
||||
};
|
||||
|
||||
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
|
||||
title="Set Password - ArtCraft"
|
||||
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">
|
||||
<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">
|
||||
<img
|
||||
src="/images/artcraft-icon.png"
|
||||
@@ -105,13 +95,13 @@ const SetPassword = () => {
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -121,7 +111,7 @@ const SetPassword = () => {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
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
|
||||
type="button"
|
||||
@@ -135,7 +125,7 @@ const SetPassword = () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -144,7 +134,7 @@ const SetPassword = () => {
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
@@ -161,14 +151,12 @@ const SetPassword = () => {
|
||||
|
||||
<div className="pt-2">
|
||||
<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"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin" />
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
"Set password"
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ const Support = () => {
|
||||
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.";
|
||||
|
||||
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} />
|
||||
|
||||
<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>
|
||||
</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) => (
|
||||
<li key={row.title}>
|
||||
<SupportLink row={row} />
|
||||
@@ -72,13 +72,13 @@ const Support = () => {
|
||||
|
||||
const SupportLink = ({ row }: { row: SupportRow }) => {
|
||||
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 = (
|
||||
<>
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-md border transition-colors ${
|
||||
row.iconBgClass ?? "bg-white/[0.04] border-white/[0.06]"
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center border transition-colors ${
|
||||
row.iconBgClass ?? "bg-white/[0.04] border-white/15"
|
||||
} ${row.iconColorClass ?? "text-white/70 group-hover:text-white"}`}
|
||||
>
|
||||
<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
|
||||
// video projects plus a "New project" tile. Styled to match the create
|
||||
// pages and library: #101014 canvas, glow orbs, eyebrow + display heading,
|
||||
// ui-controls cards with primary hover rings, kebab menus, and the app's
|
||||
// pages and library: near-black canvas, eyebrow + display heading,
|
||||
// ui-controls cards with hairline hover rings, kebab menus, and the app's
|
||||
// standard confirm dialog.
|
||||
|
||||
const GRID_CLASSES =
|
||||
@@ -128,14 +128,7 @@ export function VideoEditorProjectsLanding({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto bg-[#101014] 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>
|
||||
|
||||
<div className="relative h-full w-full overflow-y-auto bg-ui-background text-white">
|
||||
{/* The global topbar is hidden on /video-editor routes; surface its
|
||||
actions here like the editor header does. */}
|
||||
<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">
|
||||
<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
|
||||
</p>
|
||||
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
|
||||
@@ -193,7 +186,7 @@ export function VideoEditorProjectsLanding({
|
||||
variant="primary"
|
||||
icon={SparklesIcon}
|
||||
onClick={onNewProject}
|
||||
className="mt-8 h-12 rounded-full px-6 text-base font-semibold"
|
||||
className="mt-8 h-12 px-6"
|
||||
>
|
||||
New project
|
||||
</Button>
|
||||
@@ -202,9 +195,7 @@ export function VideoEditorProjectsLanding({
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
|
||||
<TriangleAlertIcon
|
||||
|
||||
className="text-3xl text-white/30" />
|
||||
<TriangleAlertIcon className="text-3xl text-white/30" />
|
||||
<div>
|
||||
<div className="text-lg font-medium">
|
||||
Couldn't load your projects
|
||||
|
||||
+34
-33
@@ -1,5 +1,11 @@
|
||||
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 { PopoverMenu } from "@storyteller/ui-popover";
|
||||
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.
|
||||
// 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.
|
||||
|
||||
// Deterministic per-project placeholder art so thumbnail-less cards don't
|
||||
// all look identical (blue/teal/purple/pink families, matching the
|
||||
// create-page glow orbs).
|
||||
const PLACEHOLDER_GRADIENTS = [
|
||||
"from-blue-600/30 via-blue-500/15 to-[#00AABA]/20",
|
||||
"from-purple-600/30 via-blue-500/15 to-pink-500/20",
|
||||
"from-[#00AABA]/30 via-blue-600/15 to-purple-500/20",
|
||||
"from-pink-500/25 via-purple-500/15 to-blue-600/20",
|
||||
"from-indigo-500/30 via-blue-500/15 to-teal-500/20",
|
||||
"from-sky-500/25 via-indigo-500/15 to-purple-600/20",
|
||||
// all look identical (flat blue/teal/purple/pink tints).
|
||||
const PLACEHOLDER_TINTS = [
|
||||
"bg-blue-600/20",
|
||||
"bg-purple-600/20",
|
||||
"bg-[#00AABA]/20",
|
||||
"bg-pink-500/15",
|
||||
"bg-indigo-500/20",
|
||||
"bg-sky-500/15",
|
||||
];
|
||||
|
||||
const STAGGER_STEP_MS = 30;
|
||||
@@ -48,7 +53,7 @@ export function VideoProjectCard({
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
style={{
|
||||
@@ -73,17 +78,15 @@ export function VideoProjectCard({
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-full w-full items-center justify-center bg-gradient-to-br",
|
||||
gradientForProject(project.id),
|
||||
"flex h-full w-full items-center justify-center",
|
||||
tintForProject(project.id),
|
||||
)}
|
||||
>
|
||||
<ClapperboardIcon
|
||||
|
||||
className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
|
||||
<ClapperboardIcon className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
|
||||
</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">
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,10 +109,8 @@ export function VideoProjectCard({
|
||||
position="bottom"
|
||||
align="end"
|
||||
mode="default"
|
||||
triggerIcon={
|
||||
<EllipsisIcon className="text-base-fg" />
|
||||
}
|
||||
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"
|
||||
triggerIcon={<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"
|
||||
panelClassName="w-max min-w-44 p-1"
|
||||
closeOnUnhover
|
||||
>
|
||||
@@ -117,26 +118,26 @@ export function VideoProjectCard({
|
||||
<div className="flex flex-col">
|
||||
<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) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
onRename();
|
||||
}}
|
||||
>
|
||||
<PenIcon className="w-3.5" />
|
||||
<PenIcon className="w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
<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) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="w-3.5" />
|
||||
<Trash2Icon className="w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -149,11 +150,11 @@ export function VideoProjectCard({
|
||||
|
||||
export function VideoProjectCardSkeleton() {
|
||||
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="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 w-1/3 rounded bg-white/[0.05]" />
|
||||
<div className="animate-shimmer h-3.5 w-2/3 bg-white/[0.06]" />
|
||||
<div className="animate-shimmer h-3 w-1/3 bg-white/[0.05]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -164,9 +165,9 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
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 />
|
||||
</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;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
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 (
|
||||
<VideoEditorProjectsLanding
|
||||
projectStorage={projectStorage}
|
||||
onNewProject={() =>
|
||||
navigate(`/video-editor/${makeLocalProjectId()}`)
|
||||
}
|
||||
onNewProject={() => navigate(`/video-editor/${makeLocalProjectId()}`)}
|
||||
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
|
||||
/>
|
||||
);
|
||||
@@ -215,7 +213,7 @@ function VideoEditorSession({
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</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 { Button } from "@storyteller/ui-button";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -11,25 +20,17 @@ const Welcome = () => {
|
||||
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full bg-[#101014] text-white">
|
||||
<div className="relative min-h-full bg-ui-background text-white">
|
||||
<Seo
|
||||
title="Welcome - ArtCraft"
|
||||
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">
|
||||
{/* Success Header */}
|
||||
<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">
|
||||
<CircleCheckIcon
|
||||
|
||||
className="text-xl text-primary" />
|
||||
<div className="inline-flex items-center gap-3 bg-white/5 border border-white/15 px-6 py-3 mb-6">
|
||||
<CircleCheckIcon className="text-xl text-green-400" />
|
||||
<span className="text-white font-medium">
|
||||
Account created successfully!
|
||||
</span>
|
||||
@@ -56,12 +57,10 @@ const Welcome = () => {
|
||||
|
||||
{/* Create-in-browser CTA — desktop download is optional */}
|
||||
<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="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<WandSparklesIcon
|
||||
|
||||
className="text-primary text-lg" />
|
||||
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
|
||||
<WandSparklesIcon className="text-white/70 text-lg" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-medium text-white">
|
||||
Start creating right here
|
||||
@@ -84,18 +83,16 @@ const Welcome = () => {
|
||||
<Link
|
||||
key={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
|
||||
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">
|
||||
{item.label}
|
||||
</span>
|
||||
<ArrowRightIcon
|
||||
|
||||
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
||||
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -104,12 +101,10 @@ const Welcome = () => {
|
||||
|
||||
{/* Desktop app alternative */}
|
||||
<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="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<MonitorIcon
|
||||
|
||||
className="text-primary text-lg" />
|
||||
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
|
||||
<MonitorIcon className="text-white/70 text-lg" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-medium text-white">
|
||||
Want the desktop app too?
|
||||
@@ -130,9 +125,9 @@ const Welcome = () => {
|
||||
].map((item) => (
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
<div className="text-white/90 font-medium">{item.text}</div>
|
||||
@@ -142,7 +137,7 @@ const Welcome = () => {
|
||||
|
||||
{/* Download Contingency */}
|
||||
{!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">
|
||||
<span className="text-white/50 text-sm">
|
||||
Download didn't start?
|
||||
@@ -151,9 +146,9 @@ const Welcome = () => {
|
||||
<Button
|
||||
as="link"
|
||||
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"}
|
||||
</Button>
|
||||
<div className="flex gap-4 text-sm font-medium text-white/30">
|
||||
@@ -161,14 +156,14 @@ const Welcome = () => {
|
||||
href={DOWNLOAD_LINKS.WINDOWS}
|
||||
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
|
||||
</a>
|
||||
<a
|
||||
href={DOWNLOAD_LINKS.MACOS}
|
||||
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
|
||||
</a>
|
||||
</div>
|
||||
@@ -178,9 +173,9 @@ const Welcome = () => {
|
||||
)}
|
||||
|
||||
{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">
|
||||
<MonitorIcon className="text-2xl" />
|
||||
<MonitorIcon className="text-2xl" />
|
||||
</div>
|
||||
ArtCraft is a powerful desktop experience. <br />
|
||||
Please head to your computer to download and install.
|
||||
|
||||
@@ -5,36 +5,37 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* Sidebar tokens (consumed by tailwind colors in tailwind.config.js).
|
||||
Stored as raw hex so we can reference them via theme() if needed. */
|
||||
--sidebar-bg: #1a1a1a;
|
||||
Brutalist system: near-black surfaces, white/15 hairlines, white
|
||||
accent for selection/focus. */
|
||||
--sidebar-bg: #1e1f22;
|
||||
--sidebar-fg: rgba(255, 255, 255, 0.72);
|
||||
--sidebar-border: rgba(64, 64, 64, 0.5);
|
||||
--sidebar-accent: rgba(255, 255, 255, 0.06);
|
||||
--sidebar-border: rgba(255, 255, 255, 0.15);
|
||||
--sidebar-accent: rgba(255, 255, 255, 0.08);
|
||||
--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.
|
||||
Dark-theme palette (the editor only runs in dark mode today).
|
||||
--primary matches the webapp's brand blue (#2d81ff). */
|
||||
--background: 0 0% 9%;
|
||||
--background: 240 5% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 12%;
|
||||
--card: 240 11% 7%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover: 240 4% 5%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 216 100% 59%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 24%;
|
||||
--secondary: 240 4% 9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 18%;
|
||||
--muted: 240 4% 9%;
|
||||
--muted-foreground: 0 0% 64%;
|
||||
--accent: 0 0% 16%;
|
||||
--accent: 240 4% 9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 70% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 18%;
|
||||
--input: 0 0% 18%;
|
||||
--ring: 217 91% 60%;
|
||||
--border: 0 0% 100% / 0.15;
|
||||
--input: 0 0% 100% / 0.15;
|
||||
--ring: 0 0% 100% / 0.6;
|
||||
|
||||
/* ── Motion tokens ──────────────────────────────────────────────────
|
||||
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-color: #363636 #171717;
|
||||
scrollbar-color: #2c2c2f #0b0b0c;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@@ -81,13 +98,12 @@
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #171717;
|
||||
background: #0b0b0c;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #363636;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #171717;
|
||||
background-color: #2c2c2f;
|
||||
border: 2px solid #0b0b0c;
|
||||
}
|
||||
|
||||
.scrollbar-hidden {
|
||||
@@ -100,6 +116,9 @@
|
||||
}
|
||||
|
||||
@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,
|
||||
h2,
|
||||
h3,
|
||||
@@ -107,7 +126,7 @@
|
||||
h5,
|
||||
h6 {
|
||||
font-family:
|
||||
"Outfit",
|
||||
"Archivo",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
@@ -116,12 +135,19 @@
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
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 {
|
||||
font-family:
|
||||
"Source Sans 3",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
@@ -131,11 +157,21 @@
|
||||
Arial,
|
||||
sans-serif;
|
||||
color: #ffffff;
|
||||
background-color: #121212;
|
||||
background-color: #0b0b0c;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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-family: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||||
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) */
|
||||
.bg-ui-badge {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
@@ -153,16 +198,11 @@
|
||||
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 {
|
||||
background:
|
||||
radial-gradient(
|
||||
60.68% 60.68% at 50% 0%,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(0, 0, 0, 0) 70%
|
||||
),
|
||||
#1f1f1f;
|
||||
background-color: #1f1f1f;
|
||||
background: #101014;
|
||||
background-color: #101014;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.glass {
|
||||
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
|
||||
@apply bg-[#303032]/90 !important;
|
||||
@apply relative border border-white/15 transition-colors duration-300 rounded-[3px] backdrop-blur-sm;
|
||||
@apply bg-[#1E1F22]/95 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.liquid-glass {
|
||||
background: #232323c0;
|
||||
border: 1px solid #ffffff14;
|
||||
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);
|
||||
background: rgba(11, 11, 12, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Minimal thin scrollbar for prompt textareas */
|
||||
@@ -264,7 +303,6 @@
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
@@ -412,56 +450,6 @@
|
||||
@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
|
||||
tailwindcss-animate plugin, which isn't enabled here, so we drive the
|
||||
slide ourselves off Radix's data-state. Defining a closed-state animation
|
||||
|
||||
@@ -18,8 +18,26 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: [
|
||||
"Geist Mono",
|
||||
"ui-monospace",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Consolas",
|
||||
"monospace",
|
||||
],
|
||||
display: [
|
||||
"Outfit",
|
||||
"Archivo",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
sans: [
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
@@ -159,16 +177,20 @@ module.exports = {
|
||||
ring: "var(--sidebar-ring)",
|
||||
},
|
||||
ui: {
|
||||
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
|
||||
background: "#242424",
|
||||
panel: "#1F1F1F",
|
||||
"panel-border": "#272727",
|
||||
border: "#3f3f3f",
|
||||
controls: "#3e3e41",
|
||||
"controls-button": "#3E3E41",
|
||||
"controls-border": "#3f3f3f",
|
||||
divider: "#272727",
|
||||
modal: "#1F1F1F",
|
||||
// Brutalist system: near-black surfaces with white/15 hairline
|
||||
// borders, aligned with artcraft-website + artcraft-website-next.
|
||||
background: "#121316",
|
||||
panel: "#0b0b0c",
|
||||
"panel-border": "rgb(255 255 255 / 0.15)",
|
||||
border: "rgb(255 255 255 / 0.15)",
|
||||
// Raised control surface — a step above the #0b0b0c page/panel bg
|
||||
// (matches the sidebar's #1e1f22) so buttons and inputs read as
|
||||
// surfaces instead of blending into the background.
|
||||
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-y": "#308752",
|
||||
|
||||
@@ -13,8 +13,8 @@ the whole box with `–`, or expand/collapse individual sections.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Live**: physics/lighting/material values are read every frame, so slider
|
||||
changes apply instantly. Formation values (anything that changes the ball
|
||||
- **Live**: motion/look values are read every frame, so slider changes
|
||||
apply instantly. Layout values (anything that changes the wall's panel
|
||||
arrangement itself) trigger a debounced rebuild (~250 ms after the last
|
||||
change).
|
||||
- **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.
|
||||
- **Build-time consumers** (values baked into geometry/sampling): read at
|
||||
build time and subscribe to `useTunerStore` to trigger a debounced rebuild
|
||||
when the group's values change (see the Formation wiring in
|
||||
`hero-wordmark.tsx`).
|
||||
when the group's values change (see the Wall layout wiring in
|
||||
`hero-wall.tsx`).
|
||||
|
||||
The panel picks up new groups automatically — no panel changes needed.
|
||||
Registered groups: Formation, Physics, Cursor & Light, Material, Halo (all
|
||||
for the hero wordmark; see `src/components/landing/hero-tunables.ts`).
|
||||
Registered groups: Wall layout, Wall motion, Wall look (all for the hero
|
||||
render wall; see `src/components/landing/hero-tunables.ts`).
|
||||
|
||||
## Shipping tuned values
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
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;
|
||||
|
||||
+1
-9
@@ -1114,7 +1114,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
||||
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/webxr": "*",
|
||||
@@ -1497,7 +1496,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1508,7 +1506,6 @@
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1533,7 +1530,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
@@ -2342,7 +2338,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
|
||||
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
|
||||
"license": "Zlib",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.168.0 < 0.186.0"
|
||||
}
|
||||
@@ -2368,7 +2363,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2378,7 +2372,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -2597,8 +2590,7 @@
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0b0b0c;
|
||||
--bg: #121316;
|
||||
--bg-raised: #101014;
|
||||
--bg-sunken: #060607;
|
||||
--ink: #f2f1ee;
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0b0b0c;
|
||||
--bg: #121316;
|
||||
--bg-raised: #101014;
|
||||
--bg-sunken: #060607;
|
||||
--ink: #f2f1ee;
|
||||
@@ -92,10 +92,11 @@
|
||||
--color-invert-fg: var(--invert-fg);
|
||||
--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-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. */
|
||||
--radius-*: initial;
|
||||
@@ -125,7 +126,9 @@ body {
|
||||
|
||||
/* Signature interaction: label blocks invert on hover/active. */
|
||||
.invert-block {
|
||||
transition: background-color 120ms linear, color 120ms linear;
|
||||
transition:
|
||||
background-color 120ms linear,
|
||||
color 120ms linear;
|
||||
}
|
||||
.invert-block:hover,
|
||||
.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. */
|
||||
.hud-label {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 SiteFooter from "@/components/site-footer";
|
||||
import MotionProvider from "@/components/motion-provider";
|
||||
@@ -8,9 +14,21 @@ import "./globals.css";
|
||||
|
||||
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"],
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -91,7 +109,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${bricolage.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
|
||||
className={`${archivo.variable} ${archivoBlack.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<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,
|
||||
// copy the JSON from the panel, then update the defaults.
|
||||
|
||||
import { defineTunables } from "@/lib/tuner";
|
||||
|
||||
export const formationTuner = defineTunables("formation", "Formation", {
|
||||
ballScale: { label: "Ball scale", min: 0.5, max: 2.5, step: 0.05, default: 1 },
|
||||
sizeMin: { label: "Size min ×", min: 0.05, max: 0.6, step: 0.01, default: 0.18 },
|
||||
sizeMax: { label: "Size max ×", min: 0.2, max: 1.4, step: 0.01, default: 0.66 },
|
||||
sizeNoiseScale: { label: "Size noise px", min: 30, max: 400, step: 5, default: 130 },
|
||||
sizeRoughen: { label: "Size roughen", min: 0, max: 1, step: 0.02, default: 0.4 },
|
||||
gap: { label: "Gap px", min: 0, max: 6, step: 0.1, default: 0.6 },
|
||||
depth: { label: "Depth ×", min: 0.2, max: 5, step: 0.1, default: 1.9 },
|
||||
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 },
|
||||
seed: { label: "Seed", min: 1, max: 9999, step: 1, default: 1013 },
|
||||
// Wall structure — moving these rebuilds the panel layout (debounced).
|
||||
export const wallLayoutTuner = defineTunables("wallLayout", "Wall layout", {
|
||||
rowHeight: {
|
||||
label: "Near row × vh",
|
||||
min: 0.14,
|
||||
max: 0.42,
|
||||
step: 0.01,
|
||||
default: 0.31,
|
||||
},
|
||||
rowScale: {
|
||||
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", {
|
||||
spring: { label: "Spring", min: 5, max: 140, step: 1, default: 46 },
|
||||
damping: { label: "Damping", min: 1, max: 14, step: 0.1, default: 5.2 },
|
||||
pushRadius: { label: "Push radius", min: 40, max: 450, step: 5, default: 180 },
|
||||
pushStrength: { label: "Push strength", min: 1000, max: 80000, step: 500, default: 23000 },
|
||||
swirl: { label: "Swirl", min: 0, max: 1.5, step: 0.05, default: 0.55 },
|
||||
popZ: { label: "Pop toward viewer", min: 0, max: 2, step: 0.05, default: 0.6 },
|
||||
bobAmp: { label: "Idle bob px", min: 0, max: 8, step: 0.1, default: 1.4 },
|
||||
scatter: { label: "Intro scatter px", min: 0, max: 1400, step: 20, default: 480 },
|
||||
export const wallMotionTuner = defineTunables("wallMotion", "Wall motion", {
|
||||
yawDeg: { label: "Yaw °", min: -35, max: 35, step: 0.5, default: -11 },
|
||||
pitchDeg: { label: "Pitch °", min: -12, max: 12, step: 0.25, default: 2.5 },
|
||||
speed: { label: "Drift px/s", min: 0, max: 240, step: 2, default: 42 },
|
||||
speedStep: {
|
||||
label: "Row parallax",
|
||||
min: 0.3,
|
||||
max: 1,
|
||||
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", {
|
||||
intensityDark: { label: "Cursor int (dark)", min: 0, max: 220000, step: 1000, default: 58000 },
|
||||
intensityLight: { label: "Cursor int (light)", min: 0, max: 220000, step: 1000, default: 30000 },
|
||||
idleDim: { label: "Idle dim", min: 0, max: 1, step: 0.05, default: 0.3 },
|
||||
lightZ: { label: "Light z", min: 30, max: 500, step: 5, default: 130 },
|
||||
lightRange: { label: "Light range", min: 100, max: 3000, step: 50, default: 900 },
|
||||
keyIntensity: { label: "Back key (dark)", min: 0, max: 8, step: 0.1, default: 2.2 },
|
||||
counterIntensity: { label: "Counter rim (dark)", min: 0, max: 4, step: 0.05, default: 0.7 },
|
||||
frontFill: { label: "Front fill (dark)", min: 0, max: 1, step: 0.01, default: 0.06 },
|
||||
ambientDark: { label: "Ambient (dark)", min: 0, max: 1, step: 0.01, default: 0.05 },
|
||||
ambientLight: { label: "Ambient (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
|
||||
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 },
|
||||
export const wallLookTuner = defineTunables("wallLook", "Wall look", {
|
||||
dimNear: { label: "Near row dim", min: 0, max: 1, step: 0.02, default: 0.86 },
|
||||
dimMid: { label: "Mid row dim", min: 0, max: 1, step: 0.02, default: 0.7 },
|
||||
washLight: { label: "Light wash", min: 0, max: 1, step: 0.02, default: 0.15 },
|
||||
edgeFade: { label: "Edge fade", min: 0, max: 1, step: 0.02, default: 0.65 },
|
||||
frameAlpha: {
|
||||
label: "Frame alpha",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.02,
|
||||
default: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
import {
|
||||
Component,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { Component, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
|
||||
import { useTunerStore } from "@/lib/tuner";
|
||||
import {
|
||||
sampleWordmark,
|
||||
WordmarkSim,
|
||||
type SampledWordmark,
|
||||
} from "./wordmark-sim";
|
||||
import {
|
||||
formationTuner,
|
||||
physicsTuner,
|
||||
lightTuner,
|
||||
materialTuner,
|
||||
haloTuner,
|
||||
} from "./hero-tunables";
|
||||
|
||||
const WORDMARK = "artcraft";
|
||||
const FONT_WEIGHT = 700;
|
||||
import HeroWall, { createWallDrag } from "./hero-wall";
|
||||
|
||||
type PointerState = {
|
||||
x: number;
|
||||
@@ -35,17 +12,20 @@ type PointerState = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
// The hero wordmark: "artcraft" built from small 3D balls. The cursor is both
|
||||
// a point light and a push force — the balls part around it, pop toward the
|
||||
// viewer, and rejoin on their springs. On mount they assemble from a
|
||||
// scattered cloud. Server HTML, reduced-motion visitors, and WebGL failures
|
||||
// all get the static display-type wordmark instead.
|
||||
// The hero: the wordmark set in the site's display type, floating over a
|
||||
// perspective render wall — two film-strip rows of Seedance takes forming
|
||||
// one yawed wall behind the word, drifting sideways at parallax speeds.
|
||||
// The type is real DOM text (crisp at any size, selectable by crawlers);
|
||||
// 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() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [colors, setColors] = useState<ThemeColors | null>(null);
|
||||
const [sample, setSample] = useState<SampledWordmark | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
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
|
||||
// a hidden tab can come up blank).
|
||||
@@ -68,134 +48,137 @@ export default function HeroWordmark() {
|
||||
return watchThemeColors(setColors);
|
||||
}, [ready]);
|
||||
|
||||
// Rasterize + sample once the display font is really loaded; re-sample when
|
||||
// the container width changes meaningfully (not on mobile URL-bar jitter)
|
||||
// or when a Formation tunable moves in the dev tuner.
|
||||
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).
|
||||
// Pointer in scene coordinates (origin at container center, y up), plus
|
||||
// drag bookkeeping for the wall: pressing anywhere grabs the wall and
|
||||
// throws it, while pointer movement tilts the whole perspective.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const dragLast = { x: 0, t: 0 };
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
pointerRef.current.x = e.clientX - rect.left - rect.width / 2;
|
||||
pointerRef.current.y = -(e.clientY - rect.top - rect.height / 2);
|
||||
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 = () => {
|
||||
pointerRef.current.active = false;
|
||||
wallDragRef.current.dragging = false;
|
||||
};
|
||||
container.addEventListener("pointermove", onMove);
|
||||
container.addEventListener("pointerdown", onMove);
|
||||
container.addEventListener("pointerdown", onDown);
|
||||
container.addEventListener("pointerup", onUp);
|
||||
container.addEventListener("pointerleave", onLeave);
|
||||
container.addEventListener("pointercancel", onLeave);
|
||||
return () => {
|
||||
container.removeEventListener("pointermove", onMove);
|
||||
container.removeEventListener("pointerdown", onMove);
|
||||
container.removeEventListener("pointerdown", onDown);
|
||||
container.removeEventListener("pointerup", onUp);
|
||||
container.removeEventListener("pointerleave", onLeave);
|
||||
container.removeEventListener("pointercancel", onLeave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const active = ready && colors !== null && sample !== null;
|
||||
const active = ready && colors !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
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
|
||||
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 && (
|
||||
{active && (
|
||||
<CanvasBoundary>
|
||||
<Canvas
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
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" }}
|
||||
>
|
||||
<FittedCamera />
|
||||
<BallField sample={sample} colors={colors} pointer={pointerRef} />
|
||||
<HeroWall pointer={pointerRef} drag={wallDragRef} colors={colors} />
|
||||
</Canvas>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const camera = useThree((s) => s.camera) as THREE.PerspectiveCamera;
|
||||
const size = useThree((s) => s.size);
|
||||
@@ -204,278 +187,13 @@ function FittedCamera() {
|
||||
const dist = size.height / 2 / Math.tan(THREE.MathUtils.degToRad(fov / 2));
|
||||
camera.fov = fov;
|
||||
camera.position.set(0, 0, dist);
|
||||
camera.near = Math.max(1, dist - 900);
|
||||
camera.far = dist + 900;
|
||||
camera.near = Math.max(1, dist - 1200);
|
||||
camera.far = dist + 1200;
|
||||
camera.updateProjectionMatrix();
|
||||
}, [camera, size.height]);
|
||||
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<
|
||||
{ children: ReactNode },
|
||||
{ failed: boolean }
|
||||
@@ -490,14 +208,3 @@ class CanvasBoundary extends Component<
|
||||
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() {
|
||||
return (
|
||||
<section className="relative">
|
||||
{/* Full-bleed ball-field wordmark — the brand as an object you can
|
||||
shove apart and watch reassemble. */}
|
||||
{/* Full-bleed hero — crisp display-type wordmark over the render
|
||||
wall: rows of Seedance takes forming one perspective wall that
|
||||
drifts behind the word and answers the cursor. */}
|
||||
<HeroWordmark />
|
||||
|
||||
<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">
|
||||
<p className="hud-label text-muted">Open-source AI studio</p>
|
||||
<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>
|
||||
</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";
|
||||
|
||||
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> = {
|
||||
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
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel"> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
@@ -86,10 +87,17 @@ export function Button({
|
||||
);
|
||||
|
||||
if (href) {
|
||||
const anchorProps = rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
const anchorProps =
|
||||
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
if (external || /^(https?:\/\/|mailto:)/.test(href)) {
|
||||
return (
|
||||
<a href={href} target={target} rel={rel} className={classes} {...anchorProps}>
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={classes}
|
||||
{...anchorProps}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -72,6 +72,30 @@ export const FEATURES: Feature[] = [
|
||||
export const HERO_VIDEO_URL =
|
||||
"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 = [
|
||||
"HDdsKJl92H4",
|
||||
"oqoCWdOwr2U",
|
||||
@@ -79,7 +103,7 @@ export const MADE_WITH_YOUTUBE_IDS = [
|
||||
];
|
||||
|
||||
export const TICKER_ITEMS = [
|
||||
"Seedance 2.0",
|
||||
"Seedance 2.5",
|
||||
"Nano Banana 2",
|
||||
"Image to Location",
|
||||
"3D Compositing",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user