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 ...@@ -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: 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/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. - Component names, file paths, and API shapes may differ; match by **functionality**, not filename.
### Step 3 — Triage each change ### Step 3 — Triage each change
...@@ -46,7 +46,7 @@ For each **⚠️** or **❌** item: ...@@ -46,7 +46,7 @@ For each **⚠️** or **❌** item:
1. **Read the target file(s) in `web/default`** before editing (required by project conventions). 1. **Read the target file(s) in `web/default`** before editing (required by project conventions).
2. Implement using `web/default` conventions: 2. Implement using `web/default` conventions:
- React 19 patterns (hooks, Suspense, etc.) - 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) - Tailwind CSS for styling (no inline styles or Semi Design imports)
- `useTranslation()` + `t('English key')` for all user-visible strings - `useTranslation()` + `t('English key')` for all user-visible strings
- TypeScript — explicit types, no `any` - 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 ...@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack ## Tech Stack
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM - **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) - **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
- **Cache**: Redis (go-redis) + in-memory cache - **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.) - **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
...@@ -34,7 +34,7 @@ i18n/ — Backend internationalization (go-i18n, en/zh) ...@@ -34,7 +34,7 @@ i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet) pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container 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/classic/ — Classic frontend (React 18, Vite, Semi Design)
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi) 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 ...@@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack ## Tech Stack
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM - **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) - **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
- **Cache**: Redis (go-redis) + in-memory cache - **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.) - **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
...@@ -34,7 +34,7 @@ i18n/ — Backend internationalization (go-i18n, en/zh) ...@@ -34,7 +34,7 @@ i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet) pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container 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/classic/ — Classic frontend (React 18, Vite, Semi Design)
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi) web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
``` ```
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
| 表格与列表 | @tanstack/react-table、@tanstack/react-virtual | | 表格与列表 | @tanstack/react-table、@tanstack/react-virtual |
| 国际化 | i18next、react-i18next、i18next-browser-languagedetector | | 国际化 | i18next、react-i18next、i18next-browser-languagedetector |
| 日期 | Day.js | | 日期 | 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 | | 表单 | React Hook Form、Zod |
| 图表 | @visactor/vchart、@visactor/react-vchart | | 图表 | @visactor/vchart、@visactor/react-vchart |
| 工具 | qrcode.react、prettier、eslint、vitest(可选)| | 工具 | qrcode.react、prettier、eslint、vitest(可选)|
......
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova", "style": "base-nova",
"rsc": false, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
......
...@@ -16,31 +16,12 @@ ...@@ -16,31 +16,12 @@
"knip": "knip" "knip": "knip"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/public-sans": "^5.2.7", "@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6",
"@lobehub/icons": "^4.0.3", "@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", "@tailwindcss/postcss": "^4.2.2",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.23", "@tanstack/react-router": "^1.168.23",
...@@ -54,6 +35,7 @@ ...@@ -54,6 +35,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"i18next": "^25.7.4", "i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
...@@ -61,6 +43,7 @@ ...@@ -61,6 +43,7 @@
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
...@@ -69,7 +52,9 @@ ...@@ -69,7 +52,9 @@
"react-i18next": "^16.5.2", "react-i18next": "^16.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.0",
"react-top-loading-bar": "^3.0.2", "react-top-loading-bar": "^3.0.2",
"recharts": "3.8.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^4.0.2", "shiki": "^4.0.2",
......
...@@ -34,9 +34,9 @@ export default defineConfig(({ envMode }) => { ...@@ -34,9 +34,9 @@ export default defineConfig(({ envMode }) => {
priority: 0, priority: 0,
enforce: true, enforce: true,
}, },
'vendor-radix': { 'vendor-ui-primitives': {
test: /node_modules[\\/]@radix-ui[\\/]/, test: /node_modules[\\/](@base-ui|@radix-ui)[\\/]/,
name: 'vendor-radix', name: 'vendor-ui-primitives',
chunks: 'all', chunks: 'all',
priority: 0, priority: 0,
enforce: true, enforce: true,
......
...@@ -12,7 +12,7 @@ export function IconThemeSystem({ ...@@ -12,7 +12,7 @@ export function IconThemeSystem({
viewBox='0 0 79.86 51.14' viewBox='0 0 79.86 51.14'
className={cn( className={cn(
'overflow-hidden rounded-[6px]', '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 className
)} )}
{...props} {...props}
......
...@@ -52,7 +52,7 @@ export const Action = ({ ...@@ -52,7 +52,7 @@ export const Action = ({
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{tooltip}</p> <p>{tooltip}</p>
</TooltipContent> </TooltipContent>
......
...@@ -129,7 +129,7 @@ export const ArtifactAction = ({ ...@@ -129,7 +129,7 @@ export const ArtifactAction = ({
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{tooltip}</p> <p>{tooltip}</p>
</TooltipContent> </TooltipContent>
......
...@@ -7,13 +7,13 @@ import { ...@@ -7,13 +7,13 @@ import {
useContext, useContext,
useMemo, useMemo,
} from 'react' } from 'react'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { import {
BrainIcon, BrainIcon,
ChevronDownIcon, ChevronDownIcon,
DotIcon, DotIcon,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
...@@ -197,7 +197,7 @@ export const ChainOfThoughtContent = memo( ...@@ -197,7 +197,7 @@ export const ChainOfThoughtContent = memo(
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'mt-2 space-y-3', '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 className
)} )}
{...props} {...props}
......
...@@ -57,7 +57,7 @@ export const Context = ({ ...@@ -57,7 +57,7 @@ export const Context = ({
modelId, modelId,
}} }}
> >
<HoverCard closeDelay={0} openDelay={0} {...props} /> <HoverCard {...props} />
</ContextContext.Provider> </ContextContext.Provider>
) )
...@@ -114,16 +114,22 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => { ...@@ -114,16 +114,22 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
}).format(usedPercent) }).format(usedPercent)
return ( return (
<HoverCardTrigger asChild> <HoverCardTrigger
{children ?? ( delay={0}
closeDelay={0}
render={
<Button type='button' variant='ghost' {...props}> <Button type='button' variant='ghost' {...props}>
<span className='text-muted-foreground font-medium'> {children ?? (
{renderedPercent} <>
</span> <span className='text-muted-foreground font-medium'>
<ContextIcon /> {renderedPercent}
</span>
<ContextIcon />
</>
)}
</Button> </Button>
)} }
</HoverCardTrigger> />
) )
} }
......
...@@ -52,7 +52,7 @@ export const InlineCitationText = ({ ...@@ -52,7 +52,7 @@ export const InlineCitationText = ({
export type InlineCitationCardProps = ComponentProps<typeof HoverCard> export type InlineCitationCardProps = ComponentProps<typeof HoverCard>
export const InlineCitationCard = (props: InlineCitationCardProps) => ( export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} /> <HoverCard {...props} />
) )
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & { export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
...@@ -64,22 +64,26 @@ export const InlineCitationCardTrigger = ({ ...@@ -64,22 +64,26 @@ export const InlineCitationCardTrigger = ({
className, className,
...props ...props
}: InlineCitationCardTriggerProps) => ( }: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild> <HoverCardTrigger
<Badge delay={0}
className={cn('ml-1 rounded-full', className)} closeDelay={0}
variant='secondary' render={
{...props} <Badge
> className={cn('ml-1 rounded-full', className)}
{sources[0] ? ( variant='secondary'
<> {...props}
{new URL(sources[0]).hostname}{' '} >
{sources.length > 1 && `+${sources.length - 1}`} {sources[0] ? (
</> <>
) : ( {new URL(sources[0]).hostname}{' '}
'unknown' {sources.length > 1 && `+${sources.length - 1}`}
)} </>
</Badge> ) : (
</HoverCardTrigger> 'unknown'
)}
</Badge>
}
/>
) )
export type InlineCitationCardBodyProps = ComponentProps<'div'> export type InlineCitationCardBodyProps = ComponentProps<'div'>
......
...@@ -235,14 +235,19 @@ export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger> ...@@ -235,14 +235,19 @@ export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => { export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<DropdownMenuTrigger {...props} asChild> <DropdownMenuTrigger
{children ?? ( {...props}
render={
<Button type='button' variant='outline'> <Button type='button' variant='outline'>
{t('Open in chat')} {children ?? (
<ChevronDownIcon className='ml-2 size-4' /> <>
{t('Open in chat')}
<ChevronDownIcon className='ml-2 size-4' />
</>
)}
</Button> </Button>
)} }
</DropdownMenuTrigger> />
) )
} }
...@@ -251,17 +256,20 @@ export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem> ...@@ -251,17 +256,20 @@ export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInChatGPT = (props: OpenInChatGPTProps) => { export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext() const { query } = useOpenInContext()
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem
<a {...props}
className='flex items-center gap-2' render={
href={providers.chatgpt.createUrl(query)} <a
rel='noopener' className='flex items-center gap-2'
target='_blank' href={providers.chatgpt.createUrl(query)}
> rel='noopener'
<span className='shrink-0'>{providers.chatgpt.icon}</span> target='_blank'
<span className='flex-1'>{providers.chatgpt.title}</span> />
<ExternalLinkIcon className='size-4 shrink-0' /> }
</a> >
<span className='shrink-0'>{providers.chatgpt.icon}</span>
<span className='flex-1'>{providers.chatgpt.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -271,17 +279,20 @@ export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem> ...@@ -271,17 +279,20 @@ export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInClaude = (props: OpenInClaudeProps) => { export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext() const { query } = useOpenInContext()
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem
<a {...props}
className='flex items-center gap-2' render={
href={providers.claude.createUrl(query)} <a
rel='noopener' className='flex items-center gap-2'
target='_blank' href={providers.claude.createUrl(query)}
> rel='noopener'
<span className='shrink-0'>{providers.claude.icon}</span> target='_blank'
<span className='flex-1'>{providers.claude.title}</span> />
<ExternalLinkIcon className='size-4 shrink-0' /> }
</a> >
<span className='shrink-0'>{providers.claude.icon}</span>
<span className='flex-1'>{providers.claude.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -291,17 +302,20 @@ export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem> ...@@ -291,17 +302,20 @@ export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInT3 = (props: OpenInT3Props) => { export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext() const { query } = useOpenInContext()
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem
<a {...props}
className='flex items-center gap-2' render={
href={providers.t3.createUrl(query)} <a
rel='noopener' className='flex items-center gap-2'
target='_blank' href={providers.t3.createUrl(query)}
> rel='noopener'
<span className='shrink-0'>{providers.t3.icon}</span> target='_blank'
<span className='flex-1'>{providers.t3.title}</span> />
<ExternalLinkIcon className='size-4 shrink-0' /> }
</a> >
<span className='shrink-0'>{providers.t3.icon}</span>
<span className='flex-1'>{providers.t3.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -311,17 +325,20 @@ export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem> ...@@ -311,17 +325,20 @@ export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInScira = (props: OpenInSciraProps) => { export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext() const { query } = useOpenInContext()
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem
<a {...props}
className='flex items-center gap-2' render={
href={providers.scira.createUrl(query)} <a
rel='noopener' className='flex items-center gap-2'
target='_blank' href={providers.scira.createUrl(query)}
> rel='noopener'
<span className='shrink-0'>{providers.scira.icon}</span> target='_blank'
<span className='flex-1'>{providers.scira.title}</span> />
<ExternalLinkIcon className='size-4 shrink-0' /> }
</a> >
<span className='shrink-0'>{providers.scira.icon}</span>
<span className='flex-1'>{providers.scira.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -331,17 +348,20 @@ export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem> ...@@ -331,17 +348,20 @@ export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInv0 = (props: OpenInv0Props) => { export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext() const { query } = useOpenInContext()
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem
<a {...props}
className='flex items-center gap-2' render={
href={providers.v0.createUrl(query)} <a
rel='noopener' className='flex items-center gap-2'
target='_blank' href={providers.v0.createUrl(query)}
> rel='noopener'
<span className='shrink-0'>{providers.v0.icon}</span> target='_blank'
<span className='flex-1'>{providers.v0.title}</span> />
<ExternalLinkIcon className='size-4 shrink-0' /> }
</a> >
<span className='shrink-0'>{providers.v0.icon}</span>
<span className='flex-1'>{providers.v0.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -351,17 +371,20 @@ export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem> ...@@ -351,17 +371,20 @@ export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInCursor = (props: OpenInCursorProps) => { export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext() const { query } = useOpenInContext()
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem
<a {...props}
className='flex items-center gap-2' render={
href={providers.cursor.createUrl(query)} <a
rel='noopener' className='flex items-center gap-2'
target='_blank' href={providers.cursor.createUrl(query)}
> rel='noopener'
<span className='shrink-0'>{providers.cursor.icon}</span> target='_blank'
<span className='flex-1'>{providers.cursor.title}</span> />
<ExternalLinkIcon className='size-4 shrink-0' /> }
</a> >
<span className='shrink-0'>{providers.cursor.icon}</span>
<span className='flex-1'>{providers.cursor.title}</span>
<ExternalLinkIcon className='size-4 shrink-0' />
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -46,8 +46,12 @@ export const Plan = ({ ...@@ -46,8 +46,12 @@ export const Plan = ({
...props ...props
}: PlanProps) => ( }: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}> <PlanContext.Provider value={{ isStreaming }}>
<Collapsible asChild data-slot='plan' {...props}> <Collapsible
<Card className={cn('shadow-none', className)}>{children}</Card> data-slot='plan'
{...props}
render={<Card className={cn('shadow-none', className)} />}
>
{children}
</Collapsible> </Collapsible>
</PlanContext.Provider> </PlanContext.Provider>
) )
...@@ -113,9 +117,9 @@ export const PlanAction = (props: PlanActionProps) => ( ...@@ -113,9 +117,9 @@ export const PlanAction = (props: PlanActionProps) => (
export type PlanContentProps = ComponentProps<typeof CardContent> export type PlanContentProps = ComponentProps<typeof CardContent>
export const PlanContent = (props: PlanContentProps) => ( export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild> <CollapsibleContent
<CardContent data-slot='plan-content' {...props} /> render={<CardContent data-slot='plan-content' {...props} />}
</CollapsibleContent> ></CollapsibleContent>
) )
export type PlanFooterProps = ComponentProps<'div'> export type PlanFooterProps = ComponentProps<'div'>
...@@ -129,17 +133,19 @@ export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger> ...@@ -129,17 +133,19 @@ export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => { export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<CollapsibleTrigger asChild> <CollapsibleTrigger
<Button render={
className={cn('size-8', className)} <Button
data-slot='plan-trigger' className={cn('size-8', className)}
size='icon' data-slot='plan-trigger'
variant='ghost' size='icon'
{...props} variant='ghost'
> />
<ChevronsUpDownIcon className='size-4' /> }
<span className='sr-only'>{t('Toggle plan')}</span> {...props}
</Button> >
<ChevronsUpDownIcon className='size-4' />
<span className='sr-only'>{t('Toggle plan')}</span>
</CollapsibleTrigger> </CollapsibleTrigger>
) )
} }
...@@ -277,49 +277,51 @@ export function PromptInputAttachment({ ...@@ -277,49 +277,51 @@ export function PromptInputAttachment({
return ( return (
<PromptInputHoverCard> <PromptInputHoverCard>
<HoverCardTrigger asChild> <PromptInputHoverCardTrigger
<div render={
className={cn( <div
'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={cn(
className '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} 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 <div className='relative size-5 shrink-0'>
alt={filename || 'attachment'} <div className='bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0'>
className='size-5 object-cover' {isImage ? (
height={20} <img
src={data.url} alt={filename || 'attachment'}
width={20} className='size-5 object-cover'
/> height={20}
) : ( src={data.url}
<div className='text-muted-foreground flex size-5 items-center justify-center'> width={20}
<PaperclipIcon className='size-3' /> />
</div> ) : (
)} <div className='text-muted-foreground flex size-5 items-center justify-center'>
</div> <PaperclipIcon className='size-3' />
<Button </div>
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> </div>
<Button
<span className='flex-1 truncate'>{attachmentLabel}</span> 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> </div>
</HoverCardTrigger>
<span className='flex-1 truncate'>{attachmentLabel}</span>
</PromptInputHoverCardTrigger>
<PromptInputHoverCardContent className='w-auto p-2'> <PromptInputHoverCardContent className='w-auto p-2'>
<div className='w-auto space-y-3'> <div className='w-auto space-y-3'>
{isImage && ( {isImage && (
...@@ -956,10 +958,10 @@ export const PromptInputActionMenuTrigger = ({ ...@@ -956,10 +958,10 @@ export const PromptInputActionMenuTrigger = ({
children, children,
...props ...props
}: PromptInputActionMenuTriggerProps) => ( }: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<PromptInputButton className={className} {...props}> render={<PromptInputButton className={className} {...props} />}
{children ?? <PlusIcon className='size-4' />} >
</PromptInputButton> {children ?? <PlusIcon className='size-4' />}
</DropdownMenuTrigger> </DropdownMenuTrigger>
) )
...@@ -1242,21 +1244,21 @@ export const PromptInputModelSelectValue = ({ ...@@ -1242,21 +1244,21 @@ export const PromptInputModelSelectValue = ({
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard> export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>
export const PromptInputHoverCard = ({ export const PromptInputHoverCard = (props: PromptInputHoverCardProps) => (
openDelay = 0, <HoverCard {...props} />
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
) )
export type PromptInputHoverCardTriggerProps = ComponentProps< export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger typeof HoverCardTrigger
> >
export const PromptInputHoverCardTrigger = ( export const PromptInputHoverCardTrigger = ({
props: PromptInputHoverCardTriggerProps delay = 0,
) => <HoverCardTrigger {...props} /> closeDelay = 0,
...props
}: PromptInputHoverCardTriggerProps) => (
<HoverCardTrigger delay={delay} closeDelay={closeDelay} {...props} />
)
export type PromptInputHoverCardContentProps = ComponentProps< export type PromptInputHoverCardContentProps = ComponentProps<
typeof HoverCardContent typeof HoverCardContent
......
...@@ -212,18 +212,20 @@ export const QueueSectionTrigger = ({ ...@@ -212,18 +212,20 @@ export const QueueSectionTrigger = ({
className, className,
...props ...props
}: QueueSectionTriggerProps) => ( }: QueueSectionTriggerProps) => (
<CollapsibleTrigger asChild> <CollapsibleTrigger
<Button render={
variant='ghost' <Button
className={cn( variant='ghost'
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left', className={cn(
className '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} type='button'
> {...props}
{children} />
</Button> }
>
{children}
</CollapsibleTrigger> </CollapsibleTrigger>
) )
...@@ -242,7 +244,7 @@ export const QueueSectionLabel = ({ ...@@ -242,7 +244,7 @@ export const QueueSectionLabel = ({
...props ...props
}: QueueSectionLabelProps) => ( }: QueueSectionLabelProps) => (
<span className={cn('flex items-center gap-2', className)} {...props}> <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} {icon}
<span> <span>
{count} {label} {count} {label}
......
...@@ -8,8 +8,8 @@ import { ...@@ -8,8 +8,8 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { BrainIcon, ChevronDownIcon } from 'lucide-react' import { BrainIcon, ChevronDownIcon } from 'lucide-react'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Collapsible, Collapsible,
...@@ -171,7 +171,7 @@ export const ReasoningContent = memo( ...@@ -171,7 +171,7 @@ export const ReasoningContent = memo(
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'mt-4 text-sm', '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 className
)} )}
{...props} {...props}
......
...@@ -56,7 +56,7 @@ export const SourcesContent = ({ ...@@ -56,7 +56,7 @@ export const SourcesContent = ({
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'mt-3 flex w-fit flex-col gap-2', '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 className
)} )}
{...props} {...props}
......
...@@ -55,15 +55,21 @@ export const TaskTrigger = ({ ...@@ -55,15 +55,21 @@ export const TaskTrigger = ({
title, title,
...props ...props
}: TaskTriggerProps) => ( }: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}> <CollapsibleTrigger
{children ?? ( 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'> <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' /> {children ?? (
<p className='text-sm'>{title}</p> <>
<ChevronDownIcon className='size-4 transition-transform group-data-[state=open]:rotate-180' /> <SearchIcon className='size-4' />
<p className='text-sm'>{title}</p>
<ChevronDownIcon className='size-4 transition-transform group-data-[panel-open]:rotate-180' />
</>
)}
</div> </div>
)} }
</CollapsibleTrigger> />
) )
export type TaskContentProps = ComponentProps<typeof CollapsibleContent> export type TaskContentProps = ComponentProps<typeof CollapsibleContent>
...@@ -75,7 +81,7 @@ export const TaskContent = ({ ...@@ -75,7 +81,7 @@ export const TaskContent = ({
}: TaskContentProps) => ( }: TaskContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( 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 className
)} )}
{...props} {...props}
......
...@@ -81,7 +81,7 @@ export const ToolHeader = ({ ...@@ -81,7 +81,7 @@ export const ToolHeader = ({
}: ToolHeaderProps) => ( }: ToolHeaderProps) => (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -93,7 +93,7 @@ export const ToolHeader = ({ ...@@ -93,7 +93,7 @@ export const ToolHeader = ({
</span> </span>
{getStatusBadge(state)} {getStatusBadge(state)}
</div> </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> </CollapsibleTrigger>
) )
...@@ -102,7 +102,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent> ...@@ -102,7 +102,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
export const ToolContent = ({ className, ...props }: ToolContentProps) => ( export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( 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 className
)} )}
{...props} {...props}
......
...@@ -113,17 +113,19 @@ export const WebPreviewNavigationButton = ({ ...@@ -113,17 +113,19 @@ export const WebPreviewNavigationButton = ({
}: WebPreviewNavigationButtonProps) => ( }: WebPreviewNavigationButtonProps) => (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger
<Button render={
className='hover:text-foreground h-8 w-8 p-0' <Button
disabled={disabled} className='hover:text-foreground h-8 w-8 p-0'
onClick={onClick} disabled={disabled}
size='sm' onClick={onClick}
variant='ghost' size='sm'
{...props} variant='ghost'
> {...props}
{children} />
</Button> }
>
{children}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{tooltip}</p> <p>{tooltip}</p>
...@@ -225,24 +227,26 @@ export const WebPreviewConsole = ({ ...@@ -225,24 +227,26 @@ export const WebPreviewConsole = ({
open={consoleOpen} open={consoleOpen}
{...props} {...props}
> >
<CollapsibleTrigger asChild> <CollapsibleTrigger
<Button render={
className='hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium' <Button
variant='ghost' 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'
)}
/> />
</Button> }
>
{t('Console')}
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
consoleOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'px-4 pb-4', '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'> <div className='max-h-48 space-y-1 overflow-y-auto'>
......
...@@ -6,6 +6,7 @@ import { useSearch } from '@/context/search-provider' ...@@ -6,6 +6,7 @@ import { useSearch } from '@/context/search-provider'
import { useTheme } from '@/context/theme-provider' import { useTheme } from '@/context/theme-provider'
import { useSidebarData } from '@/hooks/use-sidebar-data' import { useSidebarData } from '@/hooks/use-sidebar-data'
import { import {
Command,
CommandDialog, CommandDialog,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
...@@ -38,62 +39,66 @@ export function CommandMenu() { ...@@ -38,62 +39,66 @@ export function CommandMenu() {
return ( return (
<CommandDialog modal open={open} onOpenChange={setOpen}> <CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t('Type a command or search...')} /> <Command>
<CommandList> <CommandInput placeholder={t('Type a command or search...')} />
<ScrollArea type='hover' className='h-72 pe-1'> <CommandList>
<CommandEmpty>{t('No results found.')}</CommandEmpty> <ScrollArea className='h-72 pe-1'>
{navGroups.map((group) => ( <CommandEmpty>{t('No results found.')}</CommandEmpty>
<CommandGroup key={group.id || group.title} heading={group.title}> {navGroups.map((group) => (
{group.items.map((navItem, i) => { <CommandGroup key={group.id || group.title} heading={group.title}>
if (navItem.url) {group.items.map((navItem, i) => {
return ( 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 <CommandItem
key={`${navItem.url}-${i}`} key={`${navItem.title}-${subItem.url}-${i}`}
value={navItem.title} value={`${navItem.title}-${subItem.url}`}
onSelect={() => { onSelect={() => {
runCommand(() => navigate({ to: navItem.url })) runCommand(() => navigate({ to: subItem.url }))
}} }}
> >
<div className='flex size-4 items-center justify-center'> <div className='flex size-4 items-center justify-center'>
<ArrowRight className='text-muted-foreground/80 size-2' /> <ArrowRight className='text-muted-foreground/80 size-2' />
</div> </div>
{navItem.title} {navItem.title} <ChevronRight /> {subItem.title}
</CommandItem> </CommandItem>
) ))
})}
return navItem.items?.map((subItem, i) => ( </CommandGroup>
<CommandItem ))}
key={`${navItem.title}-${subItem.url}-${i}`} <CommandSeparator />
value={`${navItem.title}-${subItem.url}`} <CommandGroup heading='Theme'>
onSelect={() => { <CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
runCommand(() => navigate({ to: subItem.url })) <Sun /> <span>{t('Light')}</span>
}} </CommandItem>
> <CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<div className='flex size-4 items-center justify-center'> <Moon className='scale-90' />
<ArrowRight className='text-muted-foreground/80 size-2' /> <span>{t('Dark')}</span>
</div> </CommandItem>
{navItem.title} <ChevronRight /> {subItem.title} <CommandItem
</CommandItem> onSelect={() => runCommand(() => setTheme('system'))}
)) >
})} <Laptop />
<span>{t('System')}</span>
</CommandItem>
</CommandGroup> </CommandGroup>
))} </ScrollArea>
<CommandSeparator /> </CommandList>
<CommandGroup heading='Theme'> </Command>
<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>
</CommandDialog> </CommandDialog>
) )
} }
...@@ -46,8 +46,8 @@ export function ConfirmDialog(props: ConfirmDialogProps) { ...@@ -46,8 +46,8 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
<AlertDialogContent className={cn(className && className)}> <AlertDialogContent className={cn(className && className)}>
<AlertDialogHeader className='text-start'> <AlertDialogHeader className='text-start'>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild> <AlertDialogDescription render={<div />}>
<div>{desc}</div> {desc}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{children} {children}
......
...@@ -61,7 +61,7 @@ export function CopyButton({ ...@@ -61,7 +61,7 @@ export function CopyButton({
if (tooltip || successTooltip) { if (tooltip || successTooltip) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{isCopied ? resolvedSuccessTooltip : resolvedTooltip}</p> <p>{isCopied ? resolvedSuccessTooltip : resolvedTooltip}</p>
</TooltipContent> </TooltipContent>
......
...@@ -88,7 +88,7 @@ export function DataTableBulkActions<TData>({ ...@@ -88,7 +88,7 @@ export function DataTableBulkActions<TData>({
break break
case 'Escape': { case 'Escape': {
// Check if the Escape key came from a dropdown trigger or content // 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 target = event.target as HTMLElement
const activeElement = document.activeElement as HTMLElement const activeElement = document.activeElement as HTMLElement
...@@ -156,18 +156,20 @@ export function DataTableBulkActions<TData>({ ...@@ -156,18 +156,20 @@ export function DataTableBulkActions<TData>({
)} )}
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger
<Button render={
variant='outline' <Button
size='icon' variant='outline'
onClick={handleClearSelection} size='icon'
className='size-6 rounded-full' onClick={handleClearSelection}
aria-label={t('Clear selection')} className='size-6 rounded-full'
title={t('Clear selection (Escape)')} aria-label={t('Clear selection')}
> title={t('Clear selection (Escape)')}
<X /> />
<span className='sr-only'>{t('Clear selection')}</span> }
</Button> >
<X />
<span className='sr-only'>{t('Clear selection')}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t('Clear selection (Escape)')}</p> <p>{t('Clear selection (Escape)')}</p>
......
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table' 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 { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
...@@ -35,21 +35,23 @@ export function DataTableColumnHeader<TData, TValue>({ ...@@ -35,21 +35,23 @@ export function DataTableColumnHeader<TData, TValue>({
return ( return (
<div className={cn('flex items-center space-x-2', className)}> <div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<Button render={
variant='ghost' <Button
size='sm' variant='ghost'
className='data-[state=open]:bg-accent -ms-3 h-8' 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' ? ( <span>{title}</span>
<ArrowUpIcon className='ms-2 h-4 w-4' /> {column.getIsSorted() === 'desc' ? (
) : ( <ArrowDownIcon className='ms-2 h-4 w-4' />
<CaretSortIcon className='ms-2 h-4 w-4' /> ) : column.getIsSorted() === 'asc' ? (
)} <ArrowUpIcon className='ms-2 h-4 w-4' />
</Button> ) : (
<CaretSortIcon className='ms-2 h-4 w-4' />
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='start'> <DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}> <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
......
import * as React from 'react' import * as React from 'react'
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table' import { type Column } from '@tanstack/react-table'
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
...@@ -48,44 +48,46 @@ export function DataTableFacetedFilter<TData, TValue>({ ...@@ -48,44 +48,46 @@ export function DataTableFacetedFilter<TData, TValue>({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger
<Button variant='outline' size='sm' className='h-8 border-dashed'> render={
<PlusCircledIcon className='size-4' /> <Button variant='outline' size='sm' className='h-8 border-dashed' />
{title} }
{selectedValues?.size > 0 && ( >
<> <PlusCircledIcon className='size-4' />
<Separator orientation='vertical' className='mx-2 h-4' /> {title}
<Badge {selectedValues?.size > 0 && (
variant='secondary' <>
className='rounded-sm px-1 font-normal lg:hidden' <Separator orientation='vertical' className='mx-2 h-4' />
> <Badge
{selectedValues.size} variant='secondary'
</Badge> className='rounded-sm px-1 font-normal lg:hidden'
<div className='hidden space-x-1 lg:flex'> >
{selectedValues.size > 2 ? ( {selectedValues.size}
<Badge </Badge>
variant='secondary' <div className='hidden space-x-1 lg:flex'>
className='rounded-sm px-1 font-normal' {selectedValues.size > 2 ? (
> <Badge
{selectedValues.size} {t('selected')} variant='secondary'
</Badge> className='rounded-sm px-1 font-normal'
) : ( >
options {selectedValues.size} {t('selected')}
.filter((option) => selectedValues.has(option.value)) </Badge>
.map((option) => ( ) : (
<Badge options
variant='secondary' .filter((option) => selectedValues.has(option.value))
key={option.value} .map((option) => (
className='rounded-sm px-1 font-normal' <Badge
> variant='secondary'
{t(option.label)} key={option.value}
</Badge> className='rounded-sm px-1 font-normal'
)) >
)} {t(option.label)}
</div> </Badge>
</> ))
)} )}
</Button> </div>
</>
)}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-[200px] p-0' align='start'> <PopoverContent className='w-[200px] p-0' align='start'>
<Command> <Command>
......
...@@ -7,6 +7,7 @@ export { DataTableBulkActions } from './bulk-actions' ...@@ -7,6 +7,7 @@ export { DataTableBulkActions } from './bulk-actions'
export { TableSkeleton } from './table-skeleton' export { TableSkeleton } from './table-skeleton'
export { TableEmpty } from './table-empty' export { TableEmpty } from './table-empty'
export { MobileCardList } from './mobile-card-list' export { MobileCardList } from './mobile-card-list'
export { DataTablePage, type DataTablePageProps } from './data-table-page'
export const DISABLED_ROW_DESKTOP = 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' '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 { ...@@ -6,6 +6,7 @@ import {
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
...@@ -14,7 +15,6 @@ import { ...@@ -14,7 +15,6 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface MobileCardListProps<TData> { interface MobileCardListProps<TData> {
table: Table<TData> table: Table<TData>
......
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table' 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 { useTranslation } from 'react-i18next'
import { cn, getPageNumbers } from '@/lib/utils' import { cn, getPageNumbers } from '@/lib/utils'
import { Button } from '@/components/ui/button' 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 { type Table } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
...@@ -7,8 +5,9 @@ import { ...@@ -7,8 +5,9 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
type DataTableViewOptionsProps<TData> = { type DataTableViewOptionsProps<TData> = {
...@@ -21,37 +20,39 @@ export function DataTableViewOptions<TData>({ ...@@ -21,37 +20,39 @@ export function DataTableViewOptions<TData>({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<Button render={
variant='outline' <Button
size='sm' variant='outline'
className='ms-auto h-9 w-9 px-0 sm:h-8 sm:w-auto sm:px-3 lg:flex' className='shrink-0'
> aria-label={t('View')}
<MixerHorizontalIcon className='size-4' /> />
<span className='hidden sm:inline'>{t('View')}</span> }
</Button> >
{t('View')}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'> <DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel> <DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
{table {table
.getAllColumns() .getAllColumns()
.filter( .filter(
(column) => (column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide() 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>
) )
})} .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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
......
...@@ -36,19 +36,21 @@ export function DatePicker({ ...@@ -36,19 +36,21 @@ export function DatePicker({
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger
<Button render={
variant='outline' <Button
data-empty={!selected} variant='outline'
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal' 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> {selected ? (
)} dayjs(selected).format('YYYY-MM-DD')
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' /> ) : (
</Button> <span>{placeholderText}</span>
)}
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-auto p-0'> <PopoverContent className='w-auto p-0'>
<Calendar <Calendar
......
...@@ -93,17 +93,19 @@ export function DateTimePicker({ ...@@ -93,17 +93,19 @@ export function DateTimePicker({
return ( return (
<div className={cn('flex gap-2', className)}> <div className={cn('flex gap-2', className)}>
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger
<Button render={
variant='outline' <Button
className={cn( variant='outline'
'flex-1 justify-between font-normal', className={cn(
!date && 'text-muted-foreground' '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> >
{date ? dayjs(date).format('YYYY-MM-DD') : placeholderText}
<ChevronDownIcon className='h-4 w-4 opacity-50' />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-auto overflow-hidden p-0' align='start'> <PopoverContent className='w-auto overflow-hidden p-0' align='start'>
<Calendar <Calendar
......
...@@ -41,11 +41,17 @@ export function LanguageSwitcher() { ...@@ -41,11 +41,17 @@ export function LanguageSwitcher() {
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<Button variant='ghost' size='icon' className='h-9 w-9 rounded-full'> render={
<Languages className='size-[1.2rem]' /> <Button
<span className='sr-only'>{t('Change language')}</span> variant='ghost'
</Button> size='icon'
className='h-9 w-9 rounded-full'
/>
}
>
<Languages className='size-[1.2rem]' />
<span className='sr-only'>{t('Change language')}</span>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
{languages.map((lang) => ( {languages.map((lang) => (
......
import { useNotifications } from '@/hooks/use-notifications' import { useNotifications } from '@/hooks/use-notifications'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import { useTopNavLinks } from '@/hooks/use-top-nav-links' import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { ConfigDrawer } from '@/components/config-drawer' import { ConfigDrawer } from '@/components/config-drawer'
import { LanguageSwitcher } from '@/components/language-switcher' import { LanguageSwitcher } from '@/components/language-switcher'
...@@ -10,6 +11,7 @@ import { defaultTopNavLinks } from '../config/top-nav.config' ...@@ -10,6 +11,7 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types' import { type TopNavLink } from '../types'
import { Header } from './header' import { Header } from './header'
import { TopNav } from './top-nav' import { TopNav } from './top-nav'
import { WorkspaceSwitcher } from './workspace-switcher'
/** /**
* General application Header component * General application Header component
...@@ -87,20 +89,30 @@ export function AppHeader({ ...@@ -87,20 +89,30 @@ export function AppHeader({
// Prioritize dynamically generated links from backend // Prioritize dynamically generated links from backend
const dynamicLinks = useTopNavLinks() const dynamicLinks = useTopNavLinks()
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
const sidebarData = useSidebarData()
// Notifications hook // Notifications hook
const notifications = useNotifications() const notifications = useNotifications()
// Determine left content: custom content > navigation bar > null
const leftSection =
leftContent || (showTopNav ? <TopNav links={links} /> : null)
return ( return (
<> <>
<Header> <Header>
{leftSection} <WorkspaceSwitcher
variant='inline'
workspaces={sidebarData.workspaces}
/>
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{rightContent ?? ( {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 />} {showSearch && <Search />}
{showNotifications && ( {showNotifications && (
<NotificationButton <NotificationButton
......
...@@ -6,15 +6,9 @@ import { ROLE } from '@/lib/roles' ...@@ -6,15 +6,9 @@ import { ROLE } from '@/lib/roles'
import { useLayout } from '@/context/layout-provider' import { useLayout } from '@/context/layout-provider'
import { useSidebarConfig } from '@/hooks/use-sidebar-config' import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { useSidebarData } from '@/hooks/use-sidebar-data' import { useSidebarData } from '@/hooks/use-sidebar-data'
import { import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
Sidebar,
SidebarContent,
SidebarHeader,
SidebarRail,
} from '@/components/ui/sidebar'
import { getNavGroupsForPath } from '../lib/workspace-registry' import { getNavGroupsForPath } from '../lib/workspace-registry'
import { NavGroup } from './nav-group' import { NavGroup } from './nav-group'
import { WorkspaceSwitcher } from './workspace-switcher'
/** /**
* Application sidebar component * Application sidebar component
...@@ -51,10 +45,7 @@ export function AppSidebar() { ...@@ -51,10 +45,7 @@ export function AppSidebar() {
return ( return (
<Sidebar collapsible={collapsible} variant={variant}> <Sidebar collapsible={collapsible} variant={variant}>
<SidebarHeader> <SidebarContent className='py-2'>
<WorkspaceSwitcher workspaces={sidebarData.workspaces} />
</SidebarHeader>
<SidebarContent>
{currentNavGroups.map((props) => { {currentNavGroups.map((props) => {
const key = props.id || props.title const key = props.id || props.title
return <NavGroup key={key} {...props} /> return <NavGroup key={key} {...props} />
......
...@@ -6,6 +6,7 @@ import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' ...@@ -6,6 +6,7 @@ import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { AnimatedOutlet } from '@/components/page-transition' import { AnimatedOutlet } from '@/components/page-transition'
import { SkipToMain } from '@/components/skip-to-main' import { SkipToMain } from '@/components/skip-to-main'
import { WorkspaceProvider } from '../context/workspace-context' import { WorkspaceProvider } from '../context/workspace-context'
import { AppHeader } from './app-header'
import { AppSidebar } from './app-sidebar' import { AppSidebar } from './app-sidebar'
type AuthenticatedLayoutProps = { type AuthenticatedLayoutProps = {
...@@ -19,18 +20,21 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) { ...@@ -19,18 +20,21 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
<LayoutProvider> <LayoutProvider>
<SearchProvider> <SearchProvider>
<WorkspaceProvider> <WorkspaceProvider>
<SidebarProvider defaultOpen={defaultOpen}> <SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
<SkipToMain /> <SkipToMain />
<AppSidebar /> <AppHeader />
<SidebarInset <div className='flex min-h-0 w-full flex-1'>
className={cn( <AppSidebar />
'@container/content', <SidebarInset
'h-svh', className={cn(
'peer-data-[variant=inset]:h-[calc(100svh-(var(--spacing)*4))]' '@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> >
{props.children ?? <AnimatedOutlet />}
</SidebarInset>
</div>
</SidebarProvider> </SidebarProvider>
</WorkspaceProvider> </WorkspaceProvider>
</SearchProvider> </SearchProvider>
......
...@@ -53,14 +53,17 @@ function ChatMenuItem({ ...@@ -53,14 +53,17 @@ function ChatMenuItem({
if (preset.type === 'web') { if (preset.type === 'web') {
return ( return (
<SidebarMenuSubItem> <SidebarMenuSubItem>
<SidebarMenuSubButton asChild isActive={active}> <SidebarMenuSubButton
<Link isActive={active}
to='/chat/$chatId' render={
params={{ chatId: preset.id }} <Link
onClick={onNavigate} to='/chat/$chatId'
> params={{ chatId: preset.id }}
<span>{preset.name}</span> onClick={onNavigate}
</Link> />
}
>
<span>{preset.name}</span>
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
) )
...@@ -92,10 +95,10 @@ function DropdownPresetItem({ ...@@ -92,10 +95,10 @@ function DropdownPresetItem({
}) { }) {
if (preset.type === 'web') { if (preset.type === 'web') {
return ( return (
<DropdownMenuItem asChild> <DropdownMenuItem
<Link to='/chat/$chatId' params={{ chatId: preset.id }}> render={<Link to='/chat/$chatId' params={{ chatId: preset.id }} />}
{preset.name} >
</Link> {preset.name}
</DropdownMenuItem> </DropdownMenuItem>
) )
} }
...@@ -187,12 +190,12 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { ...@@ -187,12 +190,12 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
return ( return (
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<SidebarMenuButton tooltip={item.title}> render={<SidebarMenuButton tooltip={item.title} />}
{item.icon && <item.icon className='h-4 w-4' />} >
<span>{item.title}</span> {item.icon && <item.icon className='h-4 w-4' />}
<ChevronRight className='ms-auto h-4 w-4 opacity-70' /> <span>{item.title}</span>
</SidebarMenuButton> <ChevronRight className='ms-auto h-4 w-4 opacity-70' />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='start'> <DropdownMenuContent align='start'>
{visiblePresets.map((preset) => ( {visiblePresets.map((preset) => (
...@@ -218,40 +221,39 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { ...@@ -218,40 +221,39 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
// Expanded state - render collapsible menu // Expanded state - render collapsible menu
return ( return (
<Collapsible <Collapsible
asChild
defaultOpen={normalizedHref.startsWith('/chat')} defaultOpen={normalizedHref.startsWith('/chat')}
className='group/collapsible' className='group/collapsible'
render={<SidebarMenuItem />}
> >
<SidebarMenuItem> <CollapsibleTrigger
<CollapsibleTrigger asChild> className='group/collapsible-trigger'
<SidebarMenuButton> render={<SidebarMenuButton />}
{item.icon && <item.icon />} >
<span>{item.title}</span> {item.icon && <item.icon />}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' /> <span>{item.title}</span>
</SidebarMenuButton> <ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'> <CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub> <SidebarMenuSub>
{visiblePresets.map((preset) => ( {visiblePresets.map((preset) => (
<ChatMenuItem <ChatMenuItem
key={preset.id} key={preset.id}
preset={preset} preset={preset}
active={normalizedHref === `/chat/${preset.id}`} active={normalizedHref === `/chat/${preset.id}`}
onOpen={handleOpenExternal} onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)} onNavigate={() => setOpenMobile(false)}
/> />
))} ))}
{hasKeyDependentPresets && isKeyPending && ( {hasKeyDependentPresets && isKeyPending && (
<SidebarMenuSubItem> <SidebarMenuSubItem>
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}> <SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage} {loadingMessage}
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
)} )}
</SidebarMenuSub> </SidebarMenuSub>
</CollapsibleContent> </CollapsibleContent>
</SidebarMenuItem>
</Collapsible> </Collapsible>
) )
} }
...@@ -67,7 +67,7 @@ function ProjectAttribution(props: { currentYear: number }) { ...@@ -67,7 +67,7 @@ function ProjectAttribution(props: { currentYear: number }) {
href='https://github.com/QuantumNous/new-api' href='https://github.com/QuantumNous/new-api'
target='_blank' target='_blank'
rel='noopener noreferrer' 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')} {t('New API')}
</a> </a>
...@@ -152,7 +152,12 @@ export function Footer(props: FooterProps) { ...@@ -152,7 +152,12 @@ export function Footer(props: FooterProps) {
if (footerHtml) { if (footerHtml) {
return ( 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='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 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 <div
......
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar' import { SidebarTrigger } from '@/components/ui/sidebar'
type HeaderProps = React.HTMLAttributes<HTMLElement> type HeaderProps = React.HTMLAttributes<HTMLElement>
...@@ -7,12 +6,14 @@ type HeaderProps = React.HTMLAttributes<HTMLElement> ...@@ -7,12 +6,14 @@ type HeaderProps = React.HTMLAttributes<HTMLElement>
export function Header({ className, children, ...props }: HeaderProps) { export function Header({ className, children, ...props }: HeaderProps) {
return ( return (
<header <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} {...props}
> >
<div className='flex h-full items-center gap-3 p-4 sm:gap-4'> <div className='flex h-full items-center gap-1.5 px-2 sm:gap-2 sm:px-3'>
<SidebarTrigger variant='outline' /> <SidebarTrigger variant='ghost' className='size-8' />
<Separator orientation='vertical' className='h-6' />
{children} {children}
</div> </div>
</header> </header>
......
...@@ -137,10 +137,13 @@ interface MobileSignInButtonProps { ...@@ -137,10 +137,13 @@ interface MobileSignInButtonProps {
function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) { function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Button variant='secondary' size='sm' asChild className='h-10 w-full'> <Button
<Link to='/sign-in' onClick={onNavigate}> variant='secondary'
{t('Sign in')} size='sm'
</Link> className='h-10 w-full'
render={<Link to='/sign-in' onClick={onNavigate} />}
>
{t('Sign in')}
</Button> </Button>
) )
} }
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
...@@ -44,8 +45,10 @@ export function NavGroup({ title, items }: NavGroupProps) { ...@@ -44,8 +45,10 @@ export function NavGroup({ title, items }: NavGroupProps) {
const href = useLocation({ select: (location) => location.href }) const href = useLocation({ select: (location) => location.href })
return ( return (
<SidebarGroup> <SidebarGroup className='px-2 py-1'>
<SidebarGroupLabel>{title}</SidebarGroupLabel> <SidebarGroupLabel className='text-muted-foreground/70 px-2 text-[11px] font-medium tracking-wider uppercase'>
{title}
</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{items.map((item) => { {items.map((item) => {
const key = `${item.title}-${item.url || item.type}` const key = `${item.title}-${item.url || item.type}`
...@@ -102,15 +105,13 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { ...@@ -102,15 +105,13 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
return ( return (
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
asChild
isActive={checkIsActive(href, item)} isActive={checkIsActive(href, item)}
tooltip={item.title} tooltip={item.title}
render={<Link to={item.url} onClick={() => setOpenMobile(false)} />}
> >
<Link to={item.url} onClick={() => setOpenMobile(false)}> {item.icon && <item.icon />}
{item.icon && <item.icon />} <span>{item.title}</span>
<span>{item.title}</span> {item.badge && <NavBadge>{item.badge}</NavBadge>}
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
) )
...@@ -142,39 +143,38 @@ function SidebarMenuCollapsible({ ...@@ -142,39 +143,38 @@ function SidebarMenuCollapsible({
return ( return (
<Collapsible <Collapsible
asChild
open={isOpen} open={isOpen}
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
className='group/collapsible' className='group/collapsible'
render={<SidebarMenuItem />}
> >
<SidebarMenuItem> <CollapsibleTrigger
<CollapsibleTrigger asChild> className='group/collapsible-trigger'
<SidebarMenuButton tooltip={item.title}> render={<SidebarMenuButton tooltip={item.title} />}
{item.icon && <item.icon />} >
<span>{item.title}</span> {item.icon && <item.icon />}
{item.badge && <NavBadge>{item.badge}</NavBadge>} <span>{item.title}</span>
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' /> {item.badge && <NavBadge>{item.badge}</NavBadge>}
</SidebarMenuButton> <ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'> <CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub> <SidebarMenuSub>
{item.items.map((subItem) => ( {item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}> <SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton <SidebarMenuSubButton
asChild isActive={checkIsActive(href, subItem)}
isActive={checkIsActive(href, subItem)} render={
> <Link to={subItem.url} onClick={() => setOpenMobile(false)} />
<Link to={subItem.url} onClick={() => setOpenMobile(false)}> }
{subItem.icon && <subItem.icon />} >
<span>{subItem.title}</span> {subItem.icon && <subItem.icon />}
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>} <span>{subItem.title}</span>
</Link> {subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
))} ))}
</SidebarMenuSub> </SidebarMenuSub>
</CollapsibleContent> </CollapsibleContent>
</SidebarMenuItem>
</Collapsible> </Collapsible>
) )
} }
...@@ -192,36 +192,44 @@ function SidebarMenuCollapsedDropdown({ ...@@ -192,36 +192,44 @@ function SidebarMenuCollapsedDropdown({
return ( return (
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<SidebarMenuButton className='group/dropdown-trigger'
tooltip={item.title} render={
isActive={checkIsActive(href, item)} <SidebarMenuButton
> tooltip={item.title}
{item.icon && <item.icon />} isActive={checkIsActive(href, item)}
<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> {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> </DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}> <DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuLabel> <DropdownMenuGroup>
{item.title} {item.badge ? `(${item.badge})` : ''} <DropdownMenuLabel>
</DropdownMenuLabel> {item.title} {item.badge ? `(${item.badge})` : ''}
<DropdownMenuSeparator /> </DropdownMenuLabel>
{item.items.map((sub) => ( <DropdownMenuSeparator />
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild> {item.items.map((sub) => (
<Link <DropdownMenuItem
to={sub.url} key={`${sub.title}-${sub.url}`}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`} render={
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
/>
}
> >
{sub.icon && <sub.icon />} {sub.icon && <sub.icon />}
<span className='max-w-52 text-wrap'>{sub.title}</span> <span className='max-w-52 text-wrap'>{sub.title}</span>
{sub.badge && ( {sub.badge && (
<span className='ms-auto text-xs'>{sub.badge}</span> <span className='ms-auto text-xs'>{sub.badge}</span>
)} )}
</Link> </DropdownMenuItem>
</DropdownMenuItem> ))}
))} </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
......
...@@ -180,9 +180,9 @@ export function PublicHeader(props: PublicHeaderProps) { ...@@ -180,9 +180,9 @@ export function PublicHeader(props: PublicHeaderProps) {
<Button <Button
size='sm' size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium' 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> </Button>
)} )}
</> </>
......
...@@ -16,7 +16,7 @@ type PublicLayoutProps = { ...@@ -16,7 +16,7 @@ type PublicLayoutProps = {
export function PublicLayout(props: PublicLayoutProps) { export function PublicLayout(props: PublicLayoutProps) {
return ( 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 <PublicHeader
navContent={props.navContent} navContent={props.navContent}
navLinks={props.navLinks} navLinks={props.navLinks}
......
...@@ -5,7 +5,6 @@ import { ...@@ -5,7 +5,6 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { AppHeader } from './app-header'
import { Main } from './main' import { Main } from './main'
import { PageFooterProvider } from './page-footer' import { PageFooterProvider } from './page-footer'
...@@ -46,7 +45,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -46,7 +45,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
) )
let title: ReactNode = null let title: ReactNode = null
let description: ReactNode = null
let actions: ReactNode = null let actions: ReactNode = null
let content: ReactNode = null let content: ReactNode = null
let breadcrumb: ReactNode = null let breadcrumb: ReactNode = null
...@@ -55,8 +53,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -55,8 +53,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
if (!isValidElement(node)) return if (!isValidElement(node)) return
const child = node as ReactElement<SlotProps> const child = node as ReactElement<SlotProps>
if (child.type === SectionPageLayoutTitle) title = child.props.children if (child.type === SectionPageLayoutTitle) title = child.props.children
else if (child.type === SectionPageLayoutDescription)
description = child.props.children
else if (child.type === SectionPageLayoutActions) else if (child.type === SectionPageLayoutActions)
actions = child.props.children actions = child.props.children
else if (child.type === SectionPageLayoutContent) else if (child.type === SectionPageLayoutContent)
...@@ -67,21 +63,16 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -67,21 +63,16 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
return ( return (
<PageFooterProvider container={footerContainer}> <PageFooterProvider container={footerContainer}>
<AppHeader />
<Main> <Main>
<div className='shrink-0 px-3 pt-3 pb-2.5 sm:px-4 sm:pt-6 sm:pb-4'> <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>} {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='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
<div className='min-w-0'> <div className='min-w-0'>
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'> <h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
{title} {title}
</h2> </h2>
{description != null && (
<p className='text-muted-foreground line-clamp-2 max-sm:text-xs sm:text-sm'>
{description}
</p>
)}
</div> </div>
{actions != null && ( {actions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'> <div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
...@@ -91,7 +82,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { ...@@ -91,7 +82,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div> </div>
</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} {content}
</div> </div>
......
...@@ -37,34 +37,37 @@ export function TopNav({ className, links, ...props }: TopNavProps) { ...@@ -37,34 +37,37 @@ export function TopNav({ className, links, ...props }: TopNavProps) {
{/* 移动端下拉菜单 */} {/* 移动端下拉菜单 */}
<div className='lg:hidden'> <div className='lg:hidden'>
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<Button size='icon' variant='outline' className='size-7'> render={<Button size='icon' variant='outline' className='size-7' />}
<Menu /> >
</Button> <Menu />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='start'> <DropdownMenuContent side='bottom' align='start'>
{normalizedLinks.map( {normalizedLinks.map(
({ title, href, isActive, disabled, external }) => ( ({ title, href, isActive, disabled, external }) => (
<DropdownMenuItem key={`${title}-${href}`} asChild> <DropdownMenuItem
{external ? ( key={`${title}-${href}`}
<a render={
href={href} external ? (
target='_blank' <a
rel='noopener noreferrer' href={href}
className={!isActive ? 'text-muted-foreground' : ''} target='_blank'
> rel='noopener noreferrer'
{title} className={!isActive ? 'text-muted-foreground' : ''}
</a> >
) : ( {title}
<Link </a>
to={href} ) : (
className={!isActive ? 'text-muted-foreground' : ''} <Link
disabled={disabled} to={href}
> className={!isActive ? 'text-muted-foreground' : ''}
{title} disabled={disabled}
</Link> >
)} {title}
</DropdownMenuItem> </Link>
)
}
></DropdownMenuItem>
) )
)} )}
</DropdownMenuContent> </DropdownMenuContent>
......
...@@ -4,11 +4,13 @@ import { ChevronsUpDown } from 'lucide-react' ...@@ -4,11 +4,13 @@ import { ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles' import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config' import { useSystemConfig } from '@/hooks/use-system-config'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
...@@ -27,6 +29,12 @@ type WorkspaceSwitcherProps = { ...@@ -27,6 +29,12 @@ type WorkspaceSwitcherProps = {
workspaces: Workspace[] workspaces: Workspace[]
defaultName?: string defaultName?: string
defaultVersion?: 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({ ...@@ -39,6 +47,7 @@ export function WorkspaceSwitcher({
workspaces, workspaces,
defaultName = 'New API', defaultName = 'New API',
defaultVersion, defaultVersion,
variant = 'sidebar',
}: WorkspaceSwitcherProps) { }: WorkspaceSwitcherProps) {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
...@@ -121,6 +130,88 @@ export function WorkspaceSwitcher({ ...@@ -121,6 +130,88 @@ export function WorkspaceSwitcher({
} }
const canSwitchWorkspace = availableWorkspaces.length > 1 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 = ( const workspaceButtonContent = (
<> <>
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? ( {activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
...@@ -151,54 +242,32 @@ export function WorkspaceSwitcher({ ...@@ -151,54 +242,32 @@ export function WorkspaceSwitcher({
<SidebarMenuItem> <SidebarMenuItem>
{canSwitchWorkspace ? ( {canSwitchWorkspace ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<SidebarMenuButton render={
size='lg' <SidebarMenuButton
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' size='lg'
> className='data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground'
{workspaceButtonContent} />
</SidebarMenuButton> }
>
{workspaceButtonContent}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg' className='w-(--anchor-width) min-w-56 rounded-lg'
align='start' align='start'
side={isMobile ? 'bottom' : 'right'} side={isMobile ? 'bottom' : 'right'}
sideOffset={4} sideOffset={4}
> >
<DropdownMenuLabel className='text-muted-foreground text-xs'> {renderWorkspaceList()}
{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>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<SidebarMenuButton <SidebarMenuButton
asChild
size='lg' 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> </SidebarMenuButton>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
......
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
import { CircleQuestionMark } from 'lucide-react' import { CircleQuestionMark } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
...@@ -9,9 +8,10 @@ import { ...@@ -9,9 +8,10 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
type LearnMoreProps = React.ComponentProps<typeof Root> & { type LearnMoreProps = Omit<React.ComponentProps<typeof Popover>, 'children'> & {
contentProps?: React.ComponentProps<typeof Content> children?: React.ReactNode
triggerProps?: React.ComponentProps<typeof Trigger> contentProps?: React.ComponentProps<typeof PopoverContent>
triggerProps?: React.ComponentProps<typeof PopoverTrigger>
} }
export function LearnMore({ export function LearnMore({
...@@ -24,14 +24,12 @@ export function LearnMore({ ...@@ -24,14 +24,12 @@ export function LearnMore({
return ( return (
<Popover {...props}> <Popover {...props}>
<PopoverTrigger <PopoverTrigger
asChild
{...triggerProps} {...triggerProps}
className={cn('size-5 rounded-full', triggerProps?.className)} 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>
<span className='sr-only'>{t('Learn more')}</span> <CircleQuestionMark className='size-4 [&>circle]:hidden' />
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
</Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
side='top' side='top'
......
...@@ -46,12 +46,12 @@ export function LongText({ ...@@ -46,12 +46,12 @@ export function LongText({
return ( return (
<> <>
<div className='hidden sm:block'> <div className='hidden sm:block'>
<TooltipProvider delayDuration={0}> <TooltipProvider delay={0}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger
<div ref={ref} className={cn('truncate', className)}> render={<div ref={ref} className={cn('truncate', className)} />}
{children} >
</div> {children}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p className={contentClassName}>{children}</p> <p className={contentClassName}>{children}</p>
...@@ -61,10 +61,10 @@ export function LongText({ ...@@ -61,10 +61,10 @@ export function LongText({
</div> </div>
<div className='sm:hidden'> <div className='sm:hidden'>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger
<div ref={ref} className={cn('truncate', className)}> render={<div ref={ref} className={cn('truncate', className)} />}
{children} >
</div> {children}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className={cn('w-fit', contentClassName)}> <PopoverContent className={cn('w-fit', contentClassName)}>
<p>{children}</p> <p>{children}</p>
......
...@@ -26,10 +26,12 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) { ...@@ -26,10 +26,12 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return ( return (
<div className='flex items-center'> <div className='flex items-center'>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger
<Button variant='ghost' size='sm' className='h-7 font-mono'> render={
{props.maskedValue} <Button variant='ghost' size='sm' className='h-7 font-mono' />
</Button> }
>
{props.maskedValue}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className='w-auto max-w-[min(90vw,28rem)]' className='w-auto max-w-[min(90vw,28rem)]'
......
...@@ -299,20 +299,21 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo( ...@@ -299,20 +299,21 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
</Drawer> </Drawer>
) : ( ) : (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger
<ModelTriggerButton render={
currentLabel={currentModel?.label || t('Model')} <ModelTriggerButton
triggerClassName={className} currentLabel={currentModel?.label || t('Model')}
isDisabled={disabled} triggerClassName={className}
aria-expanded={open} isDisabled={disabled}
/> aria-expanded={open}
</PopoverTrigger> />
}
/>
<PopoverContent <PopoverContent
className='bg-popover z-40 w-[90vw] max-w-[20em] rounded-lg border p-0 !shadow-none sm:w-[20em]' className='bg-popover z-40 w-[90vw] max-w-[20em] rounded-lg border p-0 !shadow-none sm:w-[20em]'
align='start' align='start'
side='bottom' side='bottom'
sideOffset={4} sideOffset={4}
avoidCollisions={true}
collisionPadding={8} collisionPadding={8}
> >
{renderModelCommandContent()} {renderModelCommandContent()}
...@@ -492,20 +493,21 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo( ...@@ -492,20 +493,21 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
</Drawer> </Drawer>
) : ( ) : (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger
<GroupTriggerButton render={
currentLabel={currentGroup?.label || t('Group')} <GroupTriggerButton
triggerClassName={className} currentLabel={currentGroup?.label || t('Group')}
isDisabled={disabled} triggerClassName={className}
aria-expanded={open} isDisabled={disabled}
/> aria-expanded={open}
</PopoverTrigger> />
}
/>
<PopoverContent <PopoverContent
className='bg-popover z-50 w-[90vw] max-w-[14em] rounded-lg border p-0 !shadow-none sm:w-[14em]' className='bg-popover z-50 w-[90vw] max-w-[14em] rounded-lg border p-0 !shadow-none sm:w-[14em]'
align='start' align='start'
side='bottom' side='bottom'
sideOffset={4} sideOffset={4}
avoidCollisions={true}
collisionPadding={8} collisionPadding={8}
> >
{renderGroupCommandContent()} {renderGroupCommandContent()}
......
import { useState } from 'react' import { useMemo } from 'react'
import { Link } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { User, Wallet, LogOut, Settings } from 'lucide-react' import { User, Wallet, LogOut, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { ROLE } from '@/lib/roles' import { ROLE } from '@/lib/roles'
import useDialogState from '@/hooks/use-dialog' import useDialogState from '@/hooks/use-dialog'
import { useUserDisplay } from '@/hooks/use-user-display' 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 { Button } from '@/components/ui/button'
import { import {
Sheet, DropdownMenu,
SheetContent, DropdownMenuContent,
SheetHeader, DropdownMenuItem,
SheetTitle, DropdownMenuSeparator,
SheetTrigger, DropdownMenuTrigger,
SheetClose, } from '@/components/ui/dropdown-menu'
} from '@/components/ui/sheet'
import { SignOutDialog } from '@/components/sign-out-dialog' import { SignOutDialog } from '@/components/sign-out-dialog'
const avatarFallbackClassName = 'font-semibold text-white'
export function ProfileDropdown() { export function ProfileDropdown() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const [open, setOpen] = useDialogState() const [open, setOpen] = useDialogState()
const [sheetOpen, setSheetOpen] = useState(false)
const user = useAuthStore((state) => state.auth.user) 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 isSuperAdmin = user?.role === ROLE.SUPER_ADMIN
const avatarName = user?.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = useMemo(
() => getUserAvatarStyle(avatarName),
[avatarName]
)
return ( return (
<> <>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <DropdownMenu modal={false}>
<SheetTrigger asChild> <DropdownMenuTrigger
<Button variant='ghost' className='relative h-9 w-9 rounded-full p-0'> render={
<Avatar className='h-9 w-9'> <Button
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} /> variant='ghost'
<AvatarFallback>{initials}</AvatarFallback> className='relative size-6 rounded-full p-0'
</Avatar> />
</Button> }
</SheetTrigger>
<SheetContent
side='right'
className='flex w-full flex-col p-0 sm:max-w-sm'
> >
<SheetHeader className='border-b p-4'> <Avatar className='size-6'>
<SheetTitle className='text-left'>{t('User Menu')}</SheetTitle> <AvatarFallback
</SheetHeader> className={`${avatarFallbackClassName} text-[11px]`}
style={avatarFallbackStyle}
<div className='flex flex-1 flex-col overflow-y-auto'> >
{/* User info section */} {avatarFallback}
<div className='border-b p-2.5 pb-6.5'> </AvatarFallback>
<div className='flex items-center gap-2.5'> </Avatar>
<Avatar className='size-9'> </DropdownMenuTrigger>
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} /> <DropdownMenuContent align='end' sideOffset={8} className='w-56'>
<AvatarFallback className='text-xs'> <div className='flex items-center gap-2 px-1.5 py-1.5'>
{initials} <Avatar className='size-8'>
</AvatarFallback> <AvatarFallback
</Avatar> className={`${avatarFallbackClassName} text-xs`}
<div className='flex flex-1 flex-col gap-0.5 overflow-hidden'> style={avatarFallbackStyle}
<p className='text-foreground truncate text-sm font-medium'> >
{displayName} {avatarFallback}
</p> </AvatarFallback>
<div className='flex items-center gap-1.5'> </Avatar>
<span className='text-muted-foreground text-xs'> <div className='flex flex-1 flex-col gap-0.5 overflow-hidden'>
{roleLabel} <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> </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> </div>
</div>
{/* Navigation links */} <DropdownMenuSeparator />
<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>
<SheetClose asChild> <DropdownMenuItem onClick={() => navigate({ to: '/profile' })}>
<Link <User className='size-4' />
to='/wallet' {t('Profile')}
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors' </DropdownMenuItem>
>
<Wallet className='size-4' />
{t('Wallet')}
</Link>
</SheetClose>
{/* System Settings - only for super admin */} <DropdownMenuItem onClick={() => navigate({ to: '/wallet' })}>
{isSuperAdmin && ( <Wallet className='size-4' />
<SheetClose asChild> {t('Wallet')}
<Link </DropdownMenuItem>
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>
)}
{/* Sign out */} {isSuperAdmin && (
<Button <DropdownMenuItem
variant='ghost' onClick={() =>
onClick={() => { navigate({
setSheetOpen(false) to: '/system-settings/general',
setOpen(true) search: { section: 'system-info' },
}} })
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent' }
> >
<LogOut className='size-4' /> <Settings className='size-4' />
{t('Sign out')} {t('System Settings')}
</Button> </DropdownMenuItem>
</div> )}
</SheetContent>
</Sheet> <DropdownMenuSeparator />
<DropdownMenuItem variant='destructive' onClick={() => setOpen(true)}>
<LogOut className='size-4' />
{t('Sign out')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SignOutDialog open={!!open} onOpenChange={setOpen} /> <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({ ...@@ -110,7 +110,8 @@ export function StatusBadge({
onClick?.(e) onClick?.(e)
} }
const content = children ?? (label ? <span className='truncate'>{label}</span> : null) const content =
children ?? (label ? <span className='truncate'>{label}</span> : null)
return ( return (
<span <span
......
...@@ -25,12 +25,18 @@ export function ThemeSwitch() { ...@@ -25,12 +25,18 @@ export function ThemeSwitch() {
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
<Button variant='ghost' size='icon' className='h-9 w-9 rounded-full'> render={
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' /> <Button
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' /> variant='ghost'
<span className='sr-only'>{t('Toggle theme')}</span> size='icon'
</Button> 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> </DropdownMenuTrigger>
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}> <DropdownMenuItem onClick={() => setTheme('light')}>
......
'use client' import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'
import { ArrowDown01Icon, ArrowUp01Icon } from '@hugeicons/core-free-icons'
import * as React from 'react' import { HugeiconsIcon } from '@hugeicons/react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
function Accordion({ function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
...props return (
}: React.ComponentProps<typeof AccordionPrimitive.Root>) { <AccordionPrimitive.Root
return <AccordionPrimitive.Root data-slot='accordion' {...props} /> data-slot='accordion'
className={cn('flex w-full flex-col', className)}
{...props}
/>
)
} }
function AccordionItem({ function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return ( return (
<AccordionPrimitive.Item <AccordionPrimitive.Item
data-slot='accordion-item' data-slot='accordion-item'
className={cn('border-b last:border-b-0', className)} className={cn('not-last:border-b', className)}
{...props} {...props}
/> />
) )
...@@ -28,19 +27,30 @@ function AccordionTrigger({ ...@@ -28,19 +27,30 @@ function AccordionTrigger({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) { }: AccordionPrimitive.Trigger.Props) {
return ( return (
<AccordionPrimitive.Header className='flex'> <AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
data-slot='accordion-trigger' data-slot='accordion-trigger'
className={cn( 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 className
)} )}
{...props} {...props}
> >
{children} {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.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
) )
...@@ -50,15 +60,22 @@ function AccordionContent({ ...@@ -50,15 +60,22 @@ function AccordionContent({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) { }: AccordionPrimitive.Panel.Props) {
return ( return (
<AccordionPrimitive.Content <AccordionPrimitive.Panel
data-slot='accordion-content' 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} {...props}
> >
<div className={cn('pt-0 pb-4', className)}>{children}</div> <div
</AccordionPrimitive.Content> 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 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 { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button' import { Button } from '@/components/ui/button'
function AlertDialog({ function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} /> return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
} }
function AlertDialogTrigger({ function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} /> <AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
) )
} }
function AlertDialogPortal({ function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} /> <AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
) )
...@@ -28,12 +24,12 @@ function AlertDialogPortal({ ...@@ -28,12 +24,12 @@ function AlertDialogPortal({
function AlertDialogOverlay({ function AlertDialogOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { }: AlertDialogPrimitive.Backdrop.Props) {
return ( return (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Backdrop
data-slot='alert-dialog-overlay' data-slot='alert-dialog-overlay'
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -43,15 +39,19 @@ function AlertDialogOverlay({ ...@@ -43,15 +39,19 @@ function AlertDialogOverlay({
function AlertDialogContent({ function AlertDialogContent({
className, className,
size = 'default',
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) { }: AlertDialogPrimitive.Popup.Props & {
size?: 'default' | 'sm'
}) {
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Popup
data-slot='alert-dialog-content' data-slot='alert-dialog-content'
data-size={size}
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -67,7 +67,10 @@ function AlertDialogHeader({ ...@@ -67,7 +67,10 @@ function AlertDialogHeader({
return ( return (
<div <div
data-slot='alert-dialog-header' 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} {...props}
/> />
) )
...@@ -81,7 +84,23 @@ function AlertDialogFooter({ ...@@ -81,7 +84,23 @@ function AlertDialogFooter({
<div <div
data-slot='alert-dialog-footer' data-slot='alert-dialog-footer'
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -96,7 +115,10 @@ function AlertDialogTitle({ ...@@ -96,7 +115,10 @@ function AlertDialogTitle({
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot='alert-dialog-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} {...props}
/> />
) )
...@@ -109,7 +131,10 @@ function AlertDialogDescription({ ...@@ -109,7 +131,10 @@ function AlertDialogDescription({
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
data-slot='alert-dialog-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} {...props}
/> />
) )
...@@ -118,10 +143,11 @@ function AlertDialogDescription({ ...@@ -118,10 +143,11 @@ function AlertDialogDescription({
function AlertDialogAction({ function AlertDialogAction({
className, className,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) { }: React.ComponentProps<typeof Button>) {
return ( return (
<AlertDialogPrimitive.Action <Button
className={cn(buttonVariants(), className)} data-slot='alert-dialog-action'
className={cn(className)}
{...props} {...props}
/> />
) )
...@@ -129,11 +155,16 @@ function AlertDialogAction({ ...@@ -129,11 +155,16 @@ function AlertDialogAction({
function AlertDialogCancel({ function AlertDialogCancel({
className, className,
variant = 'outline',
size = 'default',
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return ( return (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Close
className={cn(buttonVariants({ variant: 'outline' }), className)} data-slot='alert-dialog-cancel'
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props} {...props}
/> />
) )
...@@ -141,14 +172,15 @@ function AlertDialogCancel({ ...@@ -141,14 +172,15 @@ function AlertDialogCancel({
export { export {
AlertDialog, AlertDialog,
AlertDialogPortal, AlertDialogAction,
AlertDialogOverlay, AlertDialogCancel,
AlertDialogTrigger,
AlertDialogContent, AlertDialogContent,
AlertDialogHeader, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle, AlertDialogTitle,
AlertDialogDescription, AlertDialogTrigger,
AlertDialogAction,
AlertDialogCancel,
} }
...@@ -3,13 +3,13 @@ import { cva, type VariantProps } from 'class-variance-authority' ...@@ -3,13 +3,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const alertVariants = cva( 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: { variants: {
variant: { variant: {
default: 'bg-card text-card-foreground', default: 'bg-card text-card-foreground',
destructive: 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: { defaultVariants: {
...@@ -38,7 +38,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { ...@@ -38,7 +38,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
<div <div
data-slot='alert-title' data-slot='alert-title'
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -54,7 +54,7 @@ function AlertDescription({ ...@@ -54,7 +54,7 @@ function AlertDescription({
<div <div
data-slot='alert-description' data-slot='alert-description'
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -62,4 +62,14 @@ function AlertDescription({ ...@@ -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 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' import { cn } from '@/lib/utils'
function Avatar({ function Avatar({
className, className,
size = 'default',
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
}) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot='avatar' data-slot='avatar'
data-size={size}
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -18,14 +22,14 @@ function Avatar({ ...@@ -18,14 +22,14 @@ function Avatar({
) )
} }
function AvatarImage({ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot='avatar-image' data-slot='avatar-image'
className={cn('aspect-square size-full', className)} className={cn(
'aspect-square size-full rounded-full object-cover',
className
)}
{...props} {...props}
/> />
) )
...@@ -34,12 +38,28 @@ function AvatarImage({ ...@@ -34,12 +38,28 @@ function AvatarImage({
function AvatarFallback({ function AvatarFallback({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { }: AvatarPrimitive.Fallback.Props) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot='avatar-fallback' data-slot='avatar-fallback'
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -47,4 +67,40 @@ function AvatarFallback({ ...@@ -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 { mergeProps } from '@base-ui/react/merge-props'
import { Slot } from '@radix-ui/react-slot' import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive: 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: 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: { defaultVariants: {
...@@ -26,20 +28,24 @@ const badgeVariants = cva( ...@@ -26,20 +28,24 @@ const badgeVariants = cva(
function Badge({ function Badge({
className, className,
variant, variant = 'default',
asChild = false, render,
...props ...props
}: React.ComponentProps<'span'> & }: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { return useRender({
const Comp = asChild ? Slot : 'span' defaultTagName: 'span',
props: mergeProps<'span'>(
return ( {
<Comp className: cn(badgeVariants({ variant }), className),
data-slot='badge' },
className={cn(badgeVariants({ variant }), className)} props
{...props} ),
/> render,
) state: {
slot: 'badge',
variant,
},
})
} }
export { Badge, badgeVariants } 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