diff --git a/web/eslint.config.js b/web/eslint.config.js index 84a81af..c95aa9b 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -24,12 +24,4 @@ export default defineConfig([ files: ['src/routes/**/*.{ts,tsx}'], rules: { 'react-refresh/only-export-components': 'off' }, }, - { - // Copied from beui.dev; kept as published. - files: ['src/components/motion/**/*.{ts,tsx}'], - rules: { - 'react-refresh/only-export-components': 'off', - 'react-hooks/set-state-in-effect': 'off', - }, - }, ]) diff --git a/web/package-lock.json b/web/package-lock.json index 8e9fd73..9d949c6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,13 +8,11 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", "@tanstack/react-query": "^5.104.0", "@tanstack/react-router": "^1.170.40", "clsx": "^2.1.1", "lucide-react": "^1.49.0", - "motion": "^13.4.6", "openapi-fetch": "^0.17.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -431,19 +429,10 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@fontsource-variable/geist": { + "node_modules/@fontsource-variable/inter": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", - "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/geist-mono": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", - "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -2567,29 +2556,6 @@ "dev": true, "license": "ISC" }, - "node_modules/framer-motion": { - "version": "13.4.6", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.6.tgz", - "integrity": "sha512-TwKWt2CC8rjhARzwV00TZIh04ipumSAqyKM/JZmS+urdncSHg6wTF/mfj/Fd6Oi2W01TZSWsiA3uxXDrXZtKyw==", - "license": "MIT", - "dependencies": { - "motion-dom": "^13.4.5", - "motion-utils": "^13.3.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3165,43 +3131,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/motion": { - "version": "13.4.6", - "resolved": "https://registry.npmjs.org/motion/-/motion-13.4.6.tgz", - "integrity": "sha512-zDXi6lIdyhda6wZlTsv9QgT2ed6UExAfRegtl/akQmZb5iaeLlArXr9Gmo/hx3iTx2y4R7tmdb9k9koFQmV+NQ==", - "license": "MIT", - "dependencies": { - "framer-motion": "^13.4.6", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/motion-dom": { - "version": "13.4.5", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.4.5.tgz", - "integrity": "sha512-p63p2Doq+US/O5wADauIPUVx0/eONHMLPzsudBM9XMWRuBQLXogdxH0vwIA8WYQZw864pyzoV723xoDqakwWTg==", - "license": "MIT", - "dependencies": { - "motion-utils": "^13.3.0" - } - }, - "node_modules/motion-utils": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz", - "integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3646,7 +3575,9 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", diff --git a/web/package.json b/web/package.json index daa2634..201ae0c 100644 --- a/web/package.json +++ b/web/package.json @@ -12,13 +12,11 @@ "generate:api": "openapi-typescript ../api/openapi.yaml -o src/api/schema.d.ts" }, "dependencies": { - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", "@tanstack/react-query": "^5.104.0", "@tanstack/react-router": "^1.170.40", "clsx": "^2.1.1", "lucide-react": "^1.49.0", - "motion": "^13.4.6", "openapi-fetch": "^0.17.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/web/src/components/auth/auth-card.tsx b/web/src/components/auth/auth-card.tsx deleted file mode 100644 index 6580635..0000000 --- a/web/src/components/auth/auth-card.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from "react"; - -type AuthCardProps = { - title: string; - description: string; - children: ReactNode; -}; - -export function AuthCard({ title, description, children }: AuthCardProps) { - return ( -
-
-

{title}

-

{description}

-
- - {children} -
- ); -} diff --git a/web/src/components/auth/auth-layout.tsx b/web/src/components/auth/auth-layout.tsx index 0422f85..3350d94 100644 --- a/web/src/components/auth/auth-layout.tsx +++ b/web/src/components/auth/auth-layout.tsx @@ -1,23 +1,22 @@ import type { ReactNode } from "react"; type AuthLayoutProps = { - prompt: ReactNode; + title: string; + footer: ReactNode; children: ReactNode; }; -export function AuthLayout({ prompt, children }: AuthLayoutProps) { +export function AuthLayout({ title, footer, children }: AuthLayoutProps) { return ( -
-
-

Naplata

-

- Invoicing and payments, without the busywork. -

-
+
+
+

Naplata

-
-

{prompt}

- {children} +
+

{title}

+ {children} +

{footer}

+
); diff --git a/web/src/components/motion/animated-toast-stack.tsx b/web/src/components/motion/animated-toast-stack.tsx deleted file mode 100644 index cf4979d..0000000 --- a/web/src/components/motion/animated-toast-stack.tsx +++ /dev/null @@ -1,505 +0,0 @@ -"use client"; - -import { - AlertCircle, - Bell, - Check, - Info, - LoaderCircle, - X, - type LucideIcon, -} from "lucide-react"; -import { - AnimatePresence, - motion, - useReducedMotion, - type Transition, -} from "motion/react"; -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { createPortal } from "react-dom"; -import { EASE_OUT } from "@/lib/ease"; -import { cn } from "@/lib/utils"; - -export type ToastStatus = "neutral" | "info" | "loading" | "success" | "error"; -export type ToastPosition = - | "top-left" - | "top-center" - | "top-right" - | "bottom-left" - | "bottom-center" - | "bottom-right"; - -export type AnimatedToastAction = { - label: ReactNode; - onClick: (toast: AnimatedToast) => void; -}; - -export type AnimatedToast = { - id: string; - title: ReactNode; - description?: ReactNode; - status?: ToastStatus; - icon?: ReactNode; - action?: AnimatedToastAction; - duration?: number; - dismissible?: boolean; - createdAt?: number; -}; - -export type ToastInput = Omit & { - id?: string; -}; - -export type ToastClassNames = { - root?: string; - item?: string; - surface?: string; - iconWrap?: string; - content?: string; - title?: string; - description?: string; - action?: string; - close?: string; - progress?: string; -}; - -export interface AnimatedToastStackProps { - toasts: AnimatedToast[]; - onDismiss?: (id: string) => void; - position?: ToastPosition; - placement?: "static" | "fixed" | "absolute"; - fixed?: boolean; - portal?: boolean; - portalRoot?: Element | null; - maxVisible?: number; - className?: string; - classNames?: ToastClassNames; - icons?: Partial>; - renderToast?: (toast: AnimatedToast) => ReactNode; -} - -export interface UseAnimatedToastStackOptions { - initialToasts?: ToastInput[]; - defaultDuration?: number; - limit?: number; -} - -const STACK_SPRING: Transition = { - type: "spring", - stiffness: 420, - damping: 34, - mass: 0.75, -}; - -const CONTENT_TRANSITION = { - duration: 0.28, - ease: EASE_OUT, -} as const; - -const STATUS_ICON: Record = { - neutral: Bell, - info: Info, - loading: LoaderCircle, - success: Check, - error: AlertCircle, -}; - -const STATUS_CLASS: Record = { - neutral: "text-muted-foreground bg-muted/60", - info: "text-primary bg-muted", - loading: "text-primary bg-muted", - success: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", - error: "text-destructive bg-destructive/10", -}; - -const POSITION_CLASS: Record = { - "top-left": "left-4 top-4", - "top-center": "left-1/2 top-4 -translate-x-1/2", - "top-right": "right-4 top-4", - "bottom-left": "bottom-6 left-4", - "bottom-center": "bottom-6 left-1/2 -translate-x-1/2", - "bottom-right": "bottom-6 right-4", -}; - -let idSeed = 0; - -function createToast(input: ToastInput, defaultDuration: number): AnimatedToast { - return { - duration: defaultDuration, - dismissible: true, - ...input, - id: input.id ?? `toast-${Date.now()}-${idSeed++}`, - createdAt: Date.now(), - }; -} - -export function useAnimatedToastStack({ - initialToasts = [], - defaultDuration = 4200, - limit, -}: UseAnimatedToastStackOptions = {}) { - const toastTimers = useRef>(new Map()); - const [toasts, setToasts] = useState(() => - initialToasts.map((toast) => createToast(toast, defaultDuration)), - ); - - const dismissToast = useCallback((id: string) => { - setToasts((current) => current.filter((toast) => toast.id !== id)); - }, []); - - const clearToasts = useCallback(() => { - setToasts([]); - }, []); - - const showToast = useCallback( - (input: ToastInput) => { - const toast = createToast(input, defaultDuration); - setToasts((current) => { - const next = [...current, toast]; - return typeof limit === "number" ? next.slice(-limit) : next; - }); - return toast.id; - }, - [defaultDuration, limit], - ); - - const updateToast = useCallback((id: string, patch: Partial) => { - setToasts((current) => - current.map((toast) => - toast.id === id - ? { - ...toast, - ...patch, - id, - createdAt: patch.duration === undefined ? toast.createdAt : Date.now(), - } - : toast, - ), - ); - }, []); - - useEffect(() => { - const activeIds = new Set(toasts.map((toast) => toast.id)); - - toastTimers.current.forEach((entry, id) => { - if (!activeIds.has(id)) { - window.clearTimeout(entry.timer); - toastTimers.current.delete(id); - } - }); - - toasts.forEach((toast) => { - const duration = toast.duration ?? defaultDuration; - const existing = toastTimers.current.get(toast.id); - - if (duration <= 0) { - if (existing) { - window.clearTimeout(existing.timer); - toastTimers.current.delete(toast.id); - } - return; - } - - const createdAt = toast.createdAt ?? Date.now(); - const signature = `${createdAt}:${duration}`; - - if (existing?.signature === signature) { - return; - } - - if (existing) { - window.clearTimeout(existing.timer); - } - - const elapsed = Date.now() - createdAt; - const remaining = Math.max(duration - elapsed, 0); - const timer = window.setTimeout(() => { - toastTimers.current.delete(toast.id); - dismissToast(toast.id); - }, remaining); - - toastTimers.current.set(toast.id, { timer, signature }); - }); - }, [defaultDuration, dismissToast, toasts]); - - useEffect(() => { - const timers = toastTimers.current; - - return () => { - timers.forEach((entry) => { - window.clearTimeout(entry.timer); - }); - timers.clear(); - }; - }, []); - - return useMemo( - () => ({ - toasts, - showToast, - updateToast, - dismissToast, - clearToasts, - setToasts, - }), - [clearToasts, dismissToast, showToast, toasts, updateToast], - ); -} - -export function AnimatedToastStack({ - toasts, - onDismiss, - position = "bottom-right", - placement, - fixed = false, - portal, - portalRoot, - maxVisible = 4, - className, - classNames, - icons, - renderToast, -}: AnimatedToastStackProps) { - const [portalTarget, setPortalTarget] = useState(null); - const visibleToasts = toasts.slice(-maxVisible); - const isBottom = position.startsWith("bottom"); - const resolvedPlacement = placement ?? (fixed ? "fixed" : "static"); - const shouldPortal = portal ?? resolvedPlacement === "fixed"; - - useEffect(() => { - setPortalTarget(shouldPortal ? (portalRoot ?? document.body) : null); - }, [portalRoot, shouldPortal]); - - const stack = ( -
    - - {visibleToasts.map((toast, index) => ( - - ))} - -
- ); - - if (shouldPortal && !portalTarget) { - return null; - } - - if (shouldPortal && portalTarget) { - return createPortal(stack, portalTarget); - } - - return stack; -} - -const ToastItem = memo(function ToastItem({ - toast, - index, - onDismiss, - classNames, - icons, - renderToast, -}: { - toast: AnimatedToast; - index: number; - onDismiss?: (id: string) => void; - classNames?: ToastClassNames; - icons?: Partial>; - renderToast?: (toast: AnimatedToast) => ReactNode; -}) { - const reduce = useReducedMotion(); - const status = toast.status ?? "neutral"; - const Icon = STATUS_ICON[status]; - const iconNode = icons?.[status] ?? toast.icon ?? ; - const canDismiss = toast.dismissible !== false && Boolean(onDismiss); - const hasDetails = Boolean(toast.description || toast.action); - - return ( - { - if (!canDismiss || !onDismiss) return; - if (Math.abs(info.offset.x) > 72 || Math.abs(info.velocity.x) > 520) { - onDismiss(toast.id); - } - }} - className={cn("pointer-events-auto relative will-change-transform", classNames?.item)} - style={{ zIndex: 20 - index }} - > -
- {renderToast ? ( - renderToast(toast) - ) : ( -
- - - - {status === "loading" ? ( - {iconNode} - ) : ( - iconNode - )} - - - - -
- - -

- {toast.title} -

- {toast.description ? ( -

- {toast.description} -

- ) : null} -
-
- - {toast.action ? ( - - ) : null} -
- - {canDismiss ? ( - - ) : null} -
- )} - -
-
- ); -}); diff --git a/web/src/components/motion/button/base.tsx b/web/src/components/motion/button/base.tsx deleted file mode 100644 index 071fb7a..0000000 --- a/web/src/components/motion/button/base.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client"; - -import { - AnimatePresence, - type HTMLMotionProps, - motion, - useReducedMotion, -} from "motion/react"; -import { - forwardRef, - type PointerEvent, - type ReactNode, - useCallback, - useRef, - useState, -} from "react"; -import { EASE_OUT, SPRING_PRESS } from "@/lib/ease"; -import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; -import { cn } from "@/lib/utils"; - -export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline"; -export type ButtonSize = "sm" | "md" | "lg" | "icon"; - -export interface ButtonProps extends Omit< - HTMLMotionProps<"button">, - "children" -> { - variant?: ButtonVariant; - size?: ButtonSize; - pressScale?: number; - ripple?: boolean; - children?: ReactNode; -} - -export interface ButtonLinkProps extends Omit< - HTMLMotionProps<"a">, - "children" -> { - variant?: ButtonVariant; - size?: ButtonSize; - pressScale?: number; - children?: ReactNode; -} - -type Ripple = { id: number; x: number; y: number; size: number }; - -const VARIANT_CLASS: Record = { - primary: "bg-primary text-primary-foreground hover:bg-primary/90", - secondary: "border border-border bg-card text-foreground hover:border-border", - ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60", - outline: - "border border-border bg-transparent text-foreground hover:bg-muted/60", -}; - -const SIZE_CLASS: Record = { - sm: "h-8 px-3 text-xs gap-1.5 rounded-md", - md: "h-10 px-5 text-sm gap-2 rounded-md", - lg: "h-12 px-6 text-base gap-2 rounded-md", - icon: "h-8 w-8 rounded-lg", -}; - -export const Button = forwardRef( - function Button( - { - variant = "primary", - size = "md", - pressScale = 0.93, - ripple = false, - className, - children, - onPointerDown, - ...rest - }, - ref, - ) { - const reduce = useReducedMotion(); - const canHover = useHoverCapable(); - const [ripples, setRipples] = useState([]); - const nextId = useRef(0); - - const handlePointerDown = useCallback( - (event: PointerEvent) => { - if (ripple && !reduce) { - const rect = event.currentTarget.getBoundingClientRect(); - const size = Math.max(rect.width, rect.height) * 2; - const id = nextId.current++; - setRipples((prev) => [ - ...prev, - { - id, - x: event.clientX - rect.left, - y: event.clientY - rect.top, - size, - }, - ]); - } - onPointerDown?.(event); - }, - [ripple, reduce, onPointerDown], - ); - - return ( - - {ripple && !reduce ? ( - - - {ripples.map((r) => ( - - setRipples((prev) => prev.filter((x) => x.id !== r.id)) - } - /> - ))} - - - ) : null} - {children} - - ); - }, -); - -export const ButtonLink = forwardRef( - function ButtonLink( - { - variant = "primary", - size = "md", - pressScale = 0.93, - className, - children, - ...rest - }, - ref, - ) { - const reduce = useReducedMotion(); - const canHover = useHoverCapable(); - - return ( - - {children} - - ); - }, -); diff --git a/web/src/components/motion/button/stateful.tsx b/web/src/components/motion/button/stateful.tsx deleted file mode 100644 index 7953625..0000000 --- a/web/src/components/motion/button/stateful.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client"; - -import { Check, Loader2, X } from "lucide-react"; -import { - AnimatePresence, - motion, - useReducedMotion, - type Variants, -} from "motion/react"; -import { - forwardRef, - type ReactNode, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { EASE_OUT, SPRING_SWAP } from "@/lib/ease"; -import { Button, type ButtonProps } from "./base"; - -export type ButtonState = "idle" | "loading" | "success" | "error"; - -export interface StatefulButtonProps extends Omit { - state?: ButtonState; - children: ReactNode; - loadingText?: ReactNode; - successText?: ReactNode; - errorText?: ReactNode; - icon?: ReactNode; -} - -const CASCADE_STAGGER = 0.025; -const ROLL_BLUR = "blur(6px)"; - -const CASCADE_LETTER_VARIANTS: Variants = { - initial: { opacity: 0, y: "105%", filter: ROLL_BLUR }, - animate: (delay: number = 0) => ({ - opacity: 1, - y: "0%", - filter: "blur(0px)", - transition: { ...SPRING_SWAP, delay }, - }), - exit: (delay: number = 0) => ({ - opacity: 0, - y: "-105%", - filter: ROLL_BLUR, - transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 }, - }), -}; - -const ICON_VARIANTS: Variants = { - initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR }, - animate: { - opacity: 1, - width: "1.5rem", - scale: 1, - filter: "blur(0px)", - transition: SPRING_SWAP, - }, - exit: { - opacity: 0, - width: 0, - scale: 0.7, - filter: ROLL_BLUR, - transition: { duration: 0.16, ease: EASE_OUT }, - }, -}; - -function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) { - const reduce = useReducedMotion(); - return ( - - {children} - - ); -} - -function TextSlot({ - value, - children, -}: { - value: string; - children: ReactNode; -}) { - const reduce = useReducedMotion(); - const measureRef = useRef(null); - const [width, setWidth] = useState(); - const label = typeof children === "string" ? children : null; - const cascade = label !== null && !reduce; - - useLayoutEffect(() => { - const nextWidth = measureRef.current?.offsetWidth; - if (!nextWidth) return; - setWidth((current) => (current === nextWidth ? current : nextWidth)); - }); - - return ( - - - {cascade - ? label.split("").map((char, index) => ( - - {char} - - )) - : children} - - - {cascade ? ( - <> - {label} - - - {label.split("").map((char, index) => ( - - {char} - - ))} - - - - ) : ( - - - {children} - - - )} - - ); -} - -export const StatefulButton = forwardRef(function StatefulButton( - { - state = "idle", - children, - loadingText = "Loading", - successText = "Done", - errorText = "Try again", - icon, - disabled, - ...rest - }, - ref, -) { - const isBusy = state === "loading"; - const stateText = - state === "loading" - ? loadingText - : state === "success" - ? successText - : state === "error" - ? errorText - : children; - const textKey = - typeof stateText === "string" ? `${state}-${stateText}` : state; - - return ( - - ); -}); diff --git a/web/src/components/motion/checkbox.tsx b/web/src/components/motion/checkbox.tsx deleted file mode 100644 index 02144e4..0000000 --- a/web/src/components/motion/checkbox.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { useId, type ReactNode } from "react"; -import { EASE_OUT, SPRING_PRESS } from "@/lib/ease"; -import { cn } from "@/lib/utils"; - -const CHECK_PATH = "M5 13l4 4L19 7"; -const INDETERMINATE_PATH = "M6 12h12"; - -export interface CheckboxProps { - checked: boolean; - onCheckedChange: (checked: boolean) => void; - disabled?: boolean; - indeterminate?: boolean; - error?: boolean; - label?: ReactNode; - className?: string; - id?: string; - "aria-label"?: string; - "aria-describedby"?: string; -} - -export function Checkbox({ - checked, - onCheckedChange, - disabled, - indeterminate, - error, - label, - className, - id: idProp, - "aria-label": ariaLabel, - "aria-describedby": ariaDescribedBy, -}: CheckboxProps) { - const autoId = useId(); - const id = idProp ?? autoId; - const reduce = useReducedMotion(); - const showMark = checked || indeterminate; - const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH; - - return ( - - ); -} diff --git a/web/src/components/motion/input.tsx b/web/src/components/motion/input.tsx deleted file mode 100644 index 0ad7e6f..0000000 --- a/web/src/components/motion/input.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"use client"; - -import { - AnimatePresence, - animate, - motion, - useReducedMotion, -} from "motion/react"; -import { - forwardRef, - useEffect, - useId, - useRef, - useState, - type InputHTMLAttributes, - type ReactNode, -} from "react"; -import { cn } from "@/lib/utils"; - -export type InputClassNames = { - root?: string; - label?: string; - field?: string; - input?: string; - leftIcon?: string; - rightIcon?: string; - successIcon?: string; - errorMessage?: string; -}; - -export interface InputProps extends Omit< - InputHTMLAttributes, - "value" | "defaultValue" | "onChange" -> { - label?: string; - value?: string; - defaultValue?: string; - onChange?: (value: string) => void; - error?: string | boolean; - reserveErrorLine?: boolean; - success?: boolean; - leftIcon?: ReactNode; - rightIcon?: ReactNode; - className?: string; - classNames?: InputClassNames; -} - -export const Input = forwardRef(function Input( - { - label, - value: valueProp, - defaultValue, - onChange, - onFocus, - onBlur, - error, - reserveErrorLine = false, - success, - leftIcon, - rightIcon, - className, - classNames, - disabled, - id: idProp, - type, - ...rest - }, - ref, -) { - const reactId = useId(); - const id = idProp ?? reactId; - const reduce = useReducedMotion(); - - const controlled = valueProp !== undefined; - const [internal, setInternal] = useState(defaultValue ?? ""); - const value = controlled ? (valueProp ?? "") : internal; - - const [focused, setFocused] = useState(false); - - const fieldRef = useRef(null); - - const hasError = Boolean(error); - const errorMessage = typeof error === "string" ? error : null; - - const rightSlot = success ? null : rightIcon; - - useEffect(() => { - if (!fieldRef.current || reduce || !hasError) return; - animate( - fieldRef.current, - { x: [0, -6, 6, -4, 4, -2, 0] }, - { duration: 0.45 }, - ); - }, [hasError, reduce]); - - const handleChange = (next: string) => { - if (!controlled) setInternal(next); - onChange?.(next); - }; - - return ( -
- {label ? ( - - ) : null} - -
- {leftIcon ? ( - - {leftIcon} - - ) : null} - - handleChange(e.target.value)} - onFocus={(event) => { - setFocused(true); - onFocus?.(event); - }} - onBlur={(event) => { - setFocused(false); - onBlur?.(event); - }} - className={cn( - "peer h-full w-full bg-transparent text-sm leading-6 text-foreground caret-foreground outline-none", - "placeholder:text-muted-foreground/60", - leftIcon ? "pl-10" : "pl-3.5", - rightSlot || success ? "pr-10" : "pr-3.5", - disabled && "cursor-not-allowed", - classNames?.input, - )} - /> - - {success ? ( - - - - ) : rightSlot ? ( - - {rightSlot} - - ) : null} -
- -
- - {errorMessage ? ( - - {errorMessage} - - ) : null} - -
-
- ); -}); diff --git a/web/src/components/toast/toast-provider.tsx b/web/src/components/toast/toast-provider.tsx index 66585ca..a39662b 100644 --- a/web/src/components/toast/toast-provider.tsx +++ b/web/src/components/toast/toast-provider.tsx @@ -1,29 +1,66 @@ -import { useMemo, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { X } from "lucide-react"; -import { - AnimatedToastStack, - useAnimatedToastStack, -} from "@/components/motion/animated-toast-stack"; -import { ToastContext } from "@/components/toast/use-toast"; +import { ToastContext, type ToastInput, type ToastStatus } from "@/components/toast/use-toast"; +import { cn } from "@/lib/utils"; + +const TOAST_DURATION = 4000; + +const STATUS_CLASS: Record = { + info: "bg-muted-foreground", + success: "bg-success", + error: "bg-destructive", +}; + +type Toast = ToastInput & { id: number }; export function ToastProvider({ children }: { children: ReactNode }) { - const { toasts, showToast, updateToast, dismissToast, clearToasts } = - useAnimatedToastStack(); + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); - const actions = useMemo( - () => ({ showToast, updateToast, dismissToast, clearToasts }), - [showToast, updateToast, dismissToast, clearToasts], + const dismissToast = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback( + (input: ToastInput) => { + const id = nextId.current++; + setToasts((current) => [...current, { ...input, id }]); + window.setTimeout(() => dismissToast(id), TOAST_DURATION); + }, + [dismissToast], ); + const actions = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]); + return ( {children} - +
    + {toasts.map((toast) => ( +
  1. + +
    +

    {toast.title}

    + {toast.description && ( +

    {toast.description}

    + )} +
    + +
  2. + ))} +
); } diff --git a/web/src/components/toast/use-toast.ts b/web/src/components/toast/use-toast.ts index 9c68acd..55f920a 100644 --- a/web/src/components/toast/use-toast.ts +++ b/web/src/components/toast/use-toast.ts @@ -1,13 +1,17 @@ import { createContext, useContext } from "react"; -import type { useAnimatedToastStack } from "@/components/motion/animated-toast-stack"; +export type ToastStatus = "info" | "success" | "error"; -type ToastStack = ReturnType; +export type ToastInput = { + title: string; + description?: string; + status?: ToastStatus; +}; -export type ToastContextValue = Pick< - ToastStack, - "showToast" | "updateToast" | "dismissToast" | "clearToasts" ->; +export type ToastContextValue = { + showToast: (toast: ToastInput) => void; + dismissToast: (id: number) => void; +}; export const ToastContext = createContext(null); diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..f3b3215 --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,20 @@ +import type { ButtonHTMLAttributes } from "react"; + +import { cn } from "@/lib/utils"; + +export function Button({ + className, + type = "button", + ...props +}: ButtonHTMLAttributes) { + return ( + + )} +
+ {error && ( + + )} +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index d8025e9..893cd38 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,55 +1,23 @@ @import "tailwindcss"; -@import "@fontsource-variable/geist"; -@import "@fontsource-variable/geist-mono"; +@import "@fontsource-variable/inter"; @theme { - /* Fonts */ - --font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif; - --font-mono: "Geist Mono Variable", ui-monospace, monospace; - - /* Font sizes */ - --text-xs: 0.6875rem; - --text-sm: 0.8125rem; - --text-base: 0.9375rem; - --text-lg: 1.0625rem; - --text-xl: 1.1875rem; - --text-2xl: 1.375rem; - --text-3xl: 1.75rem; - --text-4xl: 2.125rem; - - /* Backgrounds */ - --color-canvas: #151515; - --color-panel: #1c1c1c; - --color-panel-raised: #202020; - --color-hover: #ffffff0d; - - /* Borders */ - --color-line: #ffffff0d; - --color-line-strong: #ffffff1a; - --color-line-focus: #ffffff26; - - /* Text */ - --color-fg: #f2f2f2; - --color-muted-foreground: #868686; - --color-faint: #5c5c5c; - - /* Accent */ - --color-accent: #23dc7f; - --color-accent-hover: #33e1e2; - --color-accent-strong: #00bebf; - --color-on-accent: #151515; - --color-selection: #00dadb40; - - /* beUI */ - --color-background: var(--color-canvas); - --color-foreground: var(--color-fg); - --color-card: var(--color-panel); - --color-muted: var(--color-panel); - --color-border: var(--color-line); - --color-ring: var(--color-line-strong); - --color-primary: var(--color-accent); - --color-primary-foreground: #ffffff; - --color-destructive: #ee343b; + --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; + + --color-background: #18181a; + --color-foreground: #fafafa; + --color-card: #18181a; + --color-muted: #27272a; + --color-muted-foreground: #a2a2a9; + --color-border: #27272a; + --color-input: #27272a; + --color-ring: #85858b; + --color-primary: #a54a4b; + --color-primary-foreground: #fafafa; + --color-accent: #e6bfbb; + --color-destructive: #d89894; + --color-success: #22c55e; + --color-selection: #a54a4b66; } @layer base { @@ -58,7 +26,7 @@ } body { - @apply bg-canvas text-base text-fg antialiased; + @apply bg-background font-sans text-foreground antialiased; } ::selection { diff --git a/web/src/lib/ease.ts b/web/src/lib/ease.ts deleted file mode 100644 index 6b7649a..0000000 --- a/web/src/lib/ease.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const EASE_OUT = [0.16, 1, 0.3, 1] as const; -export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const; -export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; - -export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)"; - -export const SPRING_PRESS = { - type: "spring", - stiffness: 500, - damping: 30, - mass: 0.6, -} as const; - -export const SPRING_SWAP = { - type: "spring", - stiffness: 460, - damping: 30, - mass: 0.55, -} as const; - -export const SPRING_PANEL = { - type: "spring", - stiffness: 420, - damping: 40, - mass: 0.5, -} as const; - -export const SPRING_LAYOUT = { - type: "spring", - stiffness: 360, - damping: 32, - mass: 0.6, -} as const; - -export const SPRING_MOUSE = { - stiffness: 200, - damping: 15, - mass: 0.3, -} as const; - -export const SPRING_GLIDE = { - stiffness: 700, - damping: 50, - mass: 0.5, -} as const; diff --git a/web/src/lib/hooks/use-hover-capable.ts b/web/src/lib/hooks/use-hover-capable.ts deleted file mode 100644 index 3b29eea..0000000 --- a/web/src/lib/hooks/use-hover-capable.ts +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -export function useHoverCapable() { - const [canHover, setCanHover] = useState(false); - - useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) return; - const mq = window.matchMedia("(hover: hover) and (pointer: fine)"); - const update = () => setCanHover(mq.matches); - update(); - mq.addEventListener?.("change", update); - return () => mq.removeEventListener?.("change", update); - }, []); - - return canHover; -} diff --git a/web/src/routes/_guest/login.tsx b/web/src/routes/_guest/login.tsx index bb24cbc..2d594dd 100644 --- a/web/src/routes/_guest/login.tsx +++ b/web/src/routes/_guest/login.tsx @@ -6,10 +6,9 @@ import * as z from "zod"; import { meQuery } from "@/api/auth/me"; import { loginUser } from "@/api/auth/user"; import { parseServerError } from "@/api/errors"; -import { AuthCard } from "@/components/auth/auth-card"; import { AuthLayout } from "@/components/auth/auth-layout"; -import { StatefulButton, type ButtonState } from "@/components/motion/button/stateful"; -import { Input } from "@/components/motion/input"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; export const Route = createFileRoute("/_guest/login")({ validateSearch: (search: Record): { redirect?: string } => ({ @@ -47,14 +46,6 @@ function Login() { }, }); - const buttonState: ButtonState = login.isPending - ? "loading" - : login.isSuccess - ? "success" - : login.isError - ? "error" - : "idle"; - const { fieldErrors: serverFieldErrors, formError } = parseServerError( login.error, { @@ -90,44 +81,45 @@ function Login() { return ( - Don't have an account?{" "} - - Sign up + No account?{" "} + + Create one } > - -
- setForm((prev) => ({ ...prev, email: value }))} - error={errors.email} - reserveErrorLine - /> - - - setForm((prev) => ({ ...prev, password: value })) - } - error={errors.password} - reserveErrorLine - /> - - {formError &&

{formError}

} - - - Sign In - -
-
+
+ setForm((prev) => ({ ...prev, email: e.target.value }))} + error={errors.email} + /> + + setForm((prev) => ({ ...prev, password: e.target.value }))} + error={errors.password} + /> + + {formError &&

{formError}

} + + + +
+ + Forgot password? + +
+
); } diff --git a/web/src/routes/_guest/register.tsx b/web/src/routes/_guest/register.tsx index af9821d..ed538b7 100644 --- a/web/src/routes/_guest/register.tsx +++ b/web/src/routes/_guest/register.tsx @@ -5,14 +5,11 @@ import * as z from "zod"; import { registerUser } from "@/api/auth/user"; import { parseServerError } from "@/api/errors"; -import { AuthCard } from "@/components/auth/auth-card"; import { AuthLayout } from "@/components/auth/auth-layout"; -import { - StatefulButton, - type ButtonState, -} from "@/components/motion/button/stateful"; -import { Checkbox } from "@/components/motion/checkbox"; -import { Input } from "@/components/motion/input"; +import { useToast } from "@/components/toast/use-toast"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; export const Route = createFileRoute("/_guest/register")({ component: Register, @@ -38,6 +35,7 @@ const registerSchema = z function Register() { const navigate = useNavigate(); + const { showToast } = useToast(); const [form, setForm] = useState({ email: "", password: "", @@ -51,18 +49,11 @@ function Register() { const register = useMutation({ mutationFn: registerUser, onSuccess: () => { - setTimeout(() => navigate({ to: "/login" }), 800); + showToast({ status: "success", title: "Account created", description: "You can now sign in." }); + navigate({ to: "/login" }); }, }); - const buttonState: ButtonState = register.isPending - ? "loading" - : register.isSuccess - ? "success" - : register.isError - ? "error" - : "idle"; - const { fieldErrors: serverFieldErrors, formError } = parseServerError( register.error, { @@ -98,104 +89,89 @@ function Register() { return ( Already have an account?{" "} - + Sign in } > - -
-
- - setForm((prev) => ({ ...prev, given_name: value })) - } - error={errors.given_name} - reserveErrorLine - /> - - - setForm((prev) => ({ ...prev, family_name: value })) - } - error={errors.family_name} - reserveErrorLine - /> -
- - setForm((prev) => ({ ...prev, email: value }))} - error={errors.email} - reserveErrorLine - /> - + +
- setForm((prev) => ({ ...prev, password: value })) - } - error={errors.password} - reserveErrorLine + className="flex-1" + type="text" + placeholder="Given name" + value={form.given_name} + onChange={(e) => setForm((prev) => ({ ...prev, given_name: e.target.value }))} + error={errors.given_name} /> - setForm((prev) => ({ ...prev, confirm_password: value })) - } - error={errors.confirm_password} - reserveErrorLine + className="flex-1" + type="text" + placeholder="Family name" + value={form.family_name} + onChange={(e) => setForm((prev) => ({ ...prev, family_name: e.target.value }))} + error={errors.family_name} /> - - - I agree to the{" "} - - terms and conditions - - - } - checked={form.terms} - error={!!errors.terms} - onCheckedChange={(checked) => { - setForm((prev) => ({ ...prev, terms: checked })); - if (checked) setClientErrors((prev) => ({ ...prev, terms: "" })); - }} - /> - - {formError &&

{formError}

} - - - Register - - - +
+ + setForm((prev) => ({ ...prev, email: e.target.value }))} + error={errors.email} + /> + + setForm((prev) => ({ ...prev, password: e.target.value }))} + error={errors.password} + /> + + setForm((prev) => ({ ...prev, confirm_password: e.target.value }))} + error={errors.confirm_password} + /> + + + I agree to the{" "} + + terms and conditions + + + } + checked={form.terms} + error={!!errors.terms} + onCheckedChange={(checked) => { + setForm((prev) => ({ ...prev, terms: checked })); + if (checked) setClientErrors((prev) => ({ ...prev, terms: "" })); + }} + /> + + {formError &&

{formError}

} + + +
); }