diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a3ac2e9..e37add81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,19 +14,18 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@stripe/stripe-js": "^8.7.0", - "@types/lodash": "^4.17.23", "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "immer": "^11.1.3", - "lodash": "^4.17.23", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", "react": "^19.2.0", "react-day-picker": "^9.13.1", "react-dom": "^19.2.0", + "react-helmet-async": "^3.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", @@ -2121,12 +2120,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4643,6 +4636,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -4818,7 +4820,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4981,12 +4982,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5004,6 +4999,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6335,6 +6342,26 @@ "react": "^19.2.4" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz", + "integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==", + "license": "Apache-2.0", + "dependencies": { + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6781,6 +6808,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 98ac3d0b..849bc248 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,19 +19,18 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@stripe/stripe-js": "^8.7.0", - "@types/lodash": "^4.17.23", "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "immer": "^11.1.3", - "lodash": "^4.17.23", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", "react": "^19.2.0", "react-day-picker": "^9.13.1", "react-dom": "^19.2.0", + "react-helmet-async": "^3.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", "recharts": "^3.7.0", diff --git a/frontend/src/components/admin/SearchInput.tsx b/frontend/src/components/admin/SearchInput.tsx index c1726873..f6b09cca 100644 --- a/frontend/src/components/admin/SearchInput.tsx +++ b/frontend/src/components/admin/SearchInput.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { Search, X } from 'lucide-react' -import { debounce } from 'lodash' +import { debounce } from '@/lib/debounce' import { cn } from '@/lib/utils' interface SearchInputProps { diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx index 58262b47..da787dfd 100644 --- a/frontend/src/components/common/Modal.tsx +++ b/frontend/src/components/common/Modal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, type ReactNode } from 'react' +import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react' import { X, Maximize2, Minimize2 } from 'lucide-react' import { cn } from '@/lib/utils' @@ -14,6 +14,9 @@ interface ModalProps { allowFullScreen?: boolean } +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])' + export function Modal({ isOpen, onClose, title, children, footer, size = 'md', allowFullScreen = false }: ModalProps) { const [isFullScreen, setIsFullScreen] = useState(() => { if (!allowFullScreen) return false @@ -24,6 +27,9 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a } }) + const modalRef = useRef(null) + const previousFocusRef = useRef(null) + const toggleFullScreen = () => { const next = !isFullScreen setIsFullScreen(next) @@ -44,8 +50,10 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a [onClose] ) + // Body overflow lock + keyboard listener useEffect(() => { if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement document.addEventListener('keydown', handleKeyDown) document.body.style.overflow = 'hidden' } @@ -55,6 +63,50 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a } }, [isOpen, handleKeyDown]) + // Focus trap: keep focus inside the modal + useEffect(() => { + if (!isOpen) { + // Restore focus when modal closes + previousFocusRef.current?.focus() + return + } + + const modal = modalRef.current + if (!modal) return + + // Auto-focus first focusable element + const timer = setTimeout(() => { + const focusable = modal.querySelectorAll(FOCUSABLE_SELECTOR) + if (focusable.length > 0) { + focusable[0].focus() + } + }, 50) + + const trapFocus = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + + const focusable = modal.querySelectorAll(FOCUSABLE_SELECTOR) + if (focusable.length === 0) return + + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + + modal.addEventListener('keydown', trapFocus) + return () => { + clearTimeout(timer) + modal.removeEventListener('keydown', trapFocus) + } + }, [isOpen]) + if (!isOpen) return null const sizeClasses = { @@ -80,6 +132,7 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a {/* Modal Content */}
and Open Graph meta tags. + * Wrap the app in (see main.tsx). + */ +export function PageMeta({ + title, + description = DEFAULT_DESCRIPTION, + ogImage, + ogType = 'website', +}: PageMetaProps) { + const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} - Decision Tree Platform` + + return ( + + {fullTitle} + + + {/* Open Graph */} + + + + + {ogImage && } + + {/* Twitter */} + + + + {ogImage && } + + ) +} diff --git a/frontend/src/components/layout/NavItem.tsx b/frontend/src/components/layout/NavItem.tsx index 1225eba2..26da8534 100644 --- a/frontend/src/components/layout/NavItem.tsx +++ b/frontend/src/components/layout/NavItem.tsx @@ -1,6 +1,7 @@ import { Link, useLocation } from 'react-router-dom' import type { LucideIcon } from 'lucide-react' import { cn } from '@/lib/utils' +import { prefetchForRoute } from '@/lib/routePrefetch' interface NavSubItem { href: string @@ -36,6 +37,7 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed, return ( prefetchForRoute(href)} className={cn( 'group relative flex items-center justify-center rounded-lg p-2 transition-all duration-120', isActive @@ -61,6 +63,7 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed,
prefetchForRoute(href)} className={cn( 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120', isActive diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 00000000..2b584873 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,63 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' +import { Spinner } from '@/components/common/Spinner' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]', + { + variants: { + variant: { + primary: + 'bg-gradient-brand text-[#101114] font-semibold shadow-lg shadow-primary/20 hover:opacity-90', + secondary: + 'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.06)]', + destructive: + 'bg-red-400/10 text-red-400 border border-red-400/20 hover:bg-red-400/20', + ghost: + 'text-muted-foreground hover:bg-accent hover:text-foreground', + link: + 'text-primary underline-offset-4 hover:underline p-0 h-auto', + }, + size: { + sm: 'h-8 px-3 text-xs rounded-lg', + md: 'h-9 px-4 text-sm rounded-[10px]', + lg: 'h-10 px-6 text-sm rounded-[10px]', + icon: 'size-9 rounded-lg', + 'icon-sm': 'size-8 rounded-lg', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + loading?: boolean +} + +export function Button({ + className, + variant, + size, + loading, + children, + disabled, + ...props +}: ButtonProps) { + return ( + + ) +} + +export { buttonVariants } diff --git a/frontend/src/components/ui/FormField.tsx b/frontend/src/components/ui/FormField.tsx new file mode 100644 index 00000000..6e5faae3 --- /dev/null +++ b/frontend/src/components/ui/FormField.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' + +interface FormFieldProps { + label: string + htmlFor?: string + required?: boolean + hint?: string + children: ReactNode +} + +export function FormField({ label, htmlFor, required, hint, children }: FormFieldProps) { + return ( +
+ + {children} + {hint && ( +

{hint}

+ )} +
+ ) +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 00000000..fd5505d2 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,35 @@ +import { cn } from '@/lib/utils' + +export interface InputProps extends React.InputHTMLAttributes { + error?: string +} + +export function Input({ className, error, id, ...props }: InputProps) { + return ( +
+ + {error && ( + + )} +
+ ) +} diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 00000000..0744b535 --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,58 @@ +import { cn } from '@/lib/utils' + +interface SkeletonProps extends React.HTMLAttributes {} + +export function Skeleton({ className, ...props }: SkeletonProps) { + return ( +
+ ) +} + +export function CardSkeleton({ className }: { className?: string }) { + return ( +
+ + +
+ + +
+
+ ) +} + +export function TableRowSkeleton({ cols = 4 }: { cols?: number }) { + return ( +
+ {Array.from({ length: cols }).map((_, i) => ( + + ))} +
+ ) +} + +export function ListSkeleton({ count = 5, className }: { count?: number; className?: string }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/ui/Textarea.tsx b/frontend/src/components/ui/Textarea.tsx new file mode 100644 index 00000000..3de30808 --- /dev/null +++ b/frontend/src/components/ui/Textarea.tsx @@ -0,0 +1,35 @@ +import { cn } from '@/lib/utils' + +export interface TextareaProps extends React.TextareaHTMLAttributes { + error?: string +} + +export function Textarea({ className, error, id, ...props }: TextareaProps) { + return ( +
+