diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 0d13a1c..7c83076 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -64,28 +64,46 @@ trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit poli - **Stack:** Next.js 16.2.6 (App Router) · React 19 · TypeScript · Tailwind CSS **v4**. Path alias `@/*` → repo root (`tsconfig.json`). `cn()` (clsx + tailwind-merge) lives in `lib/utils.ts`. -- **`app/`** — App Router. `app/page.tsx` is the product chat page (sidebar + chat panel); - `app/layout.tsx` **forces dark mode** by putting `dark` on `` and loads the fonts. -- **`components/ui/`** — shadcn components, but built on **Base UI (`@base-ui/react`), not Radix.** - APIs differ: composition uses the `render` prop and `useRender`/`mergeProps`, not `asChild`. - Match existing files when adding/editing primitives. Config in `components.json` (style - `base-luma`, baseColor `neutral`). +- **`app/`** — App Router. `app/(app)/page.tsx` is the product chat page (sidebar + chat panel); + `app/layout.tsx` loads the fonts and wraps children in `ThemeProvider` (next-themes) + + `I18nProvider` (see Theming and i18n below). +- **`components/ui/`** — **COSS components** (built on **Base UI `@base-ui/react`, not Radix**), + installed via the shadcn CLI from the `@coss/*` registry. APIs follow Base UI: composition uses + the `render` prop and `useRender`/`mergeProps`, **not `asChild`**, and popups use canonical COSS + names (`DialogPopup`/`DialogPanel`, `MenuPopup`, `SelectPopup`, `TooltipPopup`, `PreviewCard`, + `Group`) — COSS also ships back-compat aliases (`DialogContent`, `CardContent`, `SelectContent`, + `TooltipContent`, …). Add/update primitives with `npx shadcn@latest add @coss/`. + `carousel.tsx` is the one **non-COSS** primitive kept (no COSS equivalent). Config in + `components.json` (baseColor `neutral`). - **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`, `Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`, `ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state is managed with local React state. - **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` / `SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav config (New chat - · Patients · Settings) + notifications; `team-switcher.tsx` exists but is no longer used. + · Patients · Settings) + notifications; `team-switcher.tsx` is the `OrgSwitcher` (clinic switch). - **`components/chat/`** — the product chat UI. `chat-panel.tsx` owns message state + empty/active layouts; `chat-input.tsx` is a bespoke (non–ai-elements) input matching a specific design. ## Theming -Tailwind v4 with `@theme inline` and **oklch CSS variables** in `app/globals.css` (a -Linear-inspired dark palette; `--primary` is indigo `#5e6ad2`). The app is dark-only. The radius -scale (`rounded-2xl` … `rounded-4xl`) is derived from `--radius`, so those utilities are larger than -stock Tailwind. +Tailwind v4 with `@theme inline` in `app/globals.css`, using **COSS's default neutral tokens** +(`@coss/colors-neutral`; values reference Tailwind palette vars like `--color-neutral-*` plus +`--alpha()`/`color-mix()`). Both **light (`:root`) and dark (`.dark`)** palettes are defined; +`next-themes` (`components/theme-provider.tsx`) toggles them with **`defaultTheme="dark"`** + +`enableSystem`, so the app still defaults to dark. Fonts follow the COSS variable contract +(`--font-sans`, `--font-heading` = Inter; `--font-mono` = Geist Mono). The radius scale +(`rounded-2xl` … `rounded-4xl`) is derived from `--radius`, so those utilities are larger than stock +Tailwind. + +## i18n + +`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, English resources in +`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in +`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`) +in **client** components. To add a language, drop a `locales//translation.json` and register it +in `resources`/`supportedLngs` in `config.ts`. Auth forms, sidebar nav, and settings tabs are +converted as the reference pattern; other strings can be migrated incrementally. ## Gotchas diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a32a305..2a35832 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -8,8 +8,8 @@ --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); + --font-mono: var(--font-mono); + --font-heading: var(--font-heading); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -46,83 +46,110 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --color-warning-foreground: var(--warning-foreground); + --color-warning: var(--warning); + --color-success-foreground: var(--success-foreground); + --color-success: var(--success); + --color-info-foreground: var(--info-foreground); + --color-info: var(--info); + --color-destructive-foreground: var(--destructive-foreground); + --animate-skeleton: skeleton 2s -1s infinite linear; + @keyframes skeleton { + to { + background-position: -200% 0; + } + } } :root { - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.488 0.243 264.376); - --primary-foreground: oklch(0.97 0.014 254.604); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --card: var(--color-white); + --card-foreground: var(--color-neutral-800); + --popover: var(--color-white); + --popover-foreground: var(--color-neutral-800); + --primary: var(--color-neutral-800); + --primary-foreground: var(--color-neutral-50); + --secondary: --alpha(var(--color-black) / 4%); + --secondary-foreground: var(--color-neutral-800); + --muted: --alpha(var(--color-black) / 4%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black)); + --accent: --alpha(var(--color-black) / 4%); + --accent-foreground: var(--color-neutral-800); + --destructive: var(--color-red-500); + --border: --alpha(var(--color-black) / 8%); + --input: --alpha(var(--color-black) / 10%); + --ring: var(--color-neutral-400); --chart-1: oklch(0.809 0.105 251.813); --chart-2: oklch(0.623 0.214 259.815); --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); --radius: 0.875rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.546 0.245 262.881); - --sidebar-primary-foreground: oklch(0.97 0.014 254.604); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --sidebar: var(--color-neutral-50); + --sidebar-foreground: var(--color-neutral-800); + --sidebar-primary: var(--color-neutral-800); + --sidebar-primary-foreground: var(--color-neutral-50); + --sidebar-accent: --alpha(var(--color-black) / 4%); + --sidebar-accent-foreground: var(--color-neutral-800); + --sidebar-border: --alpha(var(--color-black) / 8%); + --sidebar-ring: var(--color-neutral-400); + --background: var(--color-white); + --foreground: var(--color-neutral-800); + --destructive-foreground: var(--color-red-700); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-700); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-700); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-700); } -/* Linear-inspired dark palette: near-black canvas, #5e6ad2 indigo accent, hairline borders. */ +/* COSS neutral dark palette: near-black canvas, neutral accent, hairline borders. */ .dark { - --background: oklch(0.172 0.004 277); - --foreground: oklch(0.98 0.002 277); - --card: oklch(0.193 0.005 277); - --card-foreground: oklch(0.98 0.002 277); - --popover: oklch(0.193 0.005 277); - --popover-foreground: oklch(0.98 0.002 277); - --primary: oklch(0.556 0.155 277); - --primary-foreground: oklch(0.985 0.001 277); - --secondary: oklch(0.225 0.006 277); - --secondary-foreground: oklch(0.98 0.002 277); - --muted: oklch(0.225 0.006 277); - --muted-foreground: oklch(0.652 0.012 268); - --accent: oklch(0.245 0.008 277); - --accent-foreground: oklch(0.98 0.002 277); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 8%); - --input: oklch(1 0 0 / 10%); - --ring: oklch(0.556 0.155 277); + --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); + --foreground: var(--color-neutral-100); + --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --card-foreground: var(--color-neutral-100); + --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --popover-foreground: var(--color-neutral-100); + --primary: var(--color-neutral-100); + --primary-foreground: var(--color-neutral-800); + --secondary: --alpha(var(--color-white) / 4%); + --secondary-foreground: var(--color-neutral-100); + --muted: --alpha(var(--color-white) / 4%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); + --accent: --alpha(var(--color-white) / 4%); + --accent-foreground: var(--color-neutral-100); + --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); + --border: --alpha(var(--color-white) / 6%); + --input: --alpha(var(--color-white) / 8%); + --ring: var(--color-neutral-500); --chart-1: oklch(0.809 0.105 251.813); --chart-2: oklch(0.623 0.214 259.815); --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); - --sidebar: oklch(0.138 0.004 277); - --sidebar-foreground: oklch(0.98 0.002 277); - --sidebar-primary: oklch(0.556 0.155 277); - --sidebar-primary-foreground: oklch(0.985 0.001 277); - --sidebar-accent: oklch(0.245 0.008 277); - --sidebar-accent-foreground: oklch(0.98 0.002 277); - --sidebar-border: oklch(1 0 0 / 8%); - --sidebar-ring: oklch(0.556 0.155 277); + --sidebar: var(--color-neutral-950); + --sidebar-foreground: var(--color-neutral-100); + --sidebar-primary: var(--color-neutral-100); + --sidebar-primary-foreground: var(--color-neutral-800); + --sidebar-accent: --alpha(var(--color-white) / 4%); + --sidebar-accent-foreground: var(--color-neutral-100); + --sidebar-border: --alpha(var(--color-white) / 6%); + --sidebar-ring: var(--color-neutral-500); + --destructive-foreground: var(--color-red-400); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-400); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-400); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-400); } @layer base { * { @apply border-border outline-ring/50; scrollbar-width: thin; - scrollbar-color: oklch(1 0 0 / 18%) transparent; + scrollbar-color: color-mix(in srgb, var(--foreground) 20%, transparent) transparent; } *::-webkit-scrollbar { width: 8px; @@ -132,11 +159,11 @@ background: transparent; } *::-webkit-scrollbar-thumb { - background-color: oklch(1 0 0 / 18%); + background-color: color-mix(in srgb, var(--foreground) 20%, transparent); border-radius: 9999px; } *::-webkit-scrollbar-thumb:hover { - background-color: oklch(1 0 0 / 28%); + background-color: color-mix(in srgb, var(--foreground) 32%, transparent); } body { @apply bg-background text-foreground; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 927ba5c..b673e65 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,19 +1,14 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono, Inter } from "next/font/google"; +import { Geist_Mono, Inter } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/theme-provider"; +import { I18nProvider } from "@/components/i18n-provider"; -const inter = Inter({subsets:['latin'],variable:'--font-sans'}); - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +// COSS font-variable contract: --font-sans, --font-heading, --font-mono. +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); +const interHeading = Inter({ subsets: ["latin"], variable: "--font-heading" }); +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" }); export const metadata: Metadata = { title: "temetro — AI assistant for clinicians", @@ -29,16 +24,32 @@ export default function RootLayout({ return ( - {/* suppressHydrationWarning: browser extensions (e.g. ColorZilla's - cz-shortcut-listen) mutate before hydration. Only ignores - attribute diffs on itself, not its children. */} + {/* suppressHydrationWarning: next-themes sets the theme class on + before hydration, and browser extensions (e.g. ColorZilla's + cz-shortcut-listen) mutate . Only ignores attribute diffs on + those elements, not their children. */} - {children} + + {children} + ); diff --git a/frontend/components.json b/frontend/components.json index a968803..9521df1 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -22,6 +22,7 @@ "hooks": "@/hooks" }, "registries": { - "@blocks-so": "https://blocks.so/r/{name}.json" + "@blocks-so": "https://blocks.so/r/{name}.json", + "@coss": "https://coss.com/ui/r/{name}.json" } } diff --git a/frontend/components/ai-elements/attachments.tsx b/frontend/components/ai-elements/attachments.tsx index d4e4e34..6a3c036 100644 --- a/frontend/components/ai-elements/attachments.tsx +++ b/frontend/components/ai-elements/attachments.tsx @@ -2,10 +2,10 @@ import { Button } from "@/components/ui/button"; import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; + PreviewCard as HoverCard, + PreviewCardPopup as HoverCardContent, + PreviewCardTrigger as HoverCardTrigger, +} from "@/components/ui/preview-card"; import { cn } from "@/lib/utils"; import type { FileUIPart, SourceDocumentUIPart } from "ai"; import { diff --git a/frontend/components/ai-elements/audio-player.tsx b/frontend/components/ai-elements/audio-player.tsx index 5c45417..ba98115 100644 --- a/frontend/components/ai-elements/audio-player.tsx +++ b/frontend/components/ai-elements/audio-player.tsx @@ -2,9 +2,9 @@ import { Button } from "@/components/ui/button"; import { - ButtonGroup, - ButtonGroupText, -} from "@/components/ui/button-group"; + Group as ButtonGroup, + GroupText as ButtonGroupText, +} from "@/components/ui/group"; import { cn } from "@/lib/utils"; import type { Experimental_SpeechResult as SpeechResult } from "ai"; import { diff --git a/frontend/components/ai-elements/context.tsx b/frontend/components/ai-elements/context.tsx index 2646c98..c4fe7b6 100644 --- a/frontend/components/ai-elements/context.tsx +++ b/frontend/components/ai-elements/context.tsx @@ -2,10 +2,10 @@ import { Button } from "@/components/ui/button"; import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; + PreviewCard as HoverCard, + PreviewCardPopup as HoverCardContent, + PreviewCardTrigger as HoverCardTrigger, +} from "@/components/ui/preview-card"; import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; import type { LanguageModelUsage } from "ai"; diff --git a/frontend/components/ai-elements/inline-citation.tsx b/frontend/components/ai-elements/inline-citation.tsx index ced5c5f..8194d38 100644 --- a/frontend/components/ai-elements/inline-citation.tsx +++ b/frontend/components/ai-elements/inline-citation.tsx @@ -8,10 +8,10 @@ import { CarouselItem, } from "@/components/ui/carousel"; import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; + PreviewCard as HoverCard, + PreviewCardPopup as HoverCardContent, + PreviewCardTrigger as HoverCardTrigger, +} from "@/components/ui/preview-card"; import { cn } from "@/lib/utils"; import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import type { ComponentProps } from "react"; diff --git a/frontend/components/ai-elements/message.tsx b/frontend/components/ai-elements/message.tsx index 467479a..2f6d372 100644 --- a/frontend/components/ai-elements/message.tsx +++ b/frontend/components/ai-elements/message.tsx @@ -1,13 +1,10 @@ "use client"; import { Button } from "@/components/ui/button"; -import { - ButtonGroup, - ButtonGroupText, -} from "@/components/ui/button-group"; +import { Group, GroupText } from "@/components/ui/group"; import { Tooltip, - TooltipContent, + TooltipPopup, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; @@ -101,10 +98,10 @@ export const MessageAction = ({ return ( - {button} - + +

{tooltip}

-
+
); @@ -227,7 +224,7 @@ export const MessageBranchContent = ({ )); }; -export type MessageBranchSelectorProps = ComponentProps; +export type MessageBranchSelectorProps = ComponentProps; export const MessageBranchSelector = ({ className, @@ -241,7 +238,7 @@ export const MessageBranchSelector = ({ } return ( - *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md", className @@ -307,7 +304,7 @@ export const MessageBranchPage = ({ const { currentBranch, totalBranches } = useMessageBranch(); return ( - {currentBranch + 1} of {totalBranches} - + ); }; diff --git a/frontend/components/ai-elements/open-in-chat.tsx b/frontend/components/ai-elements/open-in-chat.tsx index 69ec5ab..abadc94 100644 --- a/frontend/components/ai-elements/open-in-chat.tsx +++ b/frontend/components/ai-elements/open-in-chat.tsx @@ -2,13 +2,13 @@ import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Menu as DropdownMenu, + MenuPopup as DropdownMenuContent, + MenuItem as DropdownMenuItem, + MenuGroupLabel as DropdownMenuLabel, + MenuSeparator as DropdownMenuSeparator, + MenuTrigger as DropdownMenuTrigger, +} from "@/components/ui/menu"; import { cn } from "@/lib/utils"; import { ChevronDownIcon, diff --git a/frontend/components/ai-elements/prompt-input.tsx b/frontend/components/ai-elements/prompt-input.tsx index f3613dc..5c3fe8b 100644 --- a/frontend/components/ai-elements/prompt-input.tsx +++ b/frontend/components/ai-elements/prompt-input.tsx @@ -10,20 +10,20 @@ import { CommandSeparator, } from "@/components/ui/command"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Menu as DropdownMenu, + MenuPopup as DropdownMenuContent, + MenuItem as DropdownMenuItem, + MenuTrigger as DropdownMenuTrigger, +} from "@/components/ui/menu"; import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; + PreviewCard as HoverCard, + PreviewCardPopup as HoverCardContent, + PreviewCardTrigger as HoverCardTrigger, +} from "@/components/ui/preview-card"; +import { Button as InputGroupButton } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, - InputGroupButton, InputGroupTextarea, } from "@/components/ui/input-group"; import { diff --git a/frontend/components/ai-elements/snippet.tsx b/frontend/components/ai-elements/snippet.tsx index 2d2b915..3ef8880 100644 --- a/frontend/components/ai-elements/snippet.tsx +++ b/frontend/components/ai-elements/snippet.tsx @@ -1,9 +1,9 @@ "use client"; +import { Button as InputGroupButton } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, - InputGroupButton, InputGroupInput, InputGroupText, } from "@/components/ui/input-group"; diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index 327520a..f5b4d5d 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -25,12 +25,12 @@ import { import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuTrigger, +} from "@/components/ui/menu"; import { cn } from "@/lib/utils"; type ChatInputProps = { @@ -101,8 +101,8 @@ function SelectPill({ const selected = options.find((option) => option.value === value); return ( - - + } @@ -113,17 +113,17 @@ function SelectPill({ ) : null} {selected?.label} - - - + + + {options.map((option) => ( - + {option.label} - + ))} - - - + + + ); } diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx index 1270669..8ca51cf 100644 --- a/frontend/components/chat/patient-cards.tsx +++ b/frontend/components/chat/patient-cards.tsx @@ -62,6 +62,11 @@ const sexLabel: Record = { F: "Female", M: "Male" }; const rowCard = "w-80 shrink-0 cursor-pointer text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring"; +// COSS Card has no `size` variant; recreate the old compact ("sm") density by +// tightening the inner section padding from p-6 → p-4 via data-slot selectors. +const compactCard = + "[&_[data-slot=card-header]]:p-4 [&_[data-slot=card-panel]]:px-4 [&_[data-slot=card-panel]]:pb-4"; + function SectionLabel({ children }: { children: ReactNode }) { return (

@@ -171,7 +176,7 @@ function ExpandableCard({

} + render={} > {children} @@ -529,7 +534,7 @@ function VisitsCard({ patient }: { patient: Patient }) { function LoadingCards() { return ( <> - +
@@ -546,7 +551,7 @@ function LoadingCards() { {[0, 1, 2, 3, 4, 5].map((card) => ( - + @@ -574,7 +579,7 @@ export function PatientResult({ if (status === "not-found") { return ( - +

No patient found for file #{fileNumber}. diff --git a/frontend/components/i18n-provider.tsx b/frontend/components/i18n-provider.tsx new file mode 100644 index 0000000..82f13a9 --- /dev/null +++ b/frontend/components/i18n-provider.tsx @@ -0,0 +1,10 @@ +"use client"; + +import type * as React from "react"; +import { I18nextProvider } from "react-i18next"; + +import i18n from "@/lib/i18n/config"; + +export function I18nProvider({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index 6fcc4fb..1ce2ba4 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -12,12 +13,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@/components/ui/field"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; @@ -26,6 +22,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { + const { t } = useTranslation(); const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -45,10 +42,7 @@ export function LoginForm({ }); if (err) { - setError( - err.message ?? - "Could not sign in. Check your email and password and try again." - ); + setError(err.message ?? t("auth.login.error")); setSubmitting(false); return; } @@ -59,24 +53,26 @@ export function LoginForm({

- Welcome back - Sign in to your clinician account + {t("auth.login.title")} + {t("auth.login.subtitle")}
- +
{error && (

{error}

)} - Email + + {t("auth.login.emailLabel")} + setEmail(e.target.value)} - placeholder="you@clinic.org" + placeholder={t("auth.login.emailPlaceholder")} required type="email" value={email} @@ -84,12 +80,14 @@ export function LoginForm({
- Password + + {t("auth.login.passwordLabel")} + - Forgot your password? + {t("auth.login.forgotPassword")}
- Don't have an account?{" "} - Sign up + {t("auth.login.noAccount")}{" "} + {t("auth.login.signUpLink")} - +
diff --git a/frontend/components/settings/settings-preferences.tsx b/frontend/components/settings/settings-preferences.tsx index ce094b8..0f4ca5d 100644 --- a/frontend/components/settings/settings-preferences.tsx +++ b/frontend/components/settings/settings-preferences.tsx @@ -59,7 +59,7 @@ export function ProfilePanel() {
Avatar - + K diff --git a/frontend/components/settings/settings-view.tsx b/frontend/components/settings/settings-view.tsx index dc4525a..808d539 100644 --- a/frontend/components/settings/settings-view.tsx +++ b/frontend/components/settings/settings-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { @@ -12,14 +13,14 @@ import { CareTeamPanel } from "@/components/settings/settings-care-team"; import { ProfilePanel } from "@/components/settings/settings-preferences"; const TABS = [ - "Profile", - "Records", - "Signing", - "Care team", - "Developers", + { id: "profile", labelKey: "settings.tabs.profile" }, + { id: "records", labelKey: "settings.tabs.records" }, + { id: "signing", labelKey: "settings.tabs.signing" }, + { id: "careTeam", labelKey: "settings.tabs.careTeam" }, + { id: "developers", labelKey: "settings.tabs.developers" }, ] as const; -type Tab = (typeof TABS)[number]; +type Tab = (typeof TABS)[number]["id"]; function PlaceholderPanel({ title, @@ -28,55 +29,59 @@ function PlaceholderPanel({ title: string; description: string; }) { + const { t } = useTranslation(); return ( -

Nothing here yet.

+

{t("settings.empty")}

); } export function SettingsView() { - const [tab, setTab] = useState("Profile"); + const { t } = useTranslation(); + const [tab, setTab] = useState("profile"); return (
-

{tab}

+

+ {t(`settings.tabs.${tab}`)} +

- {tab === "Profile" && } - {tab === "Records" && ( + {tab === "profile" && } + {tab === "records" && ( )} - {tab === "Signing" && } - {tab === "Care team" && } - {tab === "Developers" && ( + {tab === "signing" && } + {tab === "careTeam" && } + {tab === "developers" && ( )}
diff --git a/frontend/components/sidebar-02/app-sidebar.tsx b/frontend/components/sidebar-02/app-sidebar.tsx index b3584e7..4683e42 100644 --- a/frontend/components/sidebar-02/app-sidebar.tsx +++ b/frontend/components/sidebar-02/app-sidebar.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import { motion } from "framer-motion"; import { Plus, Settings, Users } from "lucide-react"; import Image from "next/image"; +import { useTranslation } from "react-i18next"; import type { Route } from "./nav-main"; import DashboardNavigation from "@/components/sidebar-02/nav-main"; import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications"; @@ -42,31 +43,32 @@ const sampleNotifications = [ }, ]; -const dashboardRoutes: Route[] = [ - { - id: "new-chat", - title: "New chat", - icon: , - link: "/", - }, - { - id: "patients", - title: "Patients", - icon: , - link: "/patients", - }, - { - id: "settings", - title: "Settings", - icon: , - link: "/settings", - }, -]; - export function DashboardSidebar() { const { state } = useSidebar(); + const { t } = useTranslation(); const isCollapsed = state === "collapsed"; + const dashboardRoutes: Route[] = [ + { + id: "new-chat", + title: t("nav.newChat"), + icon: , + link: "/", + }, + { + id: "patients", + title: t("nav.patients"), + icon: , + link: "/patients", + }, + { + id: "settings", + title: t("nav.settings"), + icon: , + link: "/settings", + }, + ]; + return ( - }> - - Notifications - + + }> + + {t("nav.notifications")} + {notifications.map(({ id, avatar, fallback, text, time }) => ( - + {fallback} @@ -41,13 +43,13 @@ export function NotificationsPopover({ {text} {time}
- + ))} - - - View all notifications - - - + + + {t("nav.viewAllNotifications")} + + + ); } diff --git a/frontend/components/sidebar-02/nav-user.tsx b/frontend/components/sidebar-02/nav-user.tsx index bba6546..c9c1e4f 100644 --- a/frontend/components/sidebar-02/nav-user.tsx +++ b/frontend/components/sidebar-02/nav-user.tsx @@ -6,14 +6,14 @@ import { useRouter } from "next/navigation"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Menu, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuShortcut, + MenuTrigger, +} from "@/components/ui/menu"; import { SidebarMenu, SidebarMenuButton, @@ -66,8 +66,8 @@ export function NavUser() { return ( - - + )} - - + - + {initials} @@ -107,30 +107,30 @@ export function NavUser() { {email}
- - - }> + + + }> Settings - - + } > Docs & GitHub - - + + Theme - Dark - - - + Dark + + + Log out - - - + + + ); diff --git a/frontend/components/sidebar-02/team-switcher.tsx b/frontend/components/sidebar-02/team-switcher.tsx index 6d81490..a95d2fd 100644 --- a/frontend/components/sidebar-02/team-switcher.tsx +++ b/frontend/components/sidebar-02/team-switcher.tsx @@ -4,13 +4,13 @@ import { Building2, ChevronsUpDown, Plus } from "lucide-react"; import { useRouter } from "next/navigation"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Menu, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "@/components/ui/menu"; import { SidebarMenu, SidebarMenuButton, @@ -38,8 +38,8 @@ export function OrgSwitcher() { return ( - - + )} - - + - + Clinics - + {(orgs ?? []).map((org) => ( - setActive(org.id)} @@ -82,10 +82,10 @@ export function OrgSwitcher() {
{org.name} - + ))} - - + router.push("/onboarding")} > @@ -95,9 +95,9 @@ export function OrgSwitcher() {
Create clinic
-
- - + + + ); diff --git a/frontend/components/signup-form.tsx b/frontend/components/signup-form.tsx index 9ae6a67..7a7a86e 100644 --- a/frontend/components/signup-form.tsx +++ b/frontend/components/signup-form.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -12,12 +13,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@/components/ui/field"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; @@ -28,6 +24,7 @@ export function SignupForm({ className, ...props }: React.ComponentProps<"div">) { + const { t } = useTranslation(); const router = useRouter(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -42,11 +39,11 @@ export function SignupForm({ setError(null); if (password.length < MIN_PASSWORD) { - setError(`Password must be at least ${MIN_PASSWORD} characters.`); + setError(t("auth.signup.passwordTooShort", { count: MIN_PASSWORD })); return; } if (password !== confirm) { - setError("Passwords do not match."); + setError(t("auth.signup.passwordMismatch")); return; } @@ -59,7 +56,7 @@ export function SignupForm({ }); if (err) { - setError(err.message ?? "Could not create your account."); + setError(err.message ?? t("auth.signup.error")); setSubmitting(false); return; } @@ -72,38 +69,40 @@ export function SignupForm({
- Create your account - - Start using temetro in your clinic - + {t("auth.signup.title")} + {t("auth.signup.subtitle")}
- +
{error && (

{error}

)} - Full name + + {t("auth.signup.nameLabel")} + setName(e.target.value)} - placeholder="Dr. Jane Okafor" + placeholder={t("auth.signup.namePlaceholder")} required type="text" value={name} /> - Email + + {t("auth.signup.emailLabel")} + setEmail(e.target.value)} - placeholder="you@clinic.org" + placeholder={t("auth.signup.emailPlaceholder")} required type="email" value={email} @@ -112,7 +111,9 @@ export function SignupForm({ - Password + + {t("auth.signup.passwordLabel")} + - Confirm password + {t("auth.signup.confirmPasswordLabel")} - Must be at least {MIN_PASSWORD} characters long. + {t("auth.signup.passwordHint", { count: MIN_PASSWORD })} - Already have an account? Sign in + {t("auth.signup.haveAccount")}{" "} + {t("auth.signup.signInLink")} - +
diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx new file mode 100644 index 0000000..6459132 --- /dev/null +++ b/frontend/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import type * as React from "react"; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx index 7657c13..4164bba 100644 --- a/frontend/components/ui/accordion.tsx +++ b/frontend/components/ui/accordion.tsx @@ -1,75 +1,68 @@ -import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" +"use client"; -import { cn } from "@/lib/utils" -import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; +import { ChevronDownIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "@/lib/utils"; -function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { - return ( - - ) +export function Accordion( + props: AccordionPrimitive.Root.Props, +): React.ReactElement { + return ; } -function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { +export function AccordionItem({ + className, + ...props +}: AccordionPrimitive.Item.Props): React.ReactElement { return ( - ) + ); } -function AccordionTrigger({ +export function AccordionTrigger({ className, children, ...props -}: AccordionPrimitive.Trigger.Props) { +}: AccordionPrimitive.Trigger.Props): React.ReactElement { return ( {children} - - + - ) + ); } -function AccordionContent({ +export function AccordionPanel({ className, children, ...props -}: AccordionPrimitive.Panel.Props) { +}: AccordionPrimitive.Panel.Props): React.ReactElement { return ( -
- {children} -
+
{children}
- ) + ); } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { AccordionPrimitive, AccordionPanel as AccordionContent }; diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx index b9f98e5..5b6c7d5 100644 --- a/frontend/components/ui/alert.tsx +++ b/frontend/components/ui/alert.tsx @@ -1,76 +1,84 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; const alertVariants = cva( - "group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + "relative grid w-full items-start gap-x-2 gap-y-0.5 rounded-xl border px-3.5 py-3 text-card-foreground text-sm has-[>svg]:has-data-[slot=alert-action]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-data-[slot=alert-action]:grid-cols-[1fr_auto] has-[>svg]:gap-x-2 [&>svg]:h-lh [&>svg]:w-4", { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", - }, - }, defaultVariants: { variant: "default", }, - } -) + variants: { + variant: { + default: + "bg-transparent dark:bg-input/32 [&>svg]:text-muted-foreground", + error: + "border-destructive/32 bg-destructive/4 [&>svg]:text-destructive", + info: "border-info/32 bg-info/4 [&>svg]:text-info", + success: "border-success/32 bg-success/4 [&>svg]:text-success", + warning: "border-warning/32 bg-warning/4 [&>svg]:text-warning", + }, + }, + }, +); -function Alert({ +export function Alert({ className, variant, ...props -}: React.ComponentProps<"div"> & VariantProps) { +}: React.ComponentProps<"div"> & + VariantProps): React.ReactElement { return (
- ) + ); } -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", - className - )} - {...props} - /> - ) -} - -function AlertDescription({ +export function AlertTitle({ className, ...props -}: React.ComponentProps<"div">) { +}: React.ComponentProps<"div">): React.ReactElement { + return ( +
+ ); +} + +export function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">): React.ReactElement { return (
- ) + ); } -function AlertAction({ className, ...props }: React.ComponentProps<"div">) { +export function AlertAction({ + className, + ...props +}: React.ComponentProps<"div">): React.ReactElement { return (
- ) + ); } - -export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/frontend/components/ui/autocomplete.tsx b/frontend/components/ui/autocomplete.tsx new file mode 100644 index 0000000..ca1385c --- /dev/null +++ b/frontend/components/ui/autocomplete.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"; +import { ChevronsUpDownIcon, XIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +export const Autocomplete: typeof AutocompletePrimitive.Root = + AutocompletePrimitive.Root; + +export function AutocompleteInput({ + className, + showTrigger = false, + showClear = false, + startAddon, + size, + triggerProps, + clearProps, + ...props +}: Omit & { + showTrigger?: boolean; + showClear?: boolean; + startAddon?: React.ReactNode; + size?: "sm" | "default" | "lg" | number; + ref?: React.Ref; + triggerProps?: AutocompletePrimitive.Trigger.Props; + clearProps?: AutocompletePrimitive.Clear.Props; +}): React.ReactElement { + const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number; + + return ( + + {startAddon && ( + + )} + } + {...props} + /> + {showTrigger && ( + + + + + + )} + {showClear && ( + + + + )} + + ); +} + +export function AutocompletePopup({ + className, + children, + side = "bottom", + sideOffset = 4, + alignOffset, + align = "start", + anchor, + portalProps, + ...props +}: AutocompletePrimitive.Popup.Props & { + align?: AutocompletePrimitive.Positioner.Props["align"]; + sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]; + alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]; + side?: AutocompletePrimitive.Positioner.Props["side"]; + anchor?: AutocompletePrimitive.Positioner.Props["anchor"]; + portalProps?: AutocompletePrimitive.Portal.Props; +}): React.ReactElement { + return ( + + + + + {children} + + + + + ); +} + +export function AutocompleteItem({ + className, + children, + ...props +}: AutocompletePrimitive.Item.Props): React.ReactElement { + return ( + + {children} + + ); +} + +export function AutocompleteSeparator({ + className, + ...props +}: AutocompletePrimitive.Separator.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteGroup({ + className, + ...props +}: AutocompletePrimitive.Group.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteGroupLabel({ + className, + ...props +}: AutocompletePrimitive.GroupLabel.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteEmpty({ + className, + ...props +}: AutocompletePrimitive.Empty.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteRow({ + className, + ...props +}: AutocompletePrimitive.Row.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteValue({ + ...props +}: AutocompletePrimitive.Value.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteList({ + className, + ...props +}: AutocompletePrimitive.List.Props): React.ReactElement { + return ( + + + + ); +} + +export function AutocompleteClear({ + className, + ...props +}: AutocompletePrimitive.Clear.Props): React.ReactElement { + return ( + + + + ); +} + +export function AutocompleteStatus({ + className, + ...props +}: AutocompletePrimitive.Status.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteCollection({ + ...props +}: AutocompletePrimitive.Collection.Props): React.ReactElement { + return ( + + ); +} + +export function AutocompleteTrigger({ + className, + children, + ...props +}: AutocompletePrimitive.Trigger.Props): React.ReactElement { + return ( + + {children} + + ); +} + +export const useAutocompleteFilter: typeof AutocompletePrimitive.useFilter = + AutocompletePrimitive.useFilter; + +export { AutocompletePrimitive }; diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx index e4fed86..918135e 100644 --- a/frontend/components/ui/avatar.tsx +++ b/frontend/components/ui/avatar.tsx @@ -1,109 +1,52 @@ -"use client" +"use client"; -import * as React from "react" -import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; +import type React from "react"; +import { cn } from "@/lib/utils"; -import { cn } from "@/lib/utils" - -function Avatar({ +export function Avatar({ className, - size = "default", ...props -}: AvatarPrimitive.Root.Props & { - size?: "default" | "sm" | "lg" -}) { +}: AvatarPrimitive.Root.Props): React.ReactElement { return ( - ) + ); } -function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { +export function AvatarImage({ + className, + ...props +}: AvatarPrimitive.Image.Props): React.ReactElement { return ( - ) + ); } -function AvatarFallback({ +export function AvatarFallback({ className, ...props -}: AvatarPrimitive.Fallback.Props) { +}: AvatarPrimitive.Fallback.Props): React.ReactElement { return ( - ) + ); } -function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { - return ( - svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", - className - )} - {...props} - /> - ) -} - -function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AvatarGroupCount({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", - className - )} - {...props} - /> - ) -} - -export { - Avatar, - AvatarImage, - AvatarFallback, - AvatarGroup, - AvatarGroupCount, - AvatarBadge, -} +export { AvatarPrimitive }; diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx index 3deefd0..e01d08f 100644 --- a/frontend/components/ui/badge.tsx +++ b/frontend/components/ui/badge.tsx @@ -1,52 +1,64 @@ -import { mergeProps } from "@base-ui/react/merge-props" -import { useRender } from "@base-ui/react/use-render" -import { cva, type VariantProps } from "class-variance-authority" +"use client"; -import { cn } from "@/lib/utils" +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import type React from "react"; +import { cn } from "@/lib/utils"; -const badgeVariants = cva( - "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", +export const badgeVariants = cva( + "relative inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-sm border border-transparent font-medium outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-3.5 sm:[&_svg:not([class*='size-'])]:size-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [button&,a&]:cursor-pointer [button&,a&]:pointer-coarse:after:absolute [button&,a&]:pointer-coarse:after:size-full [button&,a&]:pointer-coarse:after:min-h-11 [button&,a&]:pointer-coarse:after:min-w-11", { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - secondary: - "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", - destructive: - "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", - outline: - "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", - ghost: - "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", - link: "text-primary underline-offset-4 hover:underline", - }, - }, defaultVariants: { + size: "default", variant: "default", }, - } -) - -function Badge({ - className, - variant = "default", - render, - ...props -}: useRender.ComponentProps<"span"> & VariantProps) { - return useRender({ - defaultTagName: "span", - props: mergeProps<"span">( - { - className: cn(badgeVariants({ variant }), className), + variants: { + size: { + default: + "h-5.5 min-w-5.5 px-[calc(--spacing(1)-1px)] text-sm sm:h-4.5 sm:min-w-4.5 sm:text-xs", + lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm", + sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs sm:h-4 sm:min-w-4 sm:text-[.625rem]", + }, + variant: { + default: + "bg-primary text-primary-foreground [button&,a&]:hover:bg-primary/90", + destructive: + "bg-destructive text-white [button&,a&]:hover:bg-destructive/90", + error: + "bg-destructive/8 text-destructive-foreground dark:bg-destructive/16", + info: "bg-info/8 text-info-foreground dark:bg-info/16", + outline: + "border-input bg-background text-foreground dark:bg-input/32 [button&,a&]:hover:bg-accent/50 dark:[button&,a&]:hover:bg-input/48", + secondary: + "bg-secondary text-secondary-foreground [button&,a&]:hover:bg-secondary/90", + success: "bg-success/8 text-success-foreground dark:bg-success/16", + warning: "bg-warning/8 text-warning-foreground dark:bg-warning/16", }, - props - ), - render, - state: { - slot: "badge", - variant, }, - }) + }, +); + +export interface BadgeProps extends useRender.ComponentProps<"span"> { + variant?: VariantProps["variant"]; + size?: VariantProps["size"]; } -export { Badge, badgeVariants } +export function Badge({ + className, + variant, + size, + render, + ...props +}: BadgeProps): React.ReactElement { + const defaultProps = { + className: cn(badgeVariants({ className, size, variant })), + "data-slot": "badge", + }; + + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">(defaultProps, props), + render, + }); +} diff --git a/frontend/components/ui/button-group.tsx b/frontend/components/ui/button-group.tsx deleted file mode 100644 index 70ef8fe..0000000 --- a/frontend/components/ui/button-group.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { mergeProps } from "@base-ui/react/merge-props" -import { useRender } from "@base-ui/react/use-render" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" -import { Separator } from "@/components/ui/separator" - -const buttonGroupVariants = cva( - "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring", - { - variants: { - orientation: { - horizontal: - "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", - vertical: - "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", - }, - }, - defaultVariants: { - orientation: "horizontal", - }, - } -) - -function ButtonGroup({ - className, - orientation, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function ButtonGroupText({ - className, - render, - ...props -}: useRender.ComponentProps<"div">) { - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">( - { - className: cn( - "flex items-center gap-2 rounded-4xl border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", - className - ), - }, - props - ), - render, - state: { - slot: "button-group-text", - }, - }) -} - -function ButtonGroupSeparator({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - ButtonGroup, - ButtonGroupSeparator, - ButtonGroupText, - buttonGroupVariants, -} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 7e1026b..66bbb89 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -1,56 +1,96 @@ -import { Button as ButtonPrimitive } from "@base-ui/react/button" -import { cva, type VariantProps } from "class-variance-authority" +"use client"; -import { cn } from "@/lib/utils" +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; +import { Spinner } from "@/components/ui/spinner"; -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", +export const buttonVariants = cva( + "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-loading:select-none data-loading:text-transparent sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0", { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", - xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", - icon: "size-9", - "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, defaultVariants: { - variant: "default", size: "default", + variant: "default", }, - } -) + variants: { + size: { + default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8", + icon: "size-9 sm:size-8", + "icon-lg": "size-10 sm:size-9", + "icon-sm": "size-8 sm:size-7", + "icon-xl": + "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", + "icon-xs": + "size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5", + lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9", + sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", + xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", + xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", + }, + variant: { + default: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs hover:bg-primary/90 data-pressed:bg-primary/90 *:data-[slot=button-loading-indicator]:text-primary-foreground [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none", + destructive: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs hover:bg-destructive/90 data-pressed:bg-destructive/90 *:data-[slot=button-loading-indicator]:text-white [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none", + "destructive-outline": + "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:border-destructive/32 hover:bg-destructive/4 data-pressed:border-destructive/32 data-pressed:bg-destructive/4 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none", + ghost: + "border-transparent text-foreground hover:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", + link: "border-transparent text-foreground underline-offset-4 hover:underline data-pressed:underline *:data-[slot=button-loading-indicator]:text-foreground", + outline: + "border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] hover:bg-accent/50 data-pressed:bg-accent/50 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:data-pressed:bg-input/64 dark:hover:bg-input/64 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90 data-pressed:bg-secondary/90 *:data-[slot=button-loading-indicator]:text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80", + }, + }, + }, +); -function Button({ - className, - variant = "default", - size = "default", - ...props -}: ButtonPrimitive.Props & VariantProps) { - return ( - - ) +export interface ButtonProps extends useRender.ComponentProps<"button"> { + variant?: VariantProps["variant"]; + size?: VariantProps["size"]; + loading?: boolean; } -export { Button, buttonVariants } +export function Button({ + className, + variant, + size, + render, + children, + loading = false, + disabled: disabledProp, + ...props +}: ButtonProps): React.ReactElement { + const isDisabled: boolean = Boolean(loading || disabledProp); + const typeValue: React.ButtonHTMLAttributes["type"] = + render ? undefined : "button"; + + const defaultProps = { + children: ( + <> + {children} + {loading && ( + + )} + + ), + className: cn(buttonVariants({ className, size, variant })), + "aria-disabled": loading || undefined, + "data-loading": loading ? "" : undefined, + "data-slot": "button", + disabled: isDisabled, + type: typeValue, + }; + + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">(defaultProps, props), + render, + }); +} diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index 8cbd169..260be33 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -1,100 +1,253 @@ -import * as React from "react" +"use client"; -import { cn } from "@/lib/utils" +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import type React from "react"; +import { cn } from "@/lib/utils"; -function Card({ +export function Card({ className, - size = "default", + render, ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { - return ( -
img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl", - className - )} - {...props} - /> - ) +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", + className, + ), + "data-slot": "card", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardFrame({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 [--clip-bottom:-1rem] [--clip-top:-1rem] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:bg-muted/72 before:shadow-[0_1px_--theme(--color-black/4%)] has-data-[slot=table-container]:overflow-hidden *:data-[slot=card]:-m-px *:data-[slot=table-container]:-m-px *:data-[slot=table-container]:w-[calc(100%+2px)] *:not-first:data-[slot=card]:rounded-t-xl *:not-last:data-[slot=card]:rounded-b-xl *:data-[slot=card]:bg-clip-padding *:data-[slot=card]:shadow-none *:data-[slot=card]:before:hidden *:not-first:data-[slot=card]:before:rounded-t-[calc(var(--radius-xl)-1px)] *:not-last:data-[slot=card]:before:rounded-b-[calc(var(--radius-xl)-1px)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=card]:[clip-path:inset(var(--clip-top)_1px_var(--clip-bottom)_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:last:[--clip-bottom:1px] *:data-[slot=card]:first:[--clip-top:1px]", + className, + ), + "data-slot": "card-frame", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardFrameHeader({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "relative flex grid auto-rows-min grid-rows-[auto_auto] flex-col items-start gap-x-4 px-6 py-4 has-data-[slot=card-frame-action]:grid-cols-[1fr_auto]", + className, + ), + "data-slot": "card-frame-header", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardFrameTitle({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn("self-center font-semibold text-sm", className), + "data-slot": "card-frame-title", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardFrameDescription({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn("self-center text-muted-foreground text-sm", className), + "data-slot": "card-frame-description", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardFrameAction({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "col-start-2 nth-3:row-span-2 nth-3:row-start-1 inline-flex self-center justify-self-end", + className, + ), + "data-slot": "card-frame-action", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardFrameFooter({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn("px-6 py-4", className), + "data-slot": "card-frame-footer", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, +export function CardHeader({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]", + className, + ), + "data-slot": "card-header", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } + +export function CardTitle({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn("font-semibold text-lg leading-none", className), + "data-slot": "card-title", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export function CardDescription({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn("text-muted-foreground text-sm", className), + "data-slot": "card-description", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export function CardAction({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "col-start-2 row-span-2 row-start-1 inline-flex self-start justify-self-end", + className, + ), + "data-slot": "card-action", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export function CardPanel({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "flex-1 p-6 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0", + className, + ), + "data-slot": "card-panel", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export function CardFooter({ + className, + render, + ...props +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "flex items-center p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pt-4", + className, + ), + "data-slot": "card-footer", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export { CardPanel as CardContent }; diff --git a/frontend/components/ui/collapsible.tsx b/frontend/components/ui/collapsible.tsx index 488fb33..606e94b 100644 --- a/frontend/components/ui/collapsible.tsx +++ b/frontend/components/ui/collapsible.tsx @@ -1,21 +1,42 @@ -"use client" +"use client"; -import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; +import type React from "react"; +import { cn } from "@/lib/utils"; -function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { - return +export function Collapsible({ + ...props +}: CollapsiblePrimitive.Root.Props): React.ReactElement { + return ; } -function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { +export function CollapsibleTrigger({ + className, + ...props +}: CollapsiblePrimitive.Trigger.Props): React.ReactElement { return ( - - ) + + ); } -function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { +export function CollapsiblePanel({ + className, + ...props +}: CollapsiblePrimitive.Panel.Props): React.ReactElement { return ( - - ) + + ); } -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export { CollapsiblePrimitive, CollapsiblePanel as CollapsibleContent }; diff --git a/frontend/components/ui/command.tsx b/frontend/components/ui/command.tsx index a00979f..c2fb874 100644 --- a/frontend/components/ui/command.tsx +++ b/frontend/components/ui/command.tsx @@ -1,196 +1,267 @@ -"use client" +"use client"; -import * as React from "react" -import { Command as CommandPrimitive } from "cmdk" - -import { cn } from "@/lib/utils" +import { Dialog as CommandDialogPrimitive } from "@base-ui/react/dialog"; +import { SearchIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - InputGroup, - InputGroupAddon, -} from "@/components/ui/input-group" -import { SearchIcon, CheckIcon } from "lucide-react" + Autocomplete, + AutocompleteCollection, + AutocompleteEmpty, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompleteSeparator, +} from "@/components/ui/autocomplete"; -function Command({ - className, - ...props -}: React.ComponentProps) { +export const CommandDialog: typeof CommandDialogPrimitive.Root = + CommandDialogPrimitive.Root; + +export const CommandDialogPortal: typeof CommandDialogPrimitive.Portal = + CommandDialogPrimitive.Portal; + +export const CommandCreateHandle: typeof CommandDialogPrimitive.createHandle = + CommandDialogPrimitive.createHandle; + +export function CommandDialogTrigger( + props: CommandDialogPrimitive.Trigger.Props, +): React.ReactElement { return ( - - ) + ); } -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", +export function CommandDialogBackdrop({ + className, + ...props +}: CommandDialogPrimitive.Backdrop.Props): React.ReactElement { + return ( + + ); +} + +export function CommandDialogViewport({ + className, + ...props +}: CommandDialogPrimitive.Viewport.Props): React.ReactElement { + return ( + + ); +} + +export function CommandDialogPopup({ + className, children, - className, - showCloseButton = false, + portalProps, ...props -}: Omit, "children"> & { - title?: string - description?: string - className?: string - showCloseButton?: boolean - children: React.ReactNode -}) { +}: CommandDialogPrimitive.Popup.Props & { + portalProps?: CommandDialogPrimitive.Portal.Props; +}): React.ReactElement { return ( - - - {title} - {description} - - - {children} - - - ) -} - -function CommandInput({ - className, - ...props -}: React.ComponentProps) { - return ( -
- - + + + - - - - + > + {children} + + + + ); +} + +export function Command({ + autoHighlight = "always", + keepHighlight = true, + ...props +}: React.ComponentProps): React.ReactElement { + return ( + + ); +} + +export function CommandInput({ + className, + placeholder = undefined, + ...props +}: React.ComponentProps): React.ReactElement { + return ( +
+ } + {...props} + />
- ) + ); } -function CommandList({ +export function CommandList({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps): React.ReactElement { return ( - - ) + ); } -function CommandEmpty({ +export function CommandEmpty({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps): React.ReactElement { return ( - - ) + ); } -function CommandGroup({ +export function CommandPanel({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps<"div">): React.ReactElement { return ( - + ); +} + +export function CommandGroup({ + className, + ...props +}: React.ComponentProps): React.ReactElement { + return ( + - ) + ); } -function CommandSeparator({ +export function CommandGroupLabel({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps): React.ReactElement { return ( - - ) + ); } -function CommandItem({ - className, - children, +export function CommandCollection({ ...props -}: React.ComponentProps) { +}: React.ComponentProps): React.ReactElement { + return ; +} + +export function CommandItem({ + className, + ...props +}: React.ComponentProps): React.ReactElement { return ( - - {children} - - - ) -} - -function CommandShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) + ); } -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, +export function CommandSeparator({ + className, + ...props +}: React.ComponentProps): React.ReactElement { + return ( + + ); } + +export function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"kbd">): React.ReactElement { + return ( + + ); +} + +export function CommandFooter({ + className, + ...props +}: React.ComponentProps<"div">): React.ReactElement { + return ( +
+ ); +} + +export { CommandDialogPrimitive }; diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index d42f830..7b37e9a 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -1,160 +1,220 @@ -"use client" +"use client"; -import * as React from "react" -import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { XIcon } from "lucide-react"; +import type React from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { XIcon } from "lucide-react" +export const DialogCreateHandle: typeof DialogPrimitive.createHandle = + DialogPrimitive.createHandle; -function Dialog({ ...props }: DialogPrimitive.Root.Props) { - return +export const Dialog: typeof DialogPrimitive.Root = DialogPrimitive.Root; + +export const DialogPortal: typeof DialogPrimitive.Portal = + DialogPrimitive.Portal; + +export function DialogTrigger( + props: DialogPrimitive.Trigger.Props, +): React.ReactElement { + return ; } -function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { - return +export function DialogClose( + props: DialogPrimitive.Close.Props, +): React.ReactElement { + return ; } -function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { - return -} - -function DialogClose({ ...props }: DialogPrimitive.Close.Props) { - return -} - -function DialogOverlay({ +export function DialogBackdrop({ className, ...props -}: DialogPrimitive.Backdrop.Props) { +}: DialogPrimitive.Backdrop.Props): React.ReactElement { return ( - ) + ); } -function DialogContent({ +export function DialogViewport({ + className, + ...props +}: DialogPrimitive.Viewport.Props): React.ReactElement { + return ( + + ); +} + +export function DialogPopup({ className, children, showCloseButton = true, + bottomStickOnMobile = true, + closeProps, + portalProps, ...props }: DialogPrimitive.Popup.Props & { - showCloseButton?: boolean -}) { + showCloseButton?: boolean; + bottomStickOnMobile?: boolean; + closeProps?: DialogPrimitive.Close.Props; + portalProps?: DialogPrimitive.Portal.Props; +}): React.ReactElement { return ( - - - + + - {children} - {showCloseButton && ( - - } - > - - Close - - )} - + + {children} + {showCloseButton && ( + } + {...closeProps} + > + + + )} + + - ) + ); } -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ +export function DialogHeader({ className, - showCloseButton = false, - children, + render, ...props -}: React.ComponentProps<"div"> & { - showCloseButton?: boolean -}) { - return ( -
- {children} - {showCloseButton && ( - }> - Close - - )} -
- ) +}: useRender.ComponentProps<"div">): React.ReactElement { + const defaultProps = { + className: cn( + "flex flex-col gap-2 p-6 in-[[data-slot=dialog-popup]:has([data-slot=dialog-panel])]:pb-3 max-sm:pb-4", + className, + ), + "data-slot": "dialog-header", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); } -function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { +export function DialogFooter({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"div"> & { + variant?: "default" | "bare"; +}): React.ReactElement { + const defaultProps = { + className: cn( + "flex flex-col-reverse gap-2 px-6 sm:flex-row sm:justify-end sm:rounded-b-[calc(var(--radius-2xl)-1px)]", + variant === "default" && "border-t bg-muted/72 py-4", + variant === "bare" && + "in-[[data-slot=dialog-popup]:has([data-slot=dialog-panel])]:pt-3 pt-4 pb-6", + className, + ), + "data-slot": "dialog-footer", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + }); +} + +export function DialogTitle({ + className, + ...props +}: DialogPrimitive.Title.Props): React.ReactElement { return ( - ) + ); } -function DialogDescription({ +export function DialogDescription({ className, ...props -}: DialogPrimitive.Description.Props) { +}: DialogPrimitive.Description.Props): React.ReactElement { return ( - ) + ); +} + +export function DialogPanel({ + className, + scrollFade = true, + render, + ...props +}: useRender.ComponentProps<"div"> & { + scrollFade?: boolean; +}): React.ReactElement { + const defaultProps = { + className: cn( + "p-6 in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1 in-[[data-slot=dialog-popup]:has([data-slot=dialog-footer]:not(.border-t))]:pb-1", + className, + ), + "data-slot": "dialog-panel", + }; + + return ( + + {useRender({ + defaultTagName: "div", + props: mergeProps<"div">(defaultProps, props), + render, + })} + + ); } export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} + DialogPrimitive, + DialogBackdrop as DialogOverlay, + DialogPopup as DialogContent, +}; diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx deleted file mode 100644 index fa37d05..0000000 --- a/frontend/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client" - -import * as React from "react" -import { Menu as MenuPrimitive } from "@base-ui/react/menu" - -import { cn } from "@/lib/utils" -import { ChevronRightIcon, CheckIcon } from "lucide-react" - -function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { - return -} - -function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { - return -} - -function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { - return -} - -function DropdownMenuContent({ - align = "start", - alignOffset = 0, - side = "bottom", - sideOffset = 4, - className, - ...props -}: MenuPrimitive.Popup.Props & - Pick< - MenuPrimitive.Positioner.Props, - "align" | "alignOffset" | "side" | "sideOffset" - >) { - return ( - - - - - - ) -} - -function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { - return -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps<"div"> & { - inset?: boolean -}) { - // Base UI's Menu.GroupLabel requires a surrounding Menu.Group (unlike Radix's - // standalone Label). These menus use the label as a plain heading, so render a - //
directly to avoid the "MenuGroupContext is missing" error. - return ( -
- ) -} - -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: MenuPrimitive.Item.Props & { - inset?: boolean - variant?: "default" | "destructive" -}) { - return ( - - ) -} - -function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { - return -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: MenuPrimitive.SubmenuTrigger.Props & { - inset?: boolean -}) { - return ( - - {children} - - - ) -} - -function DropdownMenuSubContent({ - align = "start", - alignOffset = -3, - side = "right", - sideOffset = 0, - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - inset, - ...props -}: MenuPrimitive.CheckboxItem.Props & { - inset?: boolean -}) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { - return ( - - ) -} - -function DropdownMenuRadioItem({ - className, - children, - inset, - ...props -}: MenuPrimitive.RadioItem.Props & { - inset?: boolean -}) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuSeparator({ - className, - ...props -}: MenuPrimitive.Separator.Props) { - return ( - - ) -} - -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} diff --git a/frontend/components/ui/field.tsx b/frontend/components/ui/field.tsx index 5b3ff19..ca97f9c 100644 --- a/frontend/components/ui/field.tsx +++ b/frontend/components/ui/field.tsx @@ -1,238 +1,80 @@ -"use client" +"use client"; -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" +import { Field as FieldPrimitive } from "@base-ui/react/field"; +import type React from "react"; +import { cn } from "@/lib/utils"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" - -function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { - return ( -
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className - )} - {...props} - /> - ) -} - -function FieldLegend({ +export function Field({ className, - variant = "legend", ...props -}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { +}: FieldPrimitive.Root.Props): React.ReactElement { return ( - - ) -} - -function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -const fieldVariants = cva( - "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", - { - variants: { - orientation: { - vertical: "flex-col *:w-full [&>.sr-only]:w-auto", - horizontal: - "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - responsive: - "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - }, - }, - defaultVariants: { - orientation: "vertical", - }, - } -) - -function Field({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) + ); } -function FieldContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function FieldLabel({ +export function FieldLabel({ className, ...props -}: React.ComponentProps) { +}: FieldPrimitive.Label.Props): React.ReactElement { return ( -