Commit 8b2b03d2 by Calcium-Ion Committed by GitHub

feat(web/default): unified UI overhaul — Base UI migration, theme presets,…

feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)

* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges

Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage.

Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx.

Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark).

Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi.

* 🎨 fix(web): align segmented controls with theme radius tokens

- Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart
  toolbars with radius-md so the active state follows --radius when users
  change Radius in Theme Settings.
- Use nested radii consistent with TabsList/TabsTrigger: outer
  rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px))
  so the track and active thumb stay concentric at small scales (e.g.
  0.3rem) instead of a squared “focus” block inside a rounded shell.
- Apply the same pattern to pricing SegmentedControl and the segmented
  groups in consumption-distribution-chart, model-charts, and user-charts.

Verified: bun run typecheck (web/default)

*  feat(pricing): enrich model details with uptime sparkline and API documentation

Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with
per-day tooltips, surface it in a status row under quick stats and in the
per-group performance table, and extend mock data so uptime series are stable
and optionally scoped by group.

Introduce an API tab with Shiki-highlighted code samples (cURL, Python,
TypeScript, JavaScript), endpoint-type switching, authentication guidance, a
supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer
vendor, tokenizer, license, and data-retention hints for a provider & data
privacy card on the Overview tab (capabilities/modalities stay with model
identity; rate limits stay with the API tab).

Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi.

* 🏆 feat(rankings): add comprehensive rankings dashboard

Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending.

Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales.

* fix(theme): correct theme preset selection state

- update Base UI Radio selectors to use data-checked/data-unchecked states.
- fix unchecked theme options still showing selected indicators.
- isolate the default theme preview tokens to prevent preset changes from leaking into it.

* fix(setup): correct usage mode radio state

- use Base UI data-checked/data-unchecked states for RadioGroup styling.
- hide radio indicators when options are unchecked to avoid setup page display issues.
- drive usage mode card and icon selection styles from Base UI state.

* fix(auth): submit sign-in and sign-up forms

* 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization

Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`)
and reinstall full component registry with `--overwrite`, including Hugeicons-backed
widgets and newly added registry components.

- Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies,
  preset UI from config drawer); rely on official semantic CSS tokens + light/dark only.
- Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and
  `@theme inline` mappings (plus skeleton token vars for existing shimmer usage).
- Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`;
  clarify scroll-lock override comment.

Application compatibility:
- Keep minimal shims where app code diverged from official APIs (popover collision props,
  combobox legacy `options` callers, Spinner prop typing).
- Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI
  semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.)

Tooling / docs / build:
- Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`.
- Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack.
- Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.)

Verified: `bun run typecheck` passes.

Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere;
not addressed in this change.

* 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters

- Refactor DataTableToolbar to a single wrapping flex row with a
  right-aligned action cluster (Reset / Search / View / Expand) for a
  cleaner Ant Design Pro–style filter bar; remove the dedicated stats row
  and the toolbar `stats` prop.
- Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive-
  data visibility toggle into the page header via CommonLogsHeaderActions
  and SectionPageLayout.Actions so the toolbar stays focused on filters.
- Slim CommonLogsFilterBar props (no stats / preActions eye control).
- Improve CompactDateTimeRangePicker: show minute-precision labels on the
  trigger (seconds omitted; aligns with datetime-local inputs); widen the
  trigger on sm+ breakpoints so the full range is visible without truncation;
  apply the same width in task logs filters.
- Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon.
- Earlier layout tweak: extra top padding on SectionPageLayout scroll
  content so control focus rings are not clipped by overflow.

* feat(web/default): Base UI migration and component foundation

Migrate from Radix UI to Base UI, rewrite core UI primitives,
update dependencies (recharts, date-fns, next-themes), add
shadcn agent skill documentation, and refresh AI element components.

This is the foundational work from the v2/localmain lineage that
was not covered by the individual feature commits above.

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
parent dac55f0f
......@@ -26,7 +26,7 @@ Read every changed file in `web/classic`. Identify the **logical changes** (new
For each logical change found in Step 1, locate the equivalent file(s) in `web/default/src/`. Use Glob/Grep/SemanticSearch as needed. Consider that:
- `web/classic` uses **React 18 + Vite + Semi Design**
- `web/default` uses **React 19 + Rsbuild + Radix UI + Tailwind CSS**
- `web/default` uses **React 19 + Rsbuild + Base UI + Tailwind CSS**
- Component names, file paths, and API shapes may differ; match by **functionality**, not filename.
### Step 3 — Triage each change
......@@ -46,7 +46,7 @@ For each **⚠️** or **❌** item:
1. **Read the target file(s) in `web/default`** before editing (required by project conventions).
2. Implement using `web/default` conventions:
- React 19 patterns (hooks, Suspense, etc.)
- Radix UI primitives where applicable
- Base UI primitives where applicable
- Tailwind CSS for styling (no inline styles or Semi Design imports)
- `useTranslation()` + `t('English key')` for all user-visible strings
- TypeScript — explicit types, no `any`
......
---
name: shadcn-ui
description: >-
Give the assistant project-aware shadcn/ui context: components.json,
composition patterns, CLI, registries, theming, and MCP. Use when working on
web/default UI, shadcn components, or presets. Overview aligns with
https://ui.shadcn.com/docs/skills.md; full upstream skill text is vendored
under vendor/shadcn/.
---
<!-- Canonical overview: https://ui.shadcn.com/docs/skills.md -->
# Skills (shadcn/ui)
Skills give AI assistants project-aware context about shadcn/ui. When used, the assistant knows how to find, install, compose, and customize components using the correct APIs and patterns for your project.
For example, you can ask:
- _"Add a login form with email and password fields."_
- _"Create a settings page with a form for updating profile information."_
- _"Build a dashboard with a sidebar, stats cards, and a data table."_
- _"Switch to --preset [CODE]"_
- _"Can you add a hero from @tailark?"_
The skill reads your project's `components.json` and provides your framework, aliases, installed components, icon library, and base library so it can generate correct code on the first try.
---
## Install (ecosystem vs this repo)
Official install from [Skills — shadcn/ui](https://ui.shadcn.com/docs/skills.md):
```bash
npx skills add shadcn/ui
```
That installs the skill where the `skills` CLI is available. **This repository** keeps the same intent under `.agents/skills/shadcn-ui/` (overview here + **vendored** upstream docs in [`vendor/shadcn/`](./vendor/shadcn/)) and runs the shadcn CLI from the frontend app root:
```bash
cd web/default && bunx shadcn@latest info --json
```
Learn more about skills at [skills.sh](https://skills.sh).
---
## What's included (and where)
### Project context
Run **`shadcn info --json`** (here: `cd web/default && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths.
### CLI commands
Full command reference (vendored): [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md).
### Theming and customization
Vendored: [`vendor/shadcn/customization.md`](./vendor/shadcn/customization.md). Live docs: [Theming](https://ui.shadcn.com/docs/theming).
### Registry authoring
Not duplicated as a single file in the vendor tree; see [Registry](https://ui.shadcn.com/docs/registry) and `build` in [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md).
### MCP server
Vendored: [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md). Live docs: [MCP Server](https://ui.shadcn.com/docs/mcp).
---
## How it works
1. **Project detection** — Applies when `components.json` exists (here: `web/default/components.json`).
2. **Context injection** — Use `shadcn info --json` as ground truth for imports and APIs.
3. **Pattern enforcement** — Follow rules in [`vendor/shadcn/SKILL.md`](./vendor/shadcn/SKILL.md) and [`vendor/shadcn/rules/`](./vendor/shadcn/rules/).
4. **Component discovery**`shadcn docs`, `shadcn search`, MCP, or registries — see vendored SKILL + MCP doc.
---
## Learn more (web)
- [CLI](https://ui.shadcn.com/docs/cli) — complements [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md)
- [Theming](https://ui.shadcn.com/docs/theming)
- [Registry](https://ui.shadcn.com/docs/registry)
- [skills.sh](https://skills.sh)
---
## Vendored upstream bundle (deep rules)
Snapshot from [shadcn-ui/ui `skills/shadcn`](https://github.com/shadcn-ui/ui/tree/main/skills/shadcn); revision note in [`vendor/shadcn/UPSTREAM.txt`](./vendor/shadcn/UPSTREAM.txt).
| Doc | Path |
| --- | --- |
| Full official skill body | [`vendor/shadcn/SKILL.md`](./vendor/shadcn/SKILL.md) |
| CLI reference | [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md) |
| Theming / customization | [`vendor/shadcn/customization.md`](./vendor/shadcn/customization.md) |
| MCP | [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md) |
| Forms | [`vendor/shadcn/rules/forms.md`](./vendor/shadcn/rules/forms.md) |
| Composition | [`vendor/shadcn/rules/composition.md`](./vendor/shadcn/rules/composition.md) |
| Icons | [`vendor/shadcn/rules/icons.md`](./vendor/shadcn/rules/icons.md) |
| Styling | [`vendor/shadcn/rules/styling.md`](./vendor/shadcn/rules/styling.md) |
| Base vs Radix | [`vendor/shadcn/rules/base-vs-radix.md`](./vendor/shadcn/rules/base-vs-radix.md) |
**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web/default`, Bun). Read **`vendor/shadcn/SKILL.md`** for the complete upstream workflow, patterns, and CLI quick reference. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup.
Source: https://github.com/shadcn-ui/ui/tree/56161142f1b83f612462772d18883807b5f0d601/skills/shadcn
Branch: main
Fetched: 2026-04-29
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest apply --preset a2r6bw
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset and overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">
Click
</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
# shadcn MCP Server
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
---
## Setup
```bash
shadcn mcp # start the MCP server (stdio)
shadcn mcp init # write config for your editor
```
Editor config files:
| Editor | Config file |
|--------|------------|
| Claude Code | `.mcp.json` |
| Cursor | `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |
| OpenCode | `opencode.json` |
| Codex | `~/.codex/config.toml` (manual) |
---
## Tools
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
### `shadcn:get_project_registries`
Returns registry names from `components.json`. Errors if no `components.json` exists.
**Input:** none
### `shadcn:list_items_in_registries`
Lists all items from one or more registries.
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries.
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
### `shadcn:view_items_in_registries`
View item details including full file contents.
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code.
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`
Returns the CLI install command.
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
### `shadcn:get_audit_checklist`
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
**Input:** none
---
## Configuring Registries
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
```json
{
"registries": {
"@acme": "https://acme.com/r/{name}.json",
"@private": {
"url": "https://private.com/r/{name}.json",
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
}
}
}
```
- Names must start with `@`.
- URLs must contain `{name}`.
- `${VAR}` references are resolved from environment variables.
Community registry index: `https://ui.shadcn.com/r/registries.json`
# Base vs Radix
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
## Contents
- Composition: asChild vs render
- Button / trigger as non-button element
- Select (items prop, placeholder, positioning, multiple, object values)
- ToggleGroup (type vs multiple)
- Slider (scalar vs array)
- Accordion (type and defaultValue)
---
## Composition: asChild (radix) vs render (base)
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
**Incorrect:**
```tsx
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
```
**Correct (radix):**
```tsx
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
```
**Correct (base):**
```tsx
<DialogTrigger render={<Button />}>Open</DialogTrigger>
```
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
---
## Button / trigger as non-button element (base only)
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
**Incorrect (base):** missing `nativeButton={false}`.
```tsx
<Button render={<a href="/docs" />}>Read the docs</Button>
```
**Correct (base):**
```tsx
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
```
**Correct (radix):**
```tsx
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
```
Same for triggers whose `render` is not a `Button`:
```tsx
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
```
---
## Select
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
**Incorrect (base):**
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
```
**Correct (base):**
```tsx
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
```
**Correct (radix):**
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
```tsx
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
```
---
## Select — multiple selection and object values (base only)
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
**Correct (base — multiple selection):**
```tsx
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
```
**Correct (base — object values):**
```tsx
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
```
---
## ToggleGroup
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
**Incorrect (base):**
```tsx
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
```
**Correct (base):**
```tsx
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Correct (radix):**
```tsx
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Controlled single value:**
```tsx
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
```
---
## Slider
Base accepts a plain number for a single thumb. Radix always requires an array.
**Incorrect (base):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
**Correct (base):**
```tsx
<Slider defaultValue={50} max={100} step={1} />
```
**Correct (radix):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
```tsx
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
```
---
## Accordion
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
**Incorrect (base):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
**Correct (base):**
```tsx
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
```
**Correct (radix):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- TabsTrigger must be inside TabsList
- Avatar always needs AvatarFallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```tsx
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
```
**Correct:**
```tsx
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
```
This applies to all group-based components:
| Item | Group |
|------|-------|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
| `MenubarItem` | `MenubarGroup` |
| `ContextMenuItem` | `ContextMenuGroup` |
| `CommandItem` | `CommandGroup` |
---
## Callouts use Alert
```tsx
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
```
---
## Empty states use Empty component
```tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
```
---
## Toast notifications use sonner
```tsx
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
```
---
## Choosing between overlay components
| Use case | Component |
|----------|-----------|
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
```tsx
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
```
---
## Card structure
Use full composition — don't dump everything into `CardContent`:
```tsx
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` + `data-icon` + `disabled`:
```tsx
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## TabsTrigger must be inside TabsList
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
```tsx
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
```
---
## Avatar always needs AvatarFallback
Always include `AvatarFallback` for when the image fails to load:
```tsx
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
|---|---|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
# Forms & Inputs
## Contents
- Forms use FieldGroup + Field
- InputGroup requires InputGroupInput/InputGroupTextarea
- Buttons inside inputs use InputGroup + InputGroupAddon
- Option sets (2–7 choices) use ToggleGroup
- FieldSet + FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use FieldGroup + Field
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
```tsx
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
```
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroupInput/InputGroupTextarea
Never use raw `Input` or `Textarea` inside an `InputGroup`.
**Incorrect:**
```tsx
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
```
---
## Buttons inside inputs use InputGroup + InputGroupAddon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```tsx
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
```
---
## Option sets (2–7 choices) use ToggleGroup
Don't manually loop `Button` components with active state.
**Incorrect:**
```tsx
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
```
**Correct:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
```
Combine with `Field` for labelled toggle groups:
```tsx
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
```
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
---
## FieldSet + FieldLegend for grouping related fields
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```tsx
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```tsx
// Invalid.
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// Disabled.
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide``lucide-react`, `tabler``@tabler/icons-react`, etc. Never assume `lucide-react`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start"/>
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end"/>
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="size-4" data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon className="mr-2 size-4" />
Settings
</DropdownMenuItem>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
```
---
## Pass icons as component objects, not string keys
Use `icon={CheckIcon}`, not a string key to a lookup map.
**Incorrect:**
```tsx
const iconMap = {
check: CheckIcon,
alert: AlertIcon,
}
function StatusBadge({ icon }: { icon: string }) {
const Icon = iconMap[icon]
return <Icon />
}
<StatusBadge icon="check" />
```
**Correct:**
```tsx
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
import { CheckIcon } from "lucide-react"
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
return <Icon />
}
<StatusBadge icon={CheckIcon} />
```
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- className for layout only
- No space-x-* / space-y-*
- Prefer size-* over w-* h-* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
---
## Semantic colors
**Incorrect:**
```tsx
<div className="bg-blue-500 text-white">
<p className="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```tsx
<div className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```tsx
<span className="text-emerald-600">+20.1%</span>
<span className="text-green-500">Active</span>
<span className="text-red-600">-3.2%</span>
```
**Correct:**
```tsx
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span className="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```tsx
<Button className="border border-input bg-transparent hover:bg-accent">
Click me
</Button>
```
**Correct:**
```tsx
<Button variant="outline">Click me</Button>
```
---
## className for layout only
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```tsx
<Card className="bg-blue-100 text-blue-900 font-bold">
<CardContent>Dashboard</CardContent>
</Card>
```
**Correct:**
```tsx
<Card className="max-w-md mx-auto">
<CardContent>Dashboard</CardContent>
</Card>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants**`variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens**`bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-* / space-y-*
Use `gap-*` instead. `space-y-4``flex flex-col gap-4`. `space-x-2``flex gap-2`.
```tsx
<div className="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-* over w-* h-* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
**Incorrect:**
```tsx
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```tsx
import { cn } from "@/lib/utils"
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
......@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
- **Frontend**: React 19, TypeScript, Rsbuild, Radix UI, Tailwind CSS
- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
- **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
......@@ -34,7 +34,7 @@ i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container
web/default/ — Default frontend (React 19, Rsbuild, Radix UI, Tailwind)
web/default/ — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
```
......
......@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
- **Frontend**: React 19, TypeScript, Rsbuild, Radix UI, Tailwind CSS
- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
- **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
......@@ -34,7 +34,7 @@ i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container
web/default/ — Default frontend (React 19, Rsbuild, Radix UI, Tailwind)
web/default/ — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
```
......
......@@ -17,7 +17,7 @@
| 表格与列表 | @tanstack/react-table、@tanstack/react-virtual |
| 国际化 | i18next、react-i18next、i18next-browser-languagedetector |
| 日期 | Day.js |
| UI 与样式 | Radix UI、Lucide React、Tailwind CSS、clsx / class-variance-authority |
| UI 与样式 | Base UI、Hugeicons、Tailwind CSS、clsx / class-variance-authority |
| 表单 | React Hook Form、Zod |
| 图表 | @visactor/vchart、@visactor/react-vchart |
| 工具 | qrcode.react、prettier、eslint、vitest(可选)|
......
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
......
......@@ -16,31 +16,12 @@
"knip": "knip"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6",
"@lobehub/icons": "^4.0.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.23",
......@@ -54,6 +35,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
......@@ -61,6 +43,7 @@
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
......@@ -69,7 +52,9 @@
"react-i18next": "^16.5.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.0",
"react-top-loading-bar": "^3.0.2",
"recharts": "3.8.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
......
......@@ -34,9 +34,9 @@ export default defineConfig(({ envMode }) => {
priority: 0,
enforce: true,
},
'vendor-radix': {
test: /node_modules[\\/]@radix-ui[\\/]/,
name: 'vendor-radix',
'vendor-ui-primitives': {
test: /node_modules[\\/](@base-ui|@radix-ui)[\\/]/,
name: 'vendor-ui-primitives',
chunks: 'all',
priority: 0,
enforce: true,
......
......@@ -12,7 +12,7 @@ export function IconThemeSystem({
viewBox='0 0 79.86 51.14'
className={cn(
'overflow-hidden rounded-[6px]',
'stroke-primary fill-primary group-data-[state=unchecked]:stroke-muted-foreground group-data-[state=unchecked]:fill-muted-foreground',
'stroke-primary fill-primary group-data-unchecked:stroke-muted-foreground group-data-unchecked:fill-muted-foreground',
className
)}
{...props}
......
......@@ -52,7 +52,7 @@ export const Action = ({
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
......
......@@ -129,7 +129,7 @@ export const ArtifactAction = ({
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
......
......@@ -7,13 +7,13 @@ import {
useContext,
useMemo,
} from 'react'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
type LucideIcon,
} from 'lucide-react'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import {
......@@ -197,7 +197,7 @@ export const ChainOfThoughtContent = memo(
<CollapsibleContent
className={cn(
'mt-2 space-y-3',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
......
......@@ -57,7 +57,7 @@ export const Context = ({
modelId,
}}
>
<HoverCard closeDelay={0} openDelay={0} {...props} />
<HoverCard {...props} />
</ContextContext.Provider>
)
......@@ -114,16 +114,22 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
}).format(usedPercent)
return (
<HoverCardTrigger asChild>
{children ?? (
<HoverCardTrigger
delay={0}
closeDelay={0}
render={
<Button type='button' variant='ghost' {...props}>
<span className='text-muted-foreground font-medium'>
{renderedPercent}
</span>
<ContextIcon />
{children ?? (
<>
<span className='text-muted-foreground font-medium'>
{renderedPercent}
</span>
<ContextIcon />
</>
)}
</Button>
)}
</HoverCardTrigger>
}
/>
)
}
......
......@@ -52,7 +52,7 @@ export const InlineCitationText = ({
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
<HoverCard {...props} />
)
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
......@@ -64,22 +64,26 @@ export const InlineCitationCardTrigger = ({
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn('ml-1 rounded-full', className)}
variant='secondary'
{...props}
>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
</HoverCardTrigger>
<HoverCardTrigger
delay={0}
closeDelay={0}
render={
<Badge
className={cn('ml-1 rounded-full', className)}
variant='secondary'
{...props}
>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
}
/>
)
export type InlineCitationCardBodyProps = ComponentProps<'div'>
......
......@@ -235,14 +235,19 @@ export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => {
const { t } = useTranslation()
return (
<DropdownMenuTrigger {...props} asChild>
{children ?? (
<DropdownMenuTrigger
{...props}
render={
<Button type='button' variant='outline'>
{t('Open in chat')}
<ChevronDownIcon className='ml-2 size-4' />
{children ?? (
<>
{t('Open in chat')}
<ChevronDownIcon className='ml-2 size-4' />
</>
)}
</Button>
)}
</DropdownMenuTrigger>
}
/>
)
}
......@@ -251,17 +256,20 @@ export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className='flex items-center gap-2'
href={providers.chatgpt.createUrl(query)}
rel='noopener'
target='_blank'
>
<span className='shrink-0'>{providers.chatgpt.icon}</span>
<span className='flex-1'>{providers.chatgpt.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</a>
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.chatgpt.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.chatgpt.icon}</span>
<span className='flex-1'>{providers.chatgpt.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
......@@ -271,17 +279,20 @@ export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className='flex items-center gap-2'
href={providers.claude.createUrl(query)}
rel='noopener'
target='_blank'
>
<span className='shrink-0'>{providers.claude.icon}</span>
<span className='flex-1'>{providers.claude.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</a>
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.claude.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.claude.icon}</span>
<span className='flex-1'>{providers.claude.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
......@@ -291,17 +302,20 @@ export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className='flex items-center gap-2'
href={providers.t3.createUrl(query)}
rel='noopener'
target='_blank'
>
<span className='shrink-0'>{providers.t3.icon}</span>
<span className='flex-1'>{providers.t3.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</a>
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.t3.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.t3.icon}</span>
<span className='flex-1'>{providers.t3.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
......@@ -311,17 +325,20 @@ export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className='flex items-center gap-2'
href={providers.scira.createUrl(query)}
rel='noopener'
target='_blank'
>
<span className='shrink-0'>{providers.scira.icon}</span>
<span className='flex-1'>{providers.scira.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</a>
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.scira.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.scira.icon}</span>
<span className='flex-1'>{providers.scira.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
......@@ -331,17 +348,20 @@ export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className='flex items-center gap-2'
href={providers.v0.createUrl(query)}
rel='noopener'
target='_blank'
>
<span className='shrink-0'>{providers.v0.icon}</span>
<span className='flex-1'>{providers.v0.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</a>
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.v0.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.v0.icon}</span>
<span className='flex-1'>{providers.v0.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
......@@ -351,17 +371,20 @@ export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className='flex items-center gap-2'
href={providers.cursor.createUrl(query)}
rel='noopener'
target='_blank'
>
<span className='shrink-0'>{providers.cursor.icon}</span>
<span className='flex-1'>{providers.cursor.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</a>
<DropdownMenuItem
{...props}
render={
<a
className='flex items-center gap-2'
href={providers.cursor.createUrl(query)}
rel='noopener'
target='_blank'
/>
}
>
<span className='shrink-0'>{providers.cursor.icon}</span>
<span className='flex-1'>{providers.cursor.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem>
)
}
......@@ -46,8 +46,12 @@ export const Plan = ({
...props
}: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}>
<Collapsible asChild data-slot='plan' {...props}>
<Card className={cn('shadow-none', className)}>{children}</Card>
<Collapsible
data-slot='plan'
{...props}
render={<Card className={cn('shadow-none', className)} />}
>
{children}
</Collapsible>
</PlanContext.Provider>
)
......@@ -113,9 +117,9 @@ export const PlanAction = (props: PlanActionProps) => (
export type PlanContentProps = ComponentProps<typeof CardContent>
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild>
<CardContent data-slot='plan-content' {...props} />
</CollapsibleContent>
<CollapsibleContent
render={<CardContent data-slot='plan-content' {...props} />}
></CollapsibleContent>
)
export type PlanFooterProps = ComponentProps<'div'>
......@@ -129,17 +133,19 @@ export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
const { t } = useTranslation()
return (
<CollapsibleTrigger asChild>
<Button
className={cn('size-8', className)}
data-slot='plan-trigger'
size='icon'
variant='ghost'
{...props}
>
<ChevronsUpDownIcon className='size-4' />
<span className='sr-only'>{t('Toggle plan')}</span>
</Button>
<CollapsibleTrigger
render={
<Button
className={cn('size-8', className)}
data-slot='plan-trigger'
size='icon'
variant='ghost'
/>
}
{...props}
>
<ChevronsUpDownIcon className='size-4' />
<span className='sr-only'>{t('Toggle plan')}</span>
</CollapsibleTrigger>
)
}
......@@ -277,49 +277,51 @@ export function PromptInputAttachment({
return (
<PromptInputHoverCard>
<HoverCardTrigger asChild>
<div
className={cn(
'group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-default items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none',
className
)}
key={data.id}
{...props}
>
<div className='relative size-5 shrink-0'>
<div className='bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0'>
{isImage ? (
<img
alt={filename || 'attachment'}
className='size-5 object-cover'
height={20}
src={data.url}
width={20}
/>
) : (
<div className='text-muted-foreground flex size-5 items-center justify-center'>
<PaperclipIcon className='size-3' />
</div>
)}
</div>
<Button
aria-label={t('Remove attachment')}
className='absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5'
onClick={(e) => {
e.stopPropagation()
attachments.remove(data.id)
}}
type='button'
variant='ghost'
>
<XIcon />
<span className='sr-only'>{t('Remove')}</span>
</Button>
<PromptInputHoverCardTrigger
render={
<div
className={cn(
'group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-default items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none',
className
)}
key={data.id}
{...props}
/>
}
>
<div className='relative size-5 shrink-0'>
<div className='bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0'>
{isImage ? (
<img
alt={filename || 'attachment'}
className='size-5 object-cover'
height={20}
src={data.url}
width={20}
/>
) : (
<div className='text-muted-foreground flex size-5 items-center justify-center'>
<PaperclipIcon className='size-3' />
</div>
)}
</div>
<span className='flex-1 truncate'>{attachmentLabel}</span>
<Button
aria-label={t('Remove attachment')}
className='absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5'
onClick={(e) => {
e.stopPropagation()
attachments.remove(data.id)
}}
type='button'
variant='ghost'
>
<XIcon />
<span className='sr-only'>{t('Remove')}</span>
</Button>
</div>
</HoverCardTrigger>
<span className='flex-1 truncate'>{attachmentLabel}</span>
</PromptInputHoverCardTrigger>
<PromptInputHoverCardContent className='w-auto p-2'>
<div className='w-auto space-y-3'>
{isImage && (
......@@ -956,10 +958,10 @@ export const PromptInputActionMenuTrigger = ({
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className='size-4' />}
</PromptInputButton>
<DropdownMenuTrigger
render={<PromptInputButton className={className} {...props} />}
>
{children ?? <PlusIcon className='size-4' />}
</DropdownMenuTrigger>
)
......@@ -1242,21 +1244,21 @@ export const PromptInputModelSelectValue = ({
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>
export const PromptInputHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
export const PromptInputHoverCard = (props: PromptInputHoverCardProps) => (
<HoverCard {...props} />
)
export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>
export const PromptInputHoverCardTrigger = (
props: PromptInputHoverCardTriggerProps
) => <HoverCardTrigger {...props} />
export const PromptInputHoverCardTrigger = ({
delay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardTriggerProps) => (
<HoverCardTrigger delay={delay} closeDelay={closeDelay} {...props} />
)
export type PromptInputHoverCardContentProps = ComponentProps<
typeof HoverCardContent
......
......@@ -212,18 +212,20 @@ export const QueueSectionTrigger = ({
className,
...props
}: QueueSectionTriggerProps) => (
<CollapsibleTrigger asChild>
<Button
variant='ghost'
className={cn(
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left',
className
)}
type='button'
{...props}
>
{children}
</Button>
<CollapsibleTrigger
render={
<Button
variant='ghost'
className={cn(
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left',
className
)}
type='button'
{...props}
/>
}
>
{children}
</CollapsibleTrigger>
)
......@@ -242,7 +244,7 @@ export const QueueSectionLabel = ({
...props
}: QueueSectionLabelProps) => (
<span className={cn('flex items-center gap-2', className)} {...props}>
<ChevronDownIcon className='size-4 transition-transform group-data-[state=closed]:-rotate-90' />
<ChevronDownIcon className='size-4 -rotate-90 transition-transform group-data-[panel-open]:rotate-0' />
{icon}
<span>
{count} {label}
......
......@@ -8,8 +8,8 @@ import {
useEffect,
useState,
} from 'react'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
import {
Collapsible,
......@@ -171,7 +171,7 @@ export const ReasoningContent = memo(
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
......
......@@ -56,7 +56,7 @@ export const SourcesContent = ({
<CollapsibleContent
className={cn(
'mt-3 flex w-fit flex-col gap-2',
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
......
......@@ -55,15 +55,21 @@ export const TaskTrigger = ({
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
{children ?? (
<CollapsibleTrigger
className={cn('group', className)}
{...props}
render={
<div className='text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors'>
<SearchIcon className='size-4' />
<p className='text-sm'>{title}</p>
<ChevronDownIcon className='size-4 transition-transform group-data-[state=open]:rotate-180' />
{children ?? (
<>
<SearchIcon className='size-4' />
<p className='text-sm'>{title}</p>
<ChevronDownIcon className='size-4 transition-transform group-data-[panel-open]:rotate-180' />
</>
)}
</div>
)}
</CollapsibleTrigger>
}
/>
)
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>
......@@ -75,7 +81,7 @@ export const TaskContent = ({
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
......
......@@ -81,7 +81,7 @@ export const ToolHeader = ({
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
'flex w-full items-center justify-between gap-4 p-3',
'group flex w-full items-center justify-between gap-4 p-3',
className
)}
{...props}
......@@ -93,7 +93,7 @@ export const ToolHeader = ({
</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className='text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-180' />
<ChevronDownIcon className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180' />
</CollapsibleTrigger>
)
......@@ -102,7 +102,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-popover-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
{...props}
......
......@@ -113,17 +113,19 @@ export const WebPreviewNavigationButton = ({
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className='hover:text-foreground h-8 w-8 p-0'
disabled={disabled}
onClick={onClick}
size='sm'
variant='ghost'
{...props}
>
{children}
</Button>
<TooltipTrigger
render={
<Button
className='hover:text-foreground h-8 w-8 p-0'
disabled={disabled}
onClick={onClick}
size='sm'
variant='ghost'
{...props}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
......@@ -225,24 +227,26 @@ export const WebPreviewConsole = ({
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className='hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium'
variant='ghost'
>
{t('Console')}
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
<CollapsibleTrigger
render={
<Button
className='hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium'
variant='ghost'
/>
</Button>
}
>
{t('Console')}
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'px-4 pb-4',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none'
'data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-open:animate-in outline-none'
)}
>
<div className='max-h-48 space-y-1 overflow-y-auto'>
......
......@@ -6,6 +6,7 @@ import { useSearch } from '@/context/search-provider'
import { useTheme } from '@/context/theme-provider'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
......@@ -38,62 +39,66 @@ export function CommandMenu() {
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t('Type a command or search...')} />
<CommandList>
<ScrollArea type='hover' className='h-72 pe-1'>
<CommandEmpty>{t('No results found.')}</CommandEmpty>
{navGroups.map((group) => (
<CommandGroup key={group.id || group.title} heading={group.title}>
{group.items.map((navItem, i) => {
if (navItem.url)
return (
<Command>
<CommandInput placeholder={t('Type a command or search...')} />
<CommandList>
<ScrollArea className='h-72 pe-1'>
<CommandEmpty>{t('No results found.')}</CommandEmpty>
{navGroups.map((group) => (
<CommandGroup key={group.id || group.title} heading={group.title}>
{group.items.map((navItem, i) => {
if (navItem.url)
return (
<CommandItem
key={`${navItem.url}-${i}`}
value={navItem.title}
onSelect={() => {
runCommand(() => navigate({ to: navItem.url }))
}}
>
<div className='flex size-4 items-center justify-center'>
<ArrowRight className='text-muted-foreground/80 size-2' />
</div>
{navItem.title}
</CommandItem>
)
return navItem.items?.map((subItem, i) => (
<CommandItem
key={`${navItem.url}-${i}`}
value={navItem.title}
key={`${navItem.title}-${subItem.url}-${i}`}
value={`${navItem.title}-${subItem.url}`}
onSelect={() => {
runCommand(() => navigate({ to: navItem.url }))
runCommand(() => navigate({ to: subItem.url }))
}}
>
<div className='flex size-4 items-center justify-center'>
<ArrowRight className='text-muted-foreground/80 size-2' />
</div>
{navItem.title}
{navItem.title} <ChevronRight /> {subItem.title}
</CommandItem>
)
return navItem.items?.map((subItem, i) => (
<CommandItem
key={`${navItem.title}-${subItem.url}-${i}`}
value={`${navItem.title}-${subItem.url}`}
onSelect={() => {
runCommand(() => navigate({ to: subItem.url }))
}}
>
<div className='flex size-4 items-center justify-center'>
<ArrowRight className='text-muted-foreground/80 size-2' />
</div>
{navItem.title} <ChevronRight /> {subItem.title}
</CommandItem>
))
})}
))
})}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading='Theme'>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<Sun /> <span>{t('Light')}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<Moon className='scale-90' />
<span>{t('Dark')}</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => setTheme('system'))}
>
<Laptop />
<span>{t('System')}</span>
</CommandItem>
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading='Theme'>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<Sun /> <span>{t('Light')}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<Moon className='scale-90' />
<span>{t('Dark')}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
<Laptop />
<span>{t('System')}</span>
</CommandItem>
</CommandGroup>
</ScrollArea>
</CommandList>
</ScrollArea>
</CommandList>
</Command>
</CommandDialog>
)
}
......@@ -46,8 +46,8 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
<AlertDialogContent className={cn(className && className)}>
<AlertDialogHeader className='text-start'>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>{desc}</div>
<AlertDialogDescription render={<div />}>
{desc}
</AlertDialogDescription>
</AlertDialogHeader>
{children}
......
......@@ -61,7 +61,7 @@ export function CopyButton({
if (tooltip || successTooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent>
<p>{isCopied ? resolvedSuccessTooltip : resolvedTooltip}</p>
</TooltipContent>
......
......@@ -88,7 +88,7 @@ export function DataTableBulkActions<TData>({
break
case 'Escape': {
// Check if the Escape key came from a dropdown trigger or content
// We can't check dropdown state because Radix UI closes it before our handler runs
// We can't check dropdown state because the menu closes before our handler runs.
const target = event.target as HTMLElement
const activeElement = document.activeElement as HTMLElement
......@@ -156,18 +156,20 @@ export function DataTableBulkActions<TData>({
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleClearSelection}
className='size-6 rounded-full'
aria-label={t('Clear selection')}
title={t('Clear selection (Escape)')}
>
<X />
<span className='sr-only'>{t('Clear selection')}</span>
</Button>
<TooltipTrigger
render={
<Button
variant='outline'
size='icon'
onClick={handleClearSelection}
className='size-6 rounded-full'
aria-label={t('Clear selection')}
title={t('Clear selection (Escape)')}
/>
}
>
<X />
<span className='sr-only'>{t('Clear selection')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Clear selection (Escape)')}</p>
......
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import {
ArrowDown as ArrowDownIcon,
ArrowUp as ArrowUpIcon,
ChevronsUpDown as CaretSortIcon,
EyeOff as EyeNoneIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
......@@ -35,21 +35,23 @@ export function DataTableColumnHeader<TData, TValue>({
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='data-[state=open]:bg-accent -ms-3 h-8'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ms-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ms-2 h-4 w-4' />
) : (
<CaretSortIcon className='ms-2 h-4 w-4' />
)}
</Button>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
size='sm'
className='data-popup-open:bg-accent -ms-3 h-8'
/>
}
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ms-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ms-2 h-4 w-4' />
) : (
<CaretSortIcon className='ms-2 h-4 w-4' />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
......
import * as React from 'react'
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
......@@ -48,44 +48,46 @@ export function DataTableFacetedFilter<TData, TValue>({
return (
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' className='h-8 border-dashed'>
<PlusCircledIcon className='size-4' />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation='vertical' className='mx-2 h-4' />
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal lg:hidden'
>
{selectedValues.size}
</Badge>
<div className='hidden space-x-1 lg:flex'>
{selectedValues.size > 2 ? (
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal'
>
{selectedValues.size} {t('selected')}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant='secondary'
key={option.value}
className='rounded-sm px-1 font-normal'
>
{t(option.label)}
</Badge>
))
)}
</div>
</>
)}
</Button>
<PopoverTrigger
render={
<Button variant='outline' size='sm' className='h-8 border-dashed' />
}
>
<PlusCircledIcon className='size-4' />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation='vertical' className='mx-2 h-4' />
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal lg:hidden'
>
{selectedValues.size}
</Badge>
<div className='hidden space-x-1 lg:flex'>
{selectedValues.size > 2 ? (
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal'
>
{selectedValues.size} {t('selected')}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant='secondary'
key={option.value}
className='rounded-sm px-1 font-normal'
>
{t(option.label)}
</Badge>
))
)}
</div>
</>
)}
</PopoverTrigger>
<PopoverContent className='w-[200px] p-0' align='start'>
<Command>
......
......@@ -7,6 +7,7 @@ export { DataTableBulkActions } from './bulk-actions'
export { TableSkeleton } from './table-skeleton'
export { TableEmpty } from './table-empty'
export { MobileCardList } from './mobile-card-list'
export { DataTablePage, type DataTablePageProps } from './data-table-page'
export const DISABLED_ROW_DESKTOP =
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70'
......
......@@ -6,6 +6,7 @@ import {
} from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Empty,
EmptyDescription,
......@@ -14,7 +15,6 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface MobileCardListProps<TData> {
table: Table<TData>
......
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
ChevronsLeft as DoubleArrowLeftIcon,
ChevronsRight as DoubleArrowRightIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn, getPageNumbers } from '@/lib/utils'
import { Button } from '@/components/ui/button'
......
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
......@@ -7,8 +5,9 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
type DataTableViewOptionsProps<TData> = {
......@@ -21,37 +20,39 @@ export function DataTableViewOptions<TData>({
const { t } = useTranslation()
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='ms-auto h-9 w-9 px-0 sm:h-8 sm:w-auto sm:px-3 lg:flex'
>
<MixerHorizontalIcon className='size-4' />
<span className='hidden sm:inline'>{t('View')}</span>
</Button>
<DropdownMenuTrigger
render={
<Button
variant='outline'
className='shrink-0'
aria-label={t('View')}
/>
}
>
{t('View')}
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuCheckboxItem>
<DropdownMenuGroup>
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
)
})}
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
......
......@@ -36,19 +36,21 @@ export function DatePicker({
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
data-empty={!selected}
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
>
{selected ? (
dayjs(selected).format('YYYY-MM-DD')
) : (
<span>{placeholderText}</span>
)}
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
</Button>
<PopoverTrigger
render={
<Button
variant='outline'
data-empty={!selected}
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
/>
}
>
{selected ? (
dayjs(selected).format('YYYY-MM-DD')
) : (
<span>{placeholderText}</span>
)}
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
......
......@@ -93,17 +93,19 @@ export function DateTimePicker({
return (
<div className={cn('flex gap-2', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
className={cn(
'flex-1 justify-between font-normal',
!date && 'text-muted-foreground'
)}
>
{date ? dayjs(date).format('YYYY-MM-DD') : placeholderText}
<ChevronDownIcon className='h-4 w-4 opacity-50' />
</Button>
<PopoverTrigger
render={
<Button
variant='outline'
className={cn(
'flex-1 justify-between font-normal',
!date && 'text-muted-foreground'
)}
/>
}
>
{date ? dayjs(date).format('YYYY-MM-DD') : placeholderText}
<ChevronDownIcon className='h-4 w-4 opacity-50' />
</PopoverTrigger>
<PopoverContent className='w-auto overflow-hidden p-0' align='start'>
<Calendar
......
......@@ -41,11 +41,17 @@ export function LanguageSwitcher() {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='h-9 w-9 rounded-full'>
<Languages className='size-[1.2rem]' />
<span className='sr-only'>{t('Change language')}</span>
</Button>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
size='icon'
className='h-9 w-9 rounded-full'
/>
}
>
<Languages className='size-[1.2rem]' />
<span className='sr-only'>{t('Change language')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{languages.map((lang) => (
......
import { useNotifications } from '@/hooks/use-notifications'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { ConfigDrawer } from '@/components/config-drawer'
import { LanguageSwitcher } from '@/components/language-switcher'
......@@ -10,6 +11,7 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import { Header } from './header'
import { TopNav } from './top-nav'
import { WorkspaceSwitcher } from './workspace-switcher'
/**
* General application Header component
......@@ -87,20 +89,30 @@ export function AppHeader({
// Prioritize dynamically generated links from backend
const dynamicLinks = useTopNavLinks()
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
const sidebarData = useSidebarData()
// Notifications hook
const notifications = useNotifications()
// Determine left content: custom content > navigation bar > null
const leftSection =
leftContent || (showTopNav ? <TopNav links={links} /> : null)
return (
<>
<Header>
{leftSection}
<WorkspaceSwitcher
variant='inline'
workspaces={sidebarData.workspaces}
/>
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{rightContent ?? (
<div className='ms-auto flex items-center space-x-4'>
<div className='ms-auto flex items-center gap-1 sm:gap-2'>
{showTopNav && (
<div className='me-1 hidden lg:block'>
<TopNav links={links} />
</div>
)}
{showSearch && <Search />}
{showNotifications && (
<NotificationButton
......
......@@ -6,15 +6,9 @@ import { ROLE } from '@/lib/roles'
import { useLayout } from '@/context/layout-provider'
import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarRail,
} from '@/components/ui/sidebar'
import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
import { getNavGroupsForPath } from '../lib/workspace-registry'
import { NavGroup } from './nav-group'
import { WorkspaceSwitcher } from './workspace-switcher'
/**
* Application sidebar component
......@@ -51,10 +45,7 @@ export function AppSidebar() {
return (
<Sidebar collapsible={collapsible} variant={variant}>
<SidebarHeader>
<WorkspaceSwitcher workspaces={sidebarData.workspaces} />
</SidebarHeader>
<SidebarContent>
<SidebarContent className='py-2'>
{currentNavGroups.map((props) => {
const key = props.id || props.title
return <NavGroup key={key} {...props} />
......
......@@ -6,6 +6,7 @@ import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { AnimatedOutlet } from '@/components/page-transition'
import { SkipToMain } from '@/components/skip-to-main'
import { WorkspaceProvider } from '../context/workspace-context'
import { AppHeader } from './app-header'
import { AppSidebar } from './app-sidebar'
type AuthenticatedLayoutProps = {
......@@ -19,18 +20,21 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
<LayoutProvider>
<SearchProvider>
<WorkspaceProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
<SkipToMain />
<AppSidebar />
<SidebarInset
className={cn(
'@container/content',
'h-svh',
'peer-data-[variant=inset]:h-[calc(100svh-(var(--spacing)*4))]'
)}
>
{props.children ?? <AnimatedOutlet />}
</SidebarInset>
<AppHeader />
<div className='flex min-h-0 w-full flex-1'>
<AppSidebar />
<SidebarInset
className={cn(
'@container/content',
'h-[calc(100svh-var(--app-header-height,0px))]',
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
)}
>
{props.children ?? <AnimatedOutlet />}
</SidebarInset>
</div>
</SidebarProvider>
</WorkspaceProvider>
</SearchProvider>
......
......@@ -53,14 +53,17 @@ function ChatMenuItem({
if (preset.type === 'web') {
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild isActive={active}>
<Link
to='/chat/$chatId'
params={{ chatId: preset.id }}
onClick={onNavigate}
>
<span>{preset.name}</span>
</Link>
<SidebarMenuSubButton
isActive={active}
render={
<Link
to='/chat/$chatId'
params={{ chatId: preset.id }}
onClick={onNavigate}
/>
}
>
<span>{preset.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
......@@ -92,10 +95,10 @@ function DropdownPresetItem({
}) {
if (preset.type === 'web') {
return (
<DropdownMenuItem asChild>
<Link to='/chat/$chatId' params={{ chatId: preset.id }}>
{preset.name}
</Link>
<DropdownMenuItem
render={<Link to='/chat/$chatId' params={{ chatId: preset.id }} />}
>
{preset.name}
</DropdownMenuItem>
)
}
......@@ -187,12 +190,12 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon className='h-4 w-4' />}
<span>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
</SidebarMenuButton>
<DropdownMenuTrigger
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon className='h-4 w-4' />}
<span>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{visiblePresets.map((preset) => (
......@@ -218,40 +221,39 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
// Expanded state - render collapsible menu
return (
<Collapsible
asChild
defaultOpen={normalizedHref.startsWith('/chat')}
className='group/collapsible'
render={<SidebarMenuItem />}
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{visiblePresets.map((preset) => (
<ChatMenuItem
key={preset.id}
preset={preset}
active={normalizedHref === `/chat/${preset.id}`}
onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)}
/>
))}
{hasKeyDependentPresets && isKeyPending && (
<SidebarMenuSubItem>
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
<CollapsibleTrigger
className='group/collapsible-trigger'
render={<SidebarMenuButton />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{visiblePresets.map((preset) => (
<ChatMenuItem
key={preset.id}
preset={preset}
active={normalizedHref === `/chat/${preset.id}`}
onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)}
/>
))}
{hasKeyDependentPresets && isKeyPending && (
<SidebarMenuSubItem>
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
......@@ -67,7 +67,7 @@ function ProjectAttribution(props: { currentYear: number }) {
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 font-medium transition-colors hover:text-foreground'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('New API')}
</a>
......@@ -152,7 +152,12 @@ export function Footer(props: FooterProps) {
if (footerHtml) {
return (
<footer className={cn('border-border/40 relative z-10 border-t', props.className)}>
<footer
className={cn(
'border-border/40 relative z-10 border-t',
props.className
)}
>
<div className='mx-auto w-full max-w-6xl px-6 py-5'>
<div className='bg-muted/20 border-border/50 flex flex-col items-center justify-between gap-4 rounded-2xl border px-4 py-4 backdrop-blur-sm sm:flex-row sm:px-5'>
<div
......
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
type HeaderProps = React.HTMLAttributes<HTMLElement>
......@@ -7,12 +6,14 @@ type HeaderProps = React.HTMLAttributes<HTMLElement>
export function Header({ className, children, ...props }: HeaderProps) {
return (
<header
className={cn('bg-background z-50 h-16 shrink-0 border-b', className)}
className={cn(
'sticky top-0 z-40 h-[var(--app-header-height,3rem)] w-full shrink-0 bg-transparent',
className
)}
{...props}
>
<div className='flex h-full items-center gap-3 p-4 sm:gap-4'>
<SidebarTrigger variant='outline' />
<Separator orientation='vertical' className='h-6' />
<div className='flex h-full items-center gap-1.5 px-2 sm:gap-2 sm:px-3'>
<SidebarTrigger variant='ghost' className='size-8' />
{children}
</div>
</header>
......
......@@ -137,10 +137,13 @@ interface MobileSignInButtonProps {
function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) {
const { t } = useTranslation()
return (
<Button variant='secondary' size='sm' asChild className='h-10 w-full'>
<Link to='/sign-in' onClick={onNavigate}>
{t('Sign in')}
</Link>
<Button
variant='secondary'
size='sm'
className='h-10 w-full'
render={<Link to='/sign-in' onClick={onNavigate} />}
>
{t('Sign in')}
</Button>
)
}
......
......@@ -10,6 +10,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
......@@ -44,8 +45,10 @@ export function NavGroup({ title, items }: NavGroupProps) {
const href = useLocation({ select: (location) => location.href })
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroup className='px-2 py-1'>
<SidebarGroupLabel className='text-muted-foreground/70 px-2 text-[11px] font-medium tracking-wider uppercase'>
{title}
</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const key = `${item.title}-${item.url || item.type}`
......@@ -102,15 +105,13 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={checkIsActive(href, item)}
tooltip={item.title}
render={<Link to={item.url} onClick={() => setOpenMobile(false)} />}
>
<Link to={item.url} onClick={() => setOpenMobile(false)}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</Link>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</SidebarMenuButton>
</SidebarMenuItem>
)
......@@ -142,39 +143,38 @@ function SidebarMenuCollapsible({
return (
<Collapsible
asChild
open={isOpen}
onOpenChange={setIsOpen}
className='group/collapsible'
render={<SidebarMenuItem />}
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={checkIsActive(href, subItem)}
>
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
<CollapsibleTrigger
className='group/collapsible-trigger'
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
isActive={checkIsActive(href, subItem)}
render={
<Link to={subItem.url} onClick={() => setOpenMobile(false)} />
}
>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
......@@ -192,36 +192,44 @@ function SidebarMenuCollapsedDropdown({
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
<DropdownMenuTrigger
className='group/dropdown-trigger'
render={
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
/>
}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
<DropdownMenuGroup>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem
key={`${sub.title}-${sub.url}`}
render={
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
/>
}
>
{sub.icon && <sub.icon />}
<span className='max-w-52 text-wrap'>{sub.title}</span>
{sub.badge && (
<span className='ms-auto text-xs'>{sub.badge}</span>
)}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
......
......@@ -180,9 +180,9 @@ export function PublicHeader(props: PublicHeaderProps) {
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
asChild
render={<Link to='/sign-in' />}
>
<Link to='/sign-in'>{t('Sign in')}</Link>
{t('Sign in')}
</Button>
)}
</>
......
......@@ -16,7 +16,7 @@ type PublicLayoutProps = {
export function PublicLayout(props: PublicLayoutProps) {
return (
<div className='bg-background text-foreground relative min-h-svh overflow-hidden'>
<div className='bg-background text-foreground relative min-h-svh overflow-x-clip'>
<PublicHeader
navContent={props.navContent}
navLinks={props.navLinks}
......
......@@ -5,7 +5,6 @@ import {
type ReactElement,
type ReactNode,
} from 'react'
import { AppHeader } from './app-header'
import { Main } from './main'
import { PageFooterProvider } from './page-footer'
......@@ -46,7 +45,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
)
let title: ReactNode = null
let description: ReactNode = null
let actions: ReactNode = null
let content: ReactNode = null
let breadcrumb: ReactNode = null
......@@ -55,8 +53,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
if (!isValidElement(node)) return
const child = node as ReactElement<SlotProps>
if (child.type === SectionPageLayoutTitle) title = child.props.children
else if (child.type === SectionPageLayoutDescription)
description = child.props.children
else if (child.type === SectionPageLayoutActions)
actions = child.props.children
else if (child.type === SectionPageLayoutContent)
......@@ -67,21 +63,16 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
return (
<PageFooterProvider container={footerContainer}>
<AppHeader />
<Main>
<div className='shrink-0 px-3 pt-3 pb-2.5 sm:px-4 sm:pt-6 sm:pb-4'>
{breadcrumb != null && <div className='mb-2 sm:mb-3'>{breadcrumb}</div>}
<div className='shrink-0 px-3 pt-3 pb-2.5 sm:px-4 sm:pt-5 sm:pb-3'>
{breadcrumb != null && (
<div className='mb-2 sm:mb-3'>{breadcrumb}</div>
)}
<div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
<div className='min-w-0'>
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
{title}
</h2>
{description != null && (
<p className='text-muted-foreground line-clamp-2 max-sm:text-xs sm:text-sm'>
{description}
</p>
)}
</div>
{actions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
......@@ -91,7 +82,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div>
</div>
<div className='min-h-0 flex-1 overflow-auto px-3 pb-3 sm:px-4 sm:pb-4'>
<div className='min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'>
{content}
</div>
......
......@@ -37,34 +37,37 @@ export function TopNav({ className, links, ...props }: TopNavProps) {
{/* 移动端下拉菜单 */}
<div className='lg:hidden'>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size='icon' variant='outline' className='size-7'>
<Menu />
</Button>
<DropdownMenuTrigger
render={<Button size='icon' variant='outline' className='size-7' />}
>
<Menu />
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='start'>
{normalizedLinks.map(
({ title, href, isActive, disabled, external }) => (
<DropdownMenuItem key={`${title}-${href}`} asChild>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className={!isActive ? 'text-muted-foreground' : ''}
>
{title}
</a>
) : (
<Link
to={href}
className={!isActive ? 'text-muted-foreground' : ''}
disabled={disabled}
>
{title}
</Link>
)}
</DropdownMenuItem>
<DropdownMenuItem
key={`${title}-${href}`}
render={
external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className={!isActive ? 'text-muted-foreground' : ''}
>
{title}
</a>
) : (
<Link
to={href}
className={!isActive ? 'text-muted-foreground' : ''}
disabled={disabled}
>
{title}
</Link>
)
}
></DropdownMenuItem>
)
)}
</DropdownMenuContent>
......
......@@ -4,11 +4,13 @@ import { ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
......@@ -27,6 +29,12 @@ type WorkspaceSwitcherProps = {
workspaces: Workspace[]
defaultName?: string
defaultVersion?: string
/**
* Visual layout:
* - 'sidebar': stacked card style (used inside the sidebar header).
* - 'inline': compact horizontal pill (used inside the top app bar).
*/
variant?: 'sidebar' | 'inline'
}
/**
......@@ -39,6 +47,7 @@ export function WorkspaceSwitcher({
workspaces,
defaultName = 'New API',
defaultVersion,
variant = 'sidebar',
}: WorkspaceSwitcherProps) {
const { t } = useTranslation()
const navigate = useNavigate()
......@@ -121,6 +130,88 @@ export function WorkspaceSwitcher({
}
const canSwitchWorkspace = availableWorkspaces.length > 1
const renderWorkspaceList = () => (
<DropdownMenuGroup>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
>
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img src={logo} alt='Logo' className='size-full object-cover' />
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)
if (variant === 'inline') {
const inlineLogo =
activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
<div className='bg-primary text-primary-foreground flex size-5 items-center justify-center rounded-md'>
<activeWorkspace.logo className='size-3' />
</div>
) : (
<div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-md object-cover'
/>
</div>
)
const inlineButtonClass = cn(
'inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium text-foreground outline-none select-none transition-colors',
'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/40',
'data-popup-open:bg-accent'
)
if (!canSwitchWorkspace) {
return (
<div
className={cn(
inlineButtonClass,
'cursor-default hover:bg-transparent'
)}
>
{inlineLogo}
<span className='max-w-[12rem] truncate'>{activeWorkspace.name}</span>
</div>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger className={inlineButtonClass}>
{inlineLogo}
<span className='max-w-[12rem] truncate'>{activeWorkspace.name}</span>
<ChevronsUpDown className='text-muted-foreground size-3.5' />
</DropdownMenuTrigger>
<DropdownMenuContent
className='min-w-56 rounded-lg'
align='start'
side='bottom'
sideOffset={6}
>
{renderWorkspaceList()}
</DropdownMenuContent>
</DropdownMenu>
)
}
const workspaceButtonContent = (
<>
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
......@@ -151,54 +242,32 @@ export function WorkspaceSwitcher({
<SidebarMenuItem>
{canSwitchWorkspace ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
{workspaceButtonContent}
</SidebarMenuButton>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size='lg'
className='data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground'
/>
}
>
{workspaceButtonContent}
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
className='w-(--anchor-width) min-w-56 rounded-lg'
align='start'
side={isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
>
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img
src={logo}
alt='Logo'
className='size-full object-cover'
/>
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
{renderWorkspaceList()}
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton
asChild
size='lg'
className='cursor-default hover:bg-transparent hover:text-sidebar-foreground active:bg-transparent active:text-sidebar-foreground'
className='hover:text-sidebar-foreground active:text-sidebar-foreground cursor-default hover:bg-transparent active:bg-transparent'
render={<div />}
>
<div>{workspaceButtonContent}</div>
{workspaceButtonContent}
</SidebarMenuButton>
)}
</SidebarMenuItem>
......
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
import { CircleQuestionMark } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
......@@ -9,9 +8,10 @@ import {
PopoverTrigger,
} from '@/components/ui/popover'
type LearnMoreProps = React.ComponentProps<typeof Root> & {
contentProps?: React.ComponentProps<typeof Content>
triggerProps?: React.ComponentProps<typeof Trigger>
type LearnMoreProps = Omit<React.ComponentProps<typeof Popover>, 'children'> & {
children?: React.ReactNode
contentProps?: React.ComponentProps<typeof PopoverContent>
triggerProps?: React.ComponentProps<typeof PopoverTrigger>
}
export function LearnMore({
......@@ -24,14 +24,12 @@ export function LearnMore({
return (
<Popover {...props}>
<PopoverTrigger
asChild
{...triggerProps}
className={cn('size-5 rounded-full', triggerProps?.className)}
render={<Button variant='outline' size='icon' />}
>
<Button variant='outline' size='icon'>
<span className='sr-only'>{t('Learn more')}</span>
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
</Button>
<span className='sr-only'>{t('Learn more')}</span>
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
</PopoverTrigger>
<PopoverContent
side='top'
......
......@@ -46,12 +46,12 @@ export function LongText({
return (
<>
<div className='hidden sm:block'>
<TooltipProvider delayDuration={0}>
<TooltipProvider delay={0}>
<Tooltip>
<TooltipTrigger asChild>
<div ref={ref} className={cn('truncate', className)}>
{children}
</div>
<TooltipTrigger
render={<div ref={ref} className={cn('truncate', className)} />}
>
{children}
</TooltipTrigger>
<TooltipContent>
<p className={contentClassName}>{children}</p>
......@@ -61,10 +61,10 @@ export function LongText({
</div>
<div className='sm:hidden'>
<Popover>
<PopoverTrigger asChild>
<div ref={ref} className={cn('truncate', className)}>
{children}
</div>
<PopoverTrigger
render={<div ref={ref} className={cn('truncate', className)} />}
>
{children}
</PopoverTrigger>
<PopoverContent className={cn('w-fit', contentClassName)}>
<p>{children}</p>
......
......@@ -26,10 +26,12 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return (
<div className='flex items-center'>
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='sm' className='h-7 font-mono'>
{props.maskedValue}
</Button>
<PopoverTrigger
render={
<Button variant='ghost' size='sm' className='h-7 font-mono' />
}
>
{props.maskedValue}
</PopoverTrigger>
<PopoverContent
className='w-auto max-w-[min(90vw,28rem)]'
......
......@@ -299,20 +299,21 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<ModelTriggerButton
currentLabel={currentModel?.label || t('Model')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
</PopoverTrigger>
<PopoverTrigger
render={
<ModelTriggerButton
currentLabel={currentModel?.label || t('Model')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
}
/>
<PopoverContent
className='bg-popover z-40 w-[90vw] max-w-[20em] rounded-lg border p-0 !shadow-none sm:w-[20em]'
align='start'
side='bottom'
sideOffset={4}
avoidCollisions={true}
collisionPadding={8}
>
{renderModelCommandContent()}
......@@ -492,20 +493,21 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<GroupTriggerButton
currentLabel={currentGroup?.label || t('Group')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
</PopoverTrigger>
<PopoverTrigger
render={
<GroupTriggerButton
currentLabel={currentGroup?.label || t('Group')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
}
/>
<PopoverContent
className='bg-popover z-50 w-[90vw] max-w-[14em] rounded-lg border p-0 !shadow-none sm:w-[14em]'
align='start'
side='bottom'
sideOffset={4}
avoidCollisions={true}
collisionPadding={8}
>
{renderGroupCommandContent()}
......
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { User, Wallet, LogOut, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { ROLE } from '@/lib/roles'
import useDialogState from '@/hooks/use-dialog'
import { useUserDisplay } from '@/hooks/use-user-display'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose,
} from '@/components/ui/sheet'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SignOutDialog } from '@/components/sign-out-dialog'
const avatarFallbackClassName = 'font-semibold text-white'
export function ProfileDropdown() {
const { t } = useTranslation()
const navigate = useNavigate()
const [open, setOpen] = useDialogState()
const [sheetOpen, setSheetOpen] = useState(false)
const user = useAuthStore((state) => state.auth.user)
const { displayName, initials, roleLabel } = useUserDisplay(user)
const { displayName, roleLabel } = useUserDisplay(user)
const isSuperAdmin = user?.role === ROLE.SUPER_ADMIN
const avatarName = user?.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = useMemo(
() => getUserAvatarStyle(avatarName),
[avatarName]
)
return (
<>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant='ghost' className='relative h-9 w-9 rounded-full p-0'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</SheetTrigger>
<SheetContent
side='right'
className='flex w-full flex-col p-0 sm:max-w-sm'
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='relative size-6 rounded-full p-0'
/>
}
>
<SheetHeader className='border-b p-4'>
<SheetTitle className='text-left'>{t('User Menu')}</SheetTitle>
</SheetHeader>
<div className='flex flex-1 flex-col overflow-y-auto'>
{/* User info section */}
<div className='border-b p-2.5 pb-6.5'>
<div className='flex items-center gap-2.5'>
<Avatar className='size-9'>
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
<AvatarFallback className='text-xs'>
{initials}
</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-col gap-0.5 overflow-hidden'>
<p className='text-foreground truncate text-sm font-medium'>
{displayName}
</p>
<div className='flex items-center gap-1.5'>
<span className='text-muted-foreground text-xs'>
{roleLabel}
<Avatar className='size-6'>
<AvatarFallback
className={`${avatarFallbackClassName} text-[11px]`}
style={avatarFallbackStyle}
>
{avatarFallback}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' sideOffset={8} className='w-56'>
<div className='flex items-center gap-2 px-1.5 py-1.5'>
<Avatar className='size-8'>
<AvatarFallback
className={`${avatarFallbackClassName} text-xs`}
style={avatarFallbackStyle}
>
{avatarFallback}
</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-col gap-0.5 overflow-hidden'>
<p className='text-foreground truncate text-sm font-medium'>
{displayName}
</p>
<div className='flex items-center gap-1.5'>
<span className='text-muted-foreground text-xs'>
{roleLabel}
</span>
{user?.group && (
<>
<span className='text-muted-foreground text-xs'>·</span>
<span className='text-muted-foreground truncate text-xs'>
{String(user.group)}
</span>
{user?.group && (
<>
<span className='text-muted-foreground text-xs'>·</span>
<span className='text-muted-foreground text-xs'>
{String(user.group)}
</span>
</>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
{/* Navigation links */}
<SheetClose asChild>
<Link
to='/profile'
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors'
>
<User className='size-4' />
{t('Profile')}
</Link>
</SheetClose>
<DropdownMenuSeparator />
<SheetClose asChild>
<Link
to='/wallet'
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors'
>
<Wallet className='size-4' />
{t('Wallet')}
</Link>
</SheetClose>
<DropdownMenuItem onClick={() => navigate({ to: '/profile' })}>
<User className='size-4' />
{t('Profile')}
</DropdownMenuItem>
{/* System Settings - only for super admin */}
{isSuperAdmin && (
<SheetClose asChild>
<Link
to='/system-settings/general'
search={{ section: 'system-info' }}
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors'
>
<Settings className='size-4' />
{t('System Settings')}
</Link>
</SheetClose>
)}
<DropdownMenuItem onClick={() => navigate({ to: '/wallet' })}>
<Wallet className='size-4' />
{t('Wallet')}
</DropdownMenuItem>
{/* Sign out */}
<Button
variant='ghost'
onClick={() => {
setSheetOpen(false)
setOpen(true)
}}
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent'
{isSuperAdmin && (
<DropdownMenuItem
onClick={() =>
navigate({
to: '/system-settings/general',
search: { section: 'system-info' },
})
}
>
<LogOut className='size-4' />
{t('Sign out')}
</Button>
</div>
</SheetContent>
</Sheet>
<Settings className='size-4' />
{t('System Settings')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant='destructive' onClick={() => setOpen(true)}>
<LogOut className='size-4' />
{t('Sign out')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
......
import { Loader } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { FormControl } from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
type SelectDropdownProps = {
onValueChange?: (value: string) => void
defaultValue: string | undefined
placeholder?: string
isPending?: boolean
items: { label: string; value: string }[] | undefined
disabled?: boolean
className?: string
isControlled?: boolean
}
export function SelectDropdown({
defaultValue,
onValueChange,
isPending,
items,
placeholder,
disabled,
className = '',
isControlled = false,
}: SelectDropdownProps) {
const { t } = useTranslation()
const placeholderText = placeholder ?? t('Select')
const defaultState = isControlled
? { value: defaultValue, onValueChange }
: { defaultValue, onValueChange }
return (
<Select {...defaultState}>
<FormControl>
<SelectTrigger disabled={disabled} className={cn(className)}>
<SelectValue placeholder={placeholderText} />
</SelectTrigger>
</FormControl>
<SelectContent>
{isPending ? (
<SelectItem disabled value='loading' className='h-14'>
<div className='flex items-center justify-center gap-2'>
<Loader className='h-5 w-5 animate-spin' />
{' '}
{t('Loading...')}
</div>
</SelectItem>
) : (
items?.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))
)}
</SelectContent>
</Select>
)
}
......@@ -110,7 +110,8 @@ export function StatusBadge({
onClick?.(e)
}
const content = children ?? (label ? <span className='truncate'>{label}</span> : null)
const content =
children ?? (label ? <span className='truncate'>{label}</span> : null)
return (
<span
......
......@@ -25,12 +25,18 @@ export function ThemeSwitch() {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='h-9 w-9 rounded-full'>
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
<span className='sr-only'>{t('Toggle theme')}</span>
</Button>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
size='icon'
className='h-9 w-9 rounded-full'
/>
}
>
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
<span className='sr-only'>{t('Toggle theme')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
......
'use client'
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from 'lucide-react'
import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'
import { ArrowDown01Icon, ArrowUp01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { cn } from '@/lib/utils'
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot='accordion' {...props} />
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot='accordion'
className={cn('flex w-full flex-col', className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot='accordion-item'
className={cn('border-b last:border-b-0', className)}
className={cn('not-last:border-b', className)}
{...props}
/>
)
......@@ -28,19 +27,30 @@ function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
data-slot='accordion-trigger'
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
'group/accordion-trigger focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4',
className
)}
{...props}
>
{children}
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200' />
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
data-slot='accordion-trigger-icon'
className='pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden'
/>
<HugeiconsIcon
icon={ArrowUp01Icon}
strokeWidth={2}
data-slot='accordion-trigger-icon'
className='pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline'
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
......@@ -50,15 +60,22 @@ function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Content
<AccordionPrimitive.Panel
data-slot='accordion-content'
className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm'
className='data-open:animate-accordion-down data-closed:animate-accordion-up overflow-hidden text-sm'
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
<div
className={cn(
'[&_a]:hover:text-foreground h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
......
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { Button } from '@/components/ui/button'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
)
......@@ -28,12 +24,12 @@ function AlertDialogPortal({
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Overlay
<AlertDialogPrimitive.Backdrop
data-slot='alert-dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
className
)}
{...props}
......@@ -43,15 +39,19 @@ function AlertDialogOverlay({
function AlertDialogContent({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
}: AlertDialogPrimitive.Popup.Props & {
size?: 'default' | 'sm'
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
<AlertDialogPrimitive.Popup
data-slot='alert-dialog-content'
data-size={size}
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
className
)}
{...props}
......@@ -67,7 +67,10 @@ function AlertDialogHeader({
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-start', className)}
className={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...props}
/>
)
......@@ -81,7 +84,23 @@ function AlertDialogFooter({
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-media'
className={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
......@@ -96,7 +115,10 @@ function AlertDialogTitle({
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
className={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...props}
/>
)
......@@ -109,7 +131,10 @@ function AlertDialogDescription({
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
className={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...props}
/>
)
......@@ -118,10 +143,11 @@ function AlertDialogDescription({
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
}: React.ComponentProps<typeof Button>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
<Button
data-slot='alert-dialog-action'
className={cn(className)}
{...props}
/>
)
......@@ -129,11 +155,16 @@ function AlertDialogAction({
function AlertDialogCancel({
className,
variant = 'outline',
size = 'default',
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
<AlertDialogPrimitive.Close
data-slot='alert-dialog-cancel'
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
......@@ -141,14 +172,15 @@ function AlertDialogCancel({
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogTrigger,
}
......@@ -3,13 +3,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 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 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
......@@ -38,7 +38,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
'[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
className
)}
{...props}
......@@ -54,7 +54,7 @@ function AlertDescription({
<div
data-slot='alert-description'
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className
)}
{...props}
......@@ -62,4 +62,14 @@ function AlertDescription({
)
}
export { Alert, AlertTitle, AlertDescription }
function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-action'
className={cn('absolute top-2 right-2', className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
import { cn } from '@/lib/utils'
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<'div'> & { ratio: number }) {
return (
<div
data-slot='aspect-ratio'
style={
{
'--ratio': ratio,
} as React.CSSProperties
}
className={cn('relative aspect-(--ratio)', className)}
{...props}
/>
)
}
export { AspectRatio }
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
}) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
data-size={size}
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...props}
......@@ -18,14 +22,14 @@ function Avatar({
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
className={cn(
'aspect-square size-full rounded-full object-cover',
className
)}
{...props}
/>
)
......@@ -34,12 +38,28 @@ function AvatarImage({
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='avatar-badge'
className={cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>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}
......@@ -47,4 +67,40 @@ function AvatarFallback({
)
}
export { Avatar, AvatarImage, AvatarFallback }
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group'
className={cn(
'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2',
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group-count'
className={cn(
'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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,
}
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
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'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl 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!',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
'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:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
'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: {
......@@ -26,20 +28,24 @@ const badgeVariants = cva(
function Badge({
className,
variant,
asChild = false,
variant = 'default',
render,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot='badge'
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: 'badge',
variant,
},
})
}
export { Badge, badgeVariants }
import * as React from 'react'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import {
ArrowRight01Icon,
MoreHorizontalCircle01Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { cn } from '@/lib/utils'
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
aria-label='breadcrumb'
data-slot='breadcrumb'
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot='breadcrumb-list'
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word',
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-item'
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<'a'>) {
return useRender({
defaultTagName: 'a',
props: mergeProps<'a'>(
{
className: cn('transition-colors hover:text-foreground', className),
},
props
),
render,
state: {
slot: 'breadcrumb-link',
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-page'
role='link'
aria-disabled='true'
aria-current='page'
className={cn('text-foreground font-normal', className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-separator'
role='presentation'
aria-hidden='true'
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-ellipsis'
role='presentation'
aria-hidden='true'
className={cn(
'flex size-5 items-center justify-center [&>svg]:size-4',
className
)}
{...props}
>
<HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />
<span className='sr-only'>More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
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-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[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-lg! [&>[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<typeof buttonGroupVariants>) {
return (
<div
role='group'
data-slot='button-group'
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
"flex items-center gap-2 rounded-lg 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<typeof Separator>) {
return (
<Separator
data-slot='button-group-separator'
orientation={orientation}
className={cn(
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment