mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): migrate launcher to modular web frontend/backend and improve management UX (#1275)
* refactor: remove the legacy picoclaw-launcher * feat: create initial web frontend and backend structure * feat(packaging): add desktop entry for PicoClaw Launcher (#1062) - Add .desktop file with Terminal=true, named "PicoClaw Launcher" - Install to /usr/share/applications/ for app menu visibility - Add 512x512 PNG icon to /usr/share/icons/hicolor/ Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * `make dev`: If you haven't built it before, you need to run `build` first. * feat(web): comprehensive web UI and backend refactoring This commit introduces a major overhaul of both the frontend web UI and the Go backend API, transitioning to a highly modular architecture and integrating new core features. Backend: - Refactored monolithic API endpoints into domain-specific modules (config, gateway, log, models, pico, session). - Cleaned up obsolete files (`server.go`, `status.go`, WebSocket handlers) and outdated tests. - Implemented Gateway process lifecycle management (start/stop/restart) and real-time log streaming. Frontend: - Integrated Shadcn UI components to establish a modern, consistent design system. - Introduced a new application layout featuring a responsive sidebar (`app-sidebar`) and header. - Implemented internationalization (i18n) with initial support for English and Chinese. - Restructured API clients, hooks, and Zustand stores into logical domains. - Added new management pages for Settings, Logs, Models, Providers, and Credentials. - Upgraded the Pico chat interface with session history management and dynamic model selection. Build & Config: - Updated frontend dependencies, Vite configuration, and lockfiles. - Refined routing setup and overarching application stylesheets. * feat(web): enhance model management, sorting, and deletion logic - Implement model sorting in UI (default > configured > unconfigured) - Prevent deletion of default models in the frontend - Update backend to clear default settings when a model is deleted - Add existence validation when setting a default model via API - Group models in chat UI by type (API Key, OAuth, Local) - Conditionally display model selector in chat based on configuration status * refactor(web): refactor chat page into modular components/hooks and update i18n - split chat route into dedicated chat components (page, composer, empty state, messages, history, model selector) - extract model/session logic into use-chat-models and use-session-history hooks - update chat locale keys in en/zh and add empty-state/history-related translations * refactor(models): refactor models page into modular components and improve UX - split /models route into dedicated components (page, provider section, card, add/edit sheets, delete dialog) - add provider grouping/sorting, provider labels/icons, and a no-default hint in the models page - add "Set as default model" toggle to add/edit flows with safer defaults - introduce shared form helpers and new UI primitives (field, label, switch) - update i18n strings (en/zh) for models and gateway header text usage - apply minor UI polish (models nav icon, separator client directive) * fix(web): add SPA index fallback for embedded frontend routes Serve existing static assets as-is, keep /api/* and missing asset paths returning 404, and add tests for SPA fallback behavior on refresh. * fix(frontend/chat): normalize message timestamp units to prevent invalid far-future dates * chore: delete TestSPARouteFallsBackToIndex * feat: update build for web-based launcher (#1186) - Makefile: add build-launcher target (builds frontend + Go backend) - GoReleaser: point picoclaw-launcher build to web/backend, add frontend build hook, restore winres hook with updated paths - Restore icon.ico and winres config from main for Windows builds Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(credentials): add multi-provider OAuth credential management - add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout - extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests - implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout - add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings * chore: remove placeholder index.html from dist (#1188) The .gitkeep is sufficient for go:embed to find the dist directory. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): polish model and credential UX; remove Providers nav - remove the Providers item from sidebar navigation and locale keys - simplify chat composer by dropping attach/voice action buttons - support ReactNode titles in credential cards and add provider brand icons - refine sheet header/footer styling and device-code footer button hierarchy - disable “Set default” when a model is unconfigured or already default * feat(web): Update config page (#1173) * feat(web): Update config page * fix(web): useEffect resets editorValue whenever config changes * fix(web): react-hooks/set-state-in-effect error & pnpm lint #1173 * feat(web): add channel management page for web console (#1190) * feat(web): add channel management page for web console Add a complete channel management UI that allows users to configure messaging channels (Telegram, Discord, Slack, Feishu, etc.) directly from the web console instead of manually editing config.json. Backend: GET/PUT/PATCH API endpoints for listing, updating, and toggling channels with secret field masking. Frontend: Channel cards grid with enable/disable toggles, per-channel configuration sheets with dedicated forms for major platforms and a generic fallback for others. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(web/channels): move channels to own sidebar group and fix sheet padding - Channels now has its own navigation group instead of being under Services - Fix edit sheet form content padding (px-1 -> px-4) to match header/footer - Fix naked return lint error in extractChannelInfo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(web): harden channel config updates and resolve frontend lint issues - validate channel PUT/PATCH updates before saving and return structured validation errors - require `enabled` in toggle requests to avoid silent false defaults - support editing `allow_origins` in the generic channel form and parse string/array inputs on backend - replace channel form `any` usage with `ChannelConfig` (`Record<string, unknown>`) and add safe value helpers - add i18n strings for allow-origins fields and apply related frontend formatting cleanups * fix(frontend): prevent false "Invalid JSON" errors in config editor * feat: add startup readiness checks and propagate start availability to UI - add gateway precondition validation for default model and credentials - auto-start gateway on backend boot when conditions are met - include gateway_start_allowed and gateway_start_reason in status updates - prevent frontend start actions when gateway cannot be started * feat(web): revamp channel config UX with catalog-based routing - replace legacy channel management endpoints with a backend channel catalog API - switch frontend channel updates to PATCH /api/config and per-channel config pages - add dynamic channel items in the sidebar with support for expand/collapse - migrate /channels to nested routes (/channels/$name) and remove old card/sheet flow - improve channel forms with clearer hints, required/error states, and reusable switch cards - fix Discord mention-only toggle to read/write group_trigger.mention_only * refactor(frontend): move shared-form to components and unify default-model switch with SwitchCardField * fix(frontend): improve model form validation and unify secret placeholder handling - block duplicate model aliases when adding a model (with localized error messages) - share masked secret placeholder logic across model and channel forms - refresh gateway state after setting the default model - apply minor UI cleanup to provider icon rendering * feat(web): add visual system config and launcher/autostart controls - add launcher config model and persistence (`launcher-config.json`) for port/public/CIDR settings - add system APIs for launch-at-login and launcher parameters - apply CIDR-based access-control middleware to backend HTTP routes - split config routing into visual config and raw JSON config pages - add frontend system API client and visual config sections for runtime/devices/launcher - expand i18n strings (en/zh) for new config UI - improve sidebar active matching and session ID generation fallback * refactor(frontend): remove i18n fallback strings and drop providers route - Replace `t(key, defaultValue)` calls with key-only translations across UI pages - Clean up locale files by pruning unused keys and adding missing shared keys - Remove the obsolete `/providers` page and update generated route tree * fix(backend): correct gateway status detection on Windows * fix(repo): keep web backend dist placeholder tracked --------- Co-authored-by: Guoguo <16666742+imguoguo@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Dihubopen <dihubcn@gmail.com> Co-authored-by: Dihubopen <130813726+Dihubopen@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
IconBook,
|
||||
IconLanguage,
|
||||
IconLoader2,
|
||||
IconMenu2,
|
||||
IconMoon,
|
||||
IconPlayerPlay,
|
||||
IconPower,
|
||||
IconSun,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog.tsx"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx"
|
||||
import { Separator } from "@/components/ui/separator.tsx"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||
import { useTheme } from "@/hooks/use-theme.ts"
|
||||
|
||||
export function AppHeader() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const {
|
||||
state: gwState,
|
||||
loading: gwLoading,
|
||||
canStart,
|
||||
start,
|
||||
stop,
|
||||
} = useGateway()
|
||||
|
||||
const isRunning = gwState === "running"
|
||||
const isStarting = gwState === "starting"
|
||||
const isStopped = gwState === "stopped" || gwState === "unknown"
|
||||
const showNotConnectedHint =
|
||||
canStart && (gwState === "stopped" || gwState === "error")
|
||||
|
||||
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
||||
|
||||
const handleGatewayToggle = () => {
|
||||
if (gwLoading || (!isRunning && !canStart)) return
|
||||
if (isRunning) {
|
||||
setShowStopDialog(true)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmStop = () => {
|
||||
setShowStopDialog(false)
|
||||
stop()
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-background/95 supports-backdrop-filter:bg-background/60 border-b-border/50 sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between border-b px-4 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-9 w-9 items-center justify-center rounded-lg sm:hidden [&>svg]:size-5">
|
||||
<IconMenu2 />
|
||||
</SidebarTrigger>
|
||||
<div className="hidden w-36 shrink-0 items-center sm:flex">
|
||||
<Link to="/">
|
||||
<img className="w-full" src="/logo_with_text.png" alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center prominent connection status */}
|
||||
<div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex">
|
||||
{showNotConnectedHint && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md">
|
||||
<span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full">
|
||||
<span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span>
|
||||
</span>
|
||||
{t("chat.notConnected")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showStopDialog} onOpenChange={setShowStopDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("header.gateway.stopDialog.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("header.gateway.stopDialog.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmStop}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("header.gateway.stopDialog.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
|
||||
{/* Gateway Start/Stop */}
|
||||
<Button
|
||||
variant={isStarting ? "secondary" : "default"}
|
||||
size="sm"
|
||||
className={`h-8 gap-2 px-3 ${
|
||||
isRunning
|
||||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
: isStopped
|
||||
? "bg-green-500 text-white hover:bg-green-600"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleGatewayToggle}
|
||||
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
|
||||
>
|
||||
{gwLoading || isStarting ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
||||
) : isRunning ? (
|
||||
<IconPower className="h-4 w-4 opacity-80" />
|
||||
) : (
|
||||
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
||||
)}
|
||||
<span className="text-xs font-semibold">
|
||||
{isRunning
|
||||
? t("header.gateway.action.stop")
|
||||
: isStarting
|
||||
? t("header.gateway.status.starting")
|
||||
: t("header.gateway.action.start")}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Separator
|
||||
className="mx-4 my-2 hidden md:block"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
{/* Docs Link */}
|
||||
<Button variant="ghost" size="icon" className="size-8" asChild>
|
||||
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
|
||||
<IconBook className="size-4.5" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<IconLanguage className="size-4.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||
English
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||
简体中文
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<IconSun className="size-4.5" />
|
||||
) : (
|
||||
<IconMoon className="size-4.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Toaster } from "sonner"
|
||||
|
||||
import { AppHeader } from "@/components/app-header"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
export function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="flex h-dvh flex-col overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<AppSidebar />
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<main className="flex min-h-0 w-full max-w-full flex-1 flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-center" />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { IconChevronRight } from "@tabler/icons-react"
|
||||
import {
|
||||
IconAtom,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconKey,
|
||||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
url: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
translateTitle?: boolean
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
defaultOpen: boolean
|
||||
items: NavItem[]
|
||||
isChannelsGroup?: boolean
|
||||
}
|
||||
|
||||
const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||
{
|
||||
label: "navigation.chat",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.model_group",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const routerState = useRouterState()
|
||||
const { t } = useTranslation()
|
||||
const currentPath = routerState.location.pathname
|
||||
const {
|
||||
channelItems,
|
||||
hasMoreChannels,
|
||||
showAllChannels,
|
||||
toggleShowAllChannels,
|
||||
} = useSidebarChannels({ t })
|
||||
|
||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
...baseNavGroups[0],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.chat",
|
||||
url: "/",
|
||||
icon: IconMessageCircle,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseNavGroups[1],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.models",
|
||||
url: "/models",
|
||||
icon: IconAtom,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.credentials",
|
||||
url: "/credentials",
|
||||
icon: IconKey,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "navigation.channels_group",
|
||||
defaultOpen: true,
|
||||
items: channelItems.map((item) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
icon: item.icon,
|
||||
translateTitle: false,
|
||||
})),
|
||||
isChannelsGroup: true,
|
||||
},
|
||||
{
|
||||
...baseNavGroups[2],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.config",
|
||||
url: "/config",
|
||||
icon: IconSettings,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.logs",
|
||||
url: "/logs",
|
||||
icon: IconListDetails,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}, [channelItems])
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...props}
|
||||
className="bg-background border-r-border/20 border-r pt-3"
|
||||
>
|
||||
<SidebarContent className="bg-background">
|
||||
{navGroups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.label}
|
||||
defaultOpen={group.defaultOpen}
|
||||
className="group/collapsible mb-1"
|
||||
>
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="hover:bg-muted/60 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 transition-colors">
|
||||
<span>{t(group.label)}</span>
|
||||
<IconChevronRight className="size-3.5 opacity-50 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent className="pt-1">
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
currentPath === item.url ||
|
||||
(item.url !== "/" &&
|
||||
currentPath.startsWith(`${item.url}/`))
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive}
|
||||
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon
|
||||
className={`size-4 ${isActive ? "opacity-100" : "opacity-60"}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "opacity-100" : "opacity-80"
|
||||
}
|
||||
>
|
||||
{item.translateTitle === false
|
||||
? item.title
|
||||
: t(item.title)}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
{group.isChannelsGroup && hasMoreChannels && (
|
||||
<SidebarMenuItem key="channels-more-toggle">
|
||||
<SidebarMenuButton
|
||||
onClick={toggleShowAllChannels}
|
||||
className="text-muted-foreground hover:bg-muted/60 h-9 px-3"
|
||||
>
|
||||
{showAllChannels ? (
|
||||
<IconChevronsUp className="size-4 opacity-60" />
|
||||
) : (
|
||||
<IconChevronsDown className="size-4 opacity-60" />
|
||||
)}
|
||||
<span className="opacity-80">
|
||||
{showAllChannels
|
||||
? t("navigation.show_less_channels")
|
||||
: t("navigation.show_more_channels")}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
type ChannelConfig,
|
||||
type SupportedChannel,
|
||||
getAppConfig,
|
||||
getChannelsCatalog,
|
||||
patchAppConfig,
|
||||
} from "@/api/channels"
|
||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
interface ChannelConfigPageProps {
|
||||
channelName: string
|
||||
}
|
||||
|
||||
const SECRET_FIELD_MAP: Record<string, string> = {
|
||||
token: "_token",
|
||||
app_secret: "_app_secret",
|
||||
client_secret: "_client_secret",
|
||||
corp_secret: "_corp_secret",
|
||||
channel_secret: "_channel_secret",
|
||||
channel_access_token: "_channel_access_token",
|
||||
access_token: "_access_token",
|
||||
bot_token: "_bot_token",
|
||||
app_token: "_app_token",
|
||||
encoding_aes_key: "_encoding_aes_key",
|
||||
encrypt_key: "_encrypt_key",
|
||||
verification_token: "_verification_token",
|
||||
password: "_password",
|
||||
nickserv_password: "_nickserv_password",
|
||||
sasl_password: "_sasl_password",
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||
const edit: ChannelConfig = { ...config }
|
||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
||||
if (secretKey in config) {
|
||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
||||
}
|
||||
}
|
||||
return edit
|
||||
}
|
||||
|
||||
function normalizeConfig(
|
||||
channel: SupportedChannel,
|
||||
rawConfig: ChannelConfig,
|
||||
): ChannelConfig {
|
||||
const config = { ...rawConfig }
|
||||
if (channel.name === "whatsapp_native") {
|
||||
config.use_native = true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
config.use_native = false
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function buildSavePayload(
|
||||
channel: SupportedChannel,
|
||||
editConfig: ChannelConfig,
|
||||
enabled: boolean,
|
||||
): ChannelConfig {
|
||||
const payload: ChannelConfig = { enabled }
|
||||
|
||||
for (const [key, value] of Object.entries(editConfig)) {
|
||||
if (key.startsWith("_")) continue
|
||||
if (key === "enabled") continue
|
||||
|
||||
if (key in SECRET_FIELD_MAP) {
|
||||
const editKey = SECRET_FIELD_MAP[key]
|
||||
const incoming = asString(editConfig[editKey])
|
||||
payload[key] = incoming !== "" ? incoming : value
|
||||
continue
|
||||
}
|
||||
|
||||
payload[key] = value
|
||||
}
|
||||
|
||||
if (channel.name === "whatsapp_native") {
|
||||
payload.use_native = true
|
||||
}
|
||||
if (channel.name === "whatsapp") {
|
||||
payload.use_native = false
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isConfigured(
|
||||
channel: SupportedChannel,
|
||||
config: ChannelConfig,
|
||||
): boolean {
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return asString(config.token) !== ""
|
||||
case "discord":
|
||||
return asString(config.token) !== ""
|
||||
case "slack":
|
||||
return asString(config.bot_token) !== ""
|
||||
case "feishu":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "dingtalk":
|
||||
return (
|
||||
asString(config.client_id) !== "" &&
|
||||
asString(config.client_secret) !== ""
|
||||
)
|
||||
case "line":
|
||||
return asString(config.channel_access_token) !== ""
|
||||
case "qq":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "onebot":
|
||||
return asString(config.ws_url) !== ""
|
||||
case "wecom":
|
||||
return asString(config.token) !== ""
|
||||
case "wecom_app":
|
||||
return (
|
||||
asString(config.corp_id) !== "" && asString(config.corp_secret) !== ""
|
||||
)
|
||||
case "wecom_aibot":
|
||||
return asString(config.token) !== ""
|
||||
case "whatsapp":
|
||||
return asString(config.bridge_url) !== ""
|
||||
case "whatsapp_native":
|
||||
return asBool(config.use_native)
|
||||
case "pico":
|
||||
return asString(config.token) !== ""
|
||||
case "maixcam":
|
||||
return asString(config.host) !== ""
|
||||
case "matrix":
|
||||
return (
|
||||
asString(config.homeserver) !== "" &&
|
||||
asString(config.user_id) !== "" &&
|
||||
asString(config.access_token) !== ""
|
||||
)
|
||||
case "irc":
|
||||
return asString(config.server) !== ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredFieldKeys(channelName: string): string[] {
|
||||
switch (channelName) {
|
||||
case "telegram":
|
||||
return ["token"]
|
||||
case "discord":
|
||||
return ["token"]
|
||||
case "slack":
|
||||
return ["bot_token"]
|
||||
case "feishu":
|
||||
return ["app_id", "app_secret"]
|
||||
case "dingtalk":
|
||||
return ["client_id", "client_secret"]
|
||||
case "line":
|
||||
return ["channel_secret", "channel_access_token"]
|
||||
case "qq":
|
||||
return ["app_id", "app_secret"]
|
||||
case "onebot":
|
||||
return ["ws_url"]
|
||||
case "wecom":
|
||||
return ["token"]
|
||||
case "wecom_app":
|
||||
return ["corp_id", "corp_secret"]
|
||||
case "wecom_aibot":
|
||||
return ["token"]
|
||||
case "whatsapp":
|
||||
return ["bridge_url"]
|
||||
case "pico":
|
||||
return ["token"]
|
||||
case "maixcam":
|
||||
return ["host"]
|
||||
case "matrix":
|
||||
return ["homeserver", "user_id", "access_token"]
|
||||
case "irc":
|
||||
return ["server"]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingRequiredValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim() === ""
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getChannelDocSlug(channelName: string): string {
|
||||
return channelName.replaceAll("_", "-")
|
||||
}
|
||||
|
||||
const CHANNELS_WITHOUT_DOCS = new Set([
|
||||
"pico",
|
||||
"wecom",
|
||||
"matrix",
|
||||
"irc",
|
||||
"whatsapp",
|
||||
"whatsapp_native",
|
||||
])
|
||||
|
||||
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const gateway = useAtomValue(gatewayAtom)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
const [serverError, setServerError] = useState("")
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
||||
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [catalog, appConfig] = await Promise.all([
|
||||
getChannelsCatalog(),
|
||||
getAppConfig(),
|
||||
])
|
||||
const matched =
|
||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||
|
||||
if (!matched) {
|
||||
setChannel(null)
|
||||
setFetchError(
|
||||
t("channels.page.notFound", {
|
||||
name: channelName,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
||||
const raw = asRecord(channelsConfig[matched.config_key])
|
||||
const normalized = normalizeConfig(matched, raw)
|
||||
|
||||
setChannel(matched)
|
||||
setBaseConfig(normalized)
|
||||
setEditConfig(buildEditConfig(normalized))
|
||||
setEnabled(asBool(normalized.enabled))
|
||||
setFetchError("")
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [channelName, t])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const previousGatewayStatusRef = useRef(gateway.status)
|
||||
useEffect(() => {
|
||||
const previousStatus = previousGatewayStatusRef.current
|
||||
if (previousStatus !== "running" && gateway.status === "running") {
|
||||
void loadData()
|
||||
}
|
||||
previousGatewayStatusRef.current = gateway.status
|
||||
}, [gateway.status, loadData])
|
||||
|
||||
const savePayload = useMemo(() => {
|
||||
if (!channel) return null
|
||||
return buildSavePayload(channel, editConfig, enabled)
|
||||
}, [channel, editConfig, enabled])
|
||||
|
||||
const configured = useMemo(() => {
|
||||
if (!channel || !savePayload) return false
|
||||
return isConfigured(channel, savePayload)
|
||||
}, [channel, savePayload])
|
||||
|
||||
const docsUrl = useMemo(() => {
|
||||
if (!channel) return ""
|
||||
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
|
||||
const language = (
|
||||
i18n.resolvedLanguage ??
|
||||
i18n.language ??
|
||||
""
|
||||
).toLowerCase()
|
||||
const base = language.startsWith("zh")
|
||||
? "https://docs.picoclaw.io/zh-Hans/docs/channels"
|
||||
: "https://docs.picoclaw.io/docs/channels"
|
||||
return `${base}/${getChannelDocSlug(channel.name)}`
|
||||
}, [channel, i18n.language, i18n.resolvedLanguage])
|
||||
|
||||
const channelDisplayName = useMemo(() => {
|
||||
if (!channel) return channelName
|
||||
return getChannelDisplayName(channel, t)
|
||||
}, [channel, channelName, t])
|
||||
|
||||
const hiddenKeys = useMemo(() => {
|
||||
if (!channel) return []
|
||||
if (channel.name === "whatsapp") {
|
||||
return ["use_native"]
|
||||
}
|
||||
if (channel.name === "whatsapp_native") {
|
||||
return ["use_native", "bridge_url"]
|
||||
}
|
||||
return []
|
||||
}, [channel])
|
||||
const requiredKeys = useMemo(
|
||||
() => getRequiredFieldKeys(channelName),
|
||||
[channelName],
|
||||
)
|
||||
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
const normalizedKey = key.startsWith("_") ? key.slice(1) : key
|
||||
setEditConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setFieldErrors((prev) => {
|
||||
if (!(key in prev) && !(normalizedKey in prev)) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
delete next[normalizedKey]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleReset = () => {
|
||||
setEditConfig(buildEditConfig(baseConfig))
|
||||
setEnabled(asBool(baseConfig.enabled))
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!channel || !savePayload) return
|
||||
|
||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||
isMissingRequiredValue(savePayload[key]),
|
||||
)
|
||||
if (missingRequiredFields.length > 0) {
|
||||
const requiredFieldError = t("channels.validation.requiredField")
|
||||
const nextFieldErrors: Record<string, string> = {}
|
||||
for (const key of missingRequiredFields) {
|
||||
nextFieldErrors[key] = requiredFieldError
|
||||
}
|
||||
setFieldErrors(nextFieldErrors)
|
||||
setServerError("")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
setFieldErrors({})
|
||||
try {
|
||||
await patchAppConfig({
|
||||
channels: {
|
||||
[channel.config_key]: savePayload,
|
||||
},
|
||||
})
|
||||
toast.success(t("channels.page.saveSuccess"))
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||
setServerError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
if (!channel) return null
|
||||
const isEdit = configured
|
||||
|
||||
switch (channel.name) {
|
||||
case "telegram":
|
||||
return (
|
||||
<TelegramForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "discord":
|
||||
return (
|
||||
<DiscordForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "slack":
|
||||
return (
|
||||
<SlackForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "feishu":
|
||||
return (
|
||||
<FeishuForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
hiddenKeys={hiddenKeys}
|
||||
requiredKeys={requiredKeys}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={channelDisplayName}
|
||||
titleExtra={
|
||||
channel ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{enabled ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("channels.page.enabled")}
|
||||
</span>
|
||||
) : configured ? (
|
||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("channels.status.configured")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 justify-center overflow-y-auto px-4 pb-8 sm:px-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<IconLoader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : fetchError ? (
|
||||
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
|
||||
{fetchError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-250 space-y-5 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<p className="font-medium">
|
||||
{t("channels.edit", {
|
||||
name: channelDisplayName,
|
||||
})}
|
||||
</p>
|
||||
{channel && docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("channels.page.docLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("channels.page.enableLabel")}
|
||||
</p>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
{renderForm()}
|
||||
|
||||
{serverError && (
|
||||
<p className="text-destructive text-sm">{serverError}</p>
|
||||
)}
|
||||
|
||||
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
|
||||
<Button variant="outline" onClick={handleReset} disabled={saving}>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TFunction } from "i18next"
|
||||
|
||||
import type { SupportedChannel } from "@/api/channels"
|
||||
|
||||
export function getChannelDisplayName(
|
||||
channel: Pick<SupportedChannel, "name" | "display_name">,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const key = `channels.name.${channel.name}`
|
||||
const translated = t(key)
|
||||
if (translated !== key) {
|
||||
return translated
|
||||
}
|
||||
|
||||
if (channel.display_name && channel.display_name.trim() !== "") {
|
||||
return channel.display_name
|
||||
}
|
||||
|
||||
return channel.name
|
||||
.split("_")
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface DiscordFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function DiscordForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: DiscordFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
const tokenExtraHint =
|
||||
isEdit && asString(config.token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.mentionOnly")}
|
||||
hint={t("channels.form.desc.mentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}}
|
||||
ariaLabel={t("channels.field.mentionOnly")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface FeishuFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function FeishuForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: FeishuFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const appSecretExtraHint =
|
||||
isEdit && asString(config.app_secret)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const verificationExtraHint =
|
||||
isEdit && asString(config.verification_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const encryptExtraHint =
|
||||
isEdit && asString(config.encrypt_key)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.appId")}
|
||||
required
|
||||
hint={t("channels.form.desc.appId")}
|
||||
error={fieldErrors.app_id}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
||||
error={fieldErrors.app_secret}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.app_secret,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.verificationToken")}
|
||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.verification_token,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.encryptKey")}
|
||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.encrypt_key,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
hiddenKeys?: string[]
|
||||
requiredKeys?: string[]
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
// Secret field names that should use masked input.
|
||||
const SECRET_FIELDS = new Set([
|
||||
"token",
|
||||
"app_secret",
|
||||
"client_secret",
|
||||
"corp_secret",
|
||||
"channel_secret",
|
||||
"channel_access_token",
|
||||
"access_token",
|
||||
"bot_token",
|
||||
"app_token",
|
||||
"encoding_aes_key",
|
||||
"encrypt_key",
|
||||
"verification_token",
|
||||
"password",
|
||||
"nickserv_password",
|
||||
"sasl_password",
|
||||
])
|
||||
|
||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
||||
|
||||
// Fields that are objects/nested — show as JSON or skip.
|
||||
const OBJECT_FIELDS = new Set([
|
||||
"group_trigger",
|
||||
"typing",
|
||||
"placeholder",
|
||||
"allow_token_query",
|
||||
"allow_from",
|
||||
"allow_origins",
|
||||
])
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
return key
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function formatSentenceFieldName(key: string): string {
|
||||
const label = formatLabel(key)
|
||||
return label.charAt(0).toLowerCase() + label.slice(1)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function GenericForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
hiddenKeys = [],
|
||||
requiredKeys = [],
|
||||
fieldErrors = {},
|
||||
}: GenericFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const hiddenFieldSet = new Set(hiddenKeys)
|
||||
const requiredFieldSet = new Set(requiredKeys)
|
||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||
const typingConfig = asRecord(config.typing)
|
||||
const placeholderConfig = asRecord(config.placeholder)
|
||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||
|
||||
const fields = Object.keys(config).filter(
|
||||
(k) =>
|
||||
!k.startsWith("_") &&
|
||||
!SKIP_FIELDS.has(k) &&
|
||||
!OBJECT_FIELDS.has(k) &&
|
||||
!hiddenFieldSet.has(k),
|
||||
)
|
||||
|
||||
const buildHint = (key: string): string => {
|
||||
const descriptions: Record<string, string> = {
|
||||
ws_url: t("channels.form.desc.wsUrl"),
|
||||
reconnect_interval: t("channels.form.desc.reconnectInterval"),
|
||||
bridge_url: t("channels.form.desc.bridgeUrl"),
|
||||
session_store_path: t("channels.form.desc.sessionStorePath"),
|
||||
use_native: t("channels.form.desc.useNative"),
|
||||
host: t("channels.form.desc.host"),
|
||||
port: t("channels.form.desc.port"),
|
||||
homeserver: t("channels.form.desc.homeserver"),
|
||||
user_id: t("channels.form.desc.userId"),
|
||||
device_id: t("channels.form.desc.deviceId"),
|
||||
join_on_invite: t("channels.form.desc.joinOnInvite"),
|
||||
app_id: t("channels.form.desc.appId"),
|
||||
client_id: t("channels.form.desc.clientId"),
|
||||
corp_id: t("channels.form.desc.corpId"),
|
||||
agent_id: t("channels.form.desc.agentId"),
|
||||
webhook_url: t("channels.form.desc.webhookUrl"),
|
||||
webhook_host: t("channels.form.desc.webhookHost"),
|
||||
webhook_port: t("channels.form.desc.webhookPort"),
|
||||
webhook_path: t("channels.form.desc.webhookPath"),
|
||||
reply_timeout: t("channels.form.desc.replyTimeout"),
|
||||
max_steps: t("channels.form.desc.maxSteps"),
|
||||
welcome_message: t("channels.form.desc.welcomeMessage"),
|
||||
allow_token_query: t("channels.form.desc.allowTokenQuery"),
|
||||
ping_interval: t("channels.form.desc.pingInterval"),
|
||||
read_timeout: t("channels.form.desc.readTimeout"),
|
||||
write_timeout: t("channels.form.desc.writeTimeout"),
|
||||
max_connections: t("channels.form.desc.maxConnections"),
|
||||
server: t("channels.form.desc.server"),
|
||||
tls: t("channels.form.desc.tls"),
|
||||
nick: t("channels.form.desc.nick"),
|
||||
user: t("channels.form.desc.user"),
|
||||
real_name: t("channels.form.desc.realName"),
|
||||
channels: t("channels.form.desc.channels"),
|
||||
request_caps: t("channels.form.desc.requestCaps"),
|
||||
}
|
||||
return (
|
||||
descriptions[key] ??
|
||||
t("channels.form.desc.genericField", {
|
||||
field: formatSentenceFieldName(key),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((key) => {
|
||||
const isRequired = requiredFieldSet.has(key)
|
||||
if (SECRET_FIELDS.has(key)) {
|
||||
const editKey = `_${key}`
|
||||
const extraHint =
|
||||
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={`${buildHint(key)}${extraHint}`}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config[editKey])}
|
||||
onChange={(v) => onChange(editKey, v)}
|
||||
placeholder={maskedSecretPlaceholder(config[key])}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const value = config[key]
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<SwitchCardField
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(key, checked)}
|
||||
ariaLabel={formatLabel(key)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(value).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
key,
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={formatLabel(key)}
|
||||
required={isRequired}
|
||||
hint={buildHint(key)}
|
||||
error={fieldErrors[key]}
|
||||
>
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => {
|
||||
// Attempt to preserve number types
|
||||
const v = e.target.value
|
||||
if (typeof config[key] === "number") {
|
||||
onChange(key, v === "" ? 0 : Number(v))
|
||||
} else {
|
||||
onChange(key, v)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Allow From field */}
|
||||
{config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_origins !== undefined &&
|
||||
!hiddenFieldSet.has("allow_origins") && (
|
||||
<Field
|
||||
label={t("channels.field.allowOrigins")}
|
||||
hint={t("channels.form.desc.allowOrigins")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_origins).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_origins",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{config.allow_token_query !== undefined &&
|
||||
!hiddenFieldSet.has("allow_token_query") && (
|
||||
<SwitchCardField
|
||||
label={formatLabel("allow_token_query")}
|
||||
hint={buildHint("allow_token_query")}
|
||||
checked={asBool(config.allow_token_query)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("allow_token_query", checked)
|
||||
}
|
||||
ariaLabel={formatLabel("allow_token_query")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.group_trigger !== undefined &&
|
||||
!hiddenFieldSet.has("group_trigger") && (
|
||||
<>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.groupTriggerMentionOnly")}
|
||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||
checked={asBool(groupTriggerConfig.mention_only)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
mention_only: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||
/>
|
||||
<Field
|
||||
label={t("channels.field.groupTriggerPrefixes")}
|
||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange("group_trigger", {
|
||||
...groupTriggerConfig,
|
||||
prefixes: e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.placeholder !== undefined &&
|
||||
!hiddenFieldSet.has("placeholder") && (
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface SlackFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function SlackForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: SlackFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const botTokenExtraHint =
|
||||
isEdit && asString(config.bot_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const appTokenExtraHint =
|
||||
isEdit && asString(config.app_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.botToken")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
|
||||
error={fieldErrors.bot_token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._bot_token)}
|
||||
onChange={(v) => onChange("_bot_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appToken")}
|
||||
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_token)}
|
||||
onChange={(v) => onChange("_app_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface TelegramFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
export function TelegramForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: TelegramFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const typingConfig = asRecord(config.typing)
|
||||
const placeholderConfig = asRecord(config.placeholder)
|
||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||
const tokenExtraHint =
|
||||
isEdit && asString(config.token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.token")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
||||
error={fieldErrors.token}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._token)}
|
||||
onChange={(v) => onChange("_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.token,
|
||||
t("channels.field.tokenPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.baseUrl")}
|
||||
hint={t("channels.form.desc.baseUrl")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.base_url)}
|
||||
onChange={(e) => onChange("base_url", e.target.value)}
|
||||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.proxy")}
|
||||
hint={t("channels.form.desc.proxy")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.proxy)}
|
||||
onChange={(e) => onChange("proxy", e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.typingEnabled")}
|
||||
hint={t("channels.form.desc.typingEnabled")}
|
||||
checked={asBool(typingConfig.enabled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("typing", { ...typingConfig, enabled: checked })
|
||||
}
|
||||
ariaLabel={t("channels.field.typingEnabled")}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("channels.field.placeholderEnabled")}
|
||||
hint={t("channels.form.desc.placeholderEnabled")}
|
||||
checked={placeholderEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
enabled: checked,
|
||||
})
|
||||
}
|
||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||
>
|
||||
{placeholderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={asString(placeholderConfig.text)}
|
||||
onChange={(e) =>
|
||||
onChange("placeholder", {
|
||||
...placeholderConfig,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.field.placeholderText")}
|
||||
aria-label={t("channels.field.placeholderText")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SwitchCardField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string
|
||||
timestamp?: string | number
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
content,
|
||||
timestamp = "",
|
||||
}: AssistantMessageProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex w-full flex-col gap-1.5">
|
||||
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>PicoClaw</span>
|
||||
{formattedTimestamp && (
|
||||
<>
|
||||
<span className="opacity-50">•</span>
|
||||
<span>{formattedTimestamp}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
|
||||
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<IconCopy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { IconArrowUp } from "@tabler/icons-react"
|
||||
import type { KeyboardEvent } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import TextareaAutosize from "react-textarea-autosize"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ChatComposerProps {
|
||||
input: string
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
isConnected: boolean
|
||||
hasDefaultModel: boolean
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
input,
|
||||
onInputChange,
|
||||
onSend,
|
||||
isConnected,
|
||||
hasDefaultModel,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation()
|
||||
const canInput = isConnected && hasDefaultModel
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
||||
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
|
||||
<TextareaAutosize
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("chat.placeholder")}
|
||||
disabled={!canInput}
|
||||
className={cn(
|
||||
"max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
||||
!canInput && "cursor-not-allowed",
|
||||
)}
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1">{/* action buttons */}</div>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
>
|
||||
<IconArrowUp className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
IconPlugConnectedX,
|
||||
IconRobot,
|
||||
IconRobotOff,
|
||||
IconStar,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
hasConfiguredModels: boolean
|
||||
defaultModelName: string
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function ChatEmptyState({
|
||||
hasConfiguredModels,
|
||||
defaultModelName,
|
||||
isConnected,
|
||||
}: ChatEmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!hasConfiguredModels) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||
<IconRobotOff className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("chat.empty.noConfiguredModel")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
{t("chat.empty.noConfiguredModelDescription")}
|
||||
</p>
|
||||
<Button asChild variant="secondary" size="sm" className="px-4">
|
||||
<Link to="/models">{t("chat.empty.goToModels")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!defaultModelName) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||
<IconStar className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("chat.empty.noSelectedModel")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
{t("chat.empty.noSelectedModelDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">
|
||||
<IconPlugConnectedX className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">
|
||||
{t("chat.empty.notRunning")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
{t("chat.empty.notRunningDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-70">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500">
|
||||
<IconRobot className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-medium">{t("chat.welcome")}</h3>
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
{t("chat.welcomeDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { IconPlus } from "@tabler/icons-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { AssistantMessage } from "@/components/chat/assistant-message"
|
||||
import { ChatComposer } from "@/components/chat/chat-composer"
|
||||
import { ChatEmptyState } from "@/components/chat/chat-empty-state"
|
||||
import { ModelSelector } from "@/components/chat/model-selector"
|
||||
import { SessionHistoryMenu } from "@/components/chat/session-history-menu"
|
||||
import { TypingIndicator } from "@/components/chat/typing-indicator"
|
||||
import { UserMessage } from "@/components/chat/user-message"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useChatModels } from "@/hooks/use-chat-models"
|
||||
import { useGateway } from "@/hooks/use-gateway"
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||
|
||||
export function ChatPage() {
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
const {
|
||||
messages,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
} = usePicoChat()
|
||||
|
||||
const { state: gwState } = useGateway()
|
||||
const isConnected = gwState === "running"
|
||||
|
||||
const {
|
||||
defaultModelName,
|
||||
hasConfiguredModels,
|
||||
apiKeyModels,
|
||||
oauthModels,
|
||||
localModels,
|
||||
handleSetDefault,
|
||||
} = useChatModels({ isConnected })
|
||||
|
||||
const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } =
|
||||
useSessionHistory({
|
||||
activeSessionId,
|
||||
onDeletedActiveSession: newChat,
|
||||
})
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAtBottom && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, isTyping, isAtBottom])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || !isConnected) return
|
||||
sendMessage(input.trim())
|
||||
setInput("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background/95 flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.chat")}
|
||||
titleExtra={
|
||||
hasConfiguredModels && (
|
||||
<ModelSelector
|
||||
defaultModelName={defaultModelName}
|
||||
apiKeyModels={apiKeyModels}
|
||||
oauthModels={oauthModels}
|
||||
localModels={localModels}
|
||||
onValueChange={handleSetDefault}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={newChat}
|
||||
className="h-9 gap-2"
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.newChat")}</span>
|
||||
</Button>
|
||||
|
||||
<SessionHistoryMenu
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
hasMore={hasMore}
|
||||
observerRef={observerRef}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void loadSessions(true)
|
||||
}
|
||||
}}
|
||||
onSwitchSession={switchSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
||||
{messages.length === 0 && !isTyping && (
|
||||
<ChatEmptyState
|
||||
hasConfiguredModels={hasConfiguredModels}
|
||||
defaultModelName={defaultModelName}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="flex w-full">
|
||||
{msg.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage content={msg.content} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && <TypingIndicator />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatComposer
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
isConnected={isConnected}
|
||||
hasDefaultModel={Boolean(defaultModelName)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ModelInfo } from "@/api/models"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
defaultModelName: string
|
||||
apiKeyModels: ModelInfo[]
|
||||
oauthModels: ModelInfo[]
|
||||
localModels: ModelInfo[]
|
||||
onValueChange: (modelName: string) => void
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
defaultModelName,
|
||||
apiKeyModels,
|
||||
oauthModels,
|
||||
localModels,
|
||||
onValueChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Select value={defaultModelName} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:border-input h-8 max-w-[160px] min-w-[80px] bg-transparent shadow-none focus-visible:ring-0 sm:max-w-[220px]"
|
||||
>
|
||||
<SelectValue placeholder={t("chat.noModel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{apiKeyModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>
|
||||
{apiKeyModels.map((model) => (
|
||||
<SelectItem key={model.index} value={model.model_name}>
|
||||
{model.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{apiKeyModels.length > 0 &&
|
||||
(oauthModels.length > 0 || localModels.length > 0) && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
|
||||
{oauthModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.oauth")}</SelectLabel>
|
||||
{oauthModels.map((model) => (
|
||||
<SelectItem key={model.index} value={model.model_name}>
|
||||
{model.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{oauthModels.length > 0 &&
|
||||
(localModels.length > 0 || apiKeyModels.length > 0) && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
|
||||
{localModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("chat.modelGroup.local")}</SelectLabel>
|
||||
{localModels.map((model) => (
|
||||
<SelectItem key={model.index} value={model.model_name}>
|
||||
{model.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { IconHistory, IconTrash } from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import type { RefObject } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { SessionSummary } from "@/api/sessions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
interface SessionHistoryMenuProps {
|
||||
sessions: SessionSummary[]
|
||||
activeSessionId: string
|
||||
hasMore: boolean
|
||||
observerRef: RefObject<HTMLDivElement | null>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSwitchSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
}
|
||||
|
||||
export function SessionHistoryMenu({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
hasMore,
|
||||
observerRef,
|
||||
onOpenChange,
|
||||
onSwitchSession,
|
||||
onDeleteSession,
|
||||
}: SessionHistoryMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 gap-2">
|
||||
<IconHistory className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.history")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
{sessions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.noHistory")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${
|
||||
session.id === activeSessionId ? "bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => onSwitchSession(session.id)}
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-medium">
|
||||
{session.preview}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.messagesCount", {
|
||||
count: session.message_count,
|
||||
})}{" "}
|
||||
· {dayjs(session.updated).fromNow()}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("chat.deleteSession")}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDeleteSession(session.id)
|
||||
}}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
{hasMore && sessions.length > 0 && (
|
||||
<div ref={observerRef} className="py-2 text-center">
|
||||
<span className="text-muted-foreground animate-pulse text-xs">
|
||||
{t("chat.loadingMore")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export function TypingIndicator() {
|
||||
const { t } = useTranslation()
|
||||
const thinkingSteps = [
|
||||
t("chat.thinking.step1"),
|
||||
t("chat.thinking.step2"),
|
||||
t("chat.thinking.step3"),
|
||||
t("chat.thinking.step4"),
|
||||
]
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const stepsCount = thinkingSteps.length
|
||||
const interval = setInterval(() => {
|
||||
setStepIndex((prev) => (prev + 1) % stepsCount)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [thinkingSteps.length])
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70">
|
||||
<span>PicoClaw</span>
|
||||
</div>
|
||||
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-violet-400/70" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted relative h-1 w-36 overflow-hidden rounded-full">
|
||||
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
key={stepIndex}
|
||||
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs"
|
||||
>
|
||||
{thinkingSteps[stepIndex]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface UserMessageProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-end gap-1.5">
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { IconCode, IconDeviceFloppy } from "@tabler/icons-react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { patchAppConfig } from "@/api/channels"
|
||||
import {
|
||||
getAutoStartStatus,
|
||||
getLauncherConfig,
|
||||
setAutoStartEnabled as updateAutoStartEnabled,
|
||||
setLauncherConfig as updateLauncherConfig,
|
||||
} from "@/api/system"
|
||||
import {
|
||||
AdvancedSection,
|
||||
AgentDefaultsSection,
|
||||
DevicesSection,
|
||||
LauncherSection,
|
||||
RuntimeSection,
|
||||
} from "@/components/config/config-sections"
|
||||
import {
|
||||
type CoreConfigForm,
|
||||
EMPTY_FORM,
|
||||
EMPTY_LAUNCHER_FORM,
|
||||
type LauncherForm,
|
||||
buildFormFromConfig,
|
||||
parseCIDRText,
|
||||
parseIntField,
|
||||
} from "@/components/config/form-model"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState<CoreConfigForm>(EMPTY_FORM)
|
||||
const [baseline, setBaseline] = useState<CoreConfigForm>(EMPTY_FORM)
|
||||
const [launcherForm, setLauncherForm] =
|
||||
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
|
||||
const [launcherBaseline, setLauncherBaseline] =
|
||||
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
|
||||
const [autoStartEnabled, setAutoStartEnabled] = useState(false)
|
||||
const [autoStartBaseline, setAutoStartBaseline] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load config")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: launcherConfig,
|
||||
isLoading: isLauncherLoading,
|
||||
error: launcherError,
|
||||
} = useQuery({
|
||||
queryKey: ["system", "launcher-config"],
|
||||
queryFn: getLauncherConfig,
|
||||
})
|
||||
|
||||
const {
|
||||
data: autoStartStatus,
|
||||
isLoading: isAutoStartLoading,
|
||||
error: autoStartError,
|
||||
} = useQuery({
|
||||
queryKey: ["system", "autostart"],
|
||||
queryFn: getAutoStartStatus,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
const parsed = buildFormFromConfig(data)
|
||||
setForm(parsed)
|
||||
setBaseline(parsed)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!launcherConfig) return
|
||||
const parsed: LauncherForm = {
|
||||
port: String(launcherConfig.port),
|
||||
publicAccess: launcherConfig.public,
|
||||
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||
}
|
||||
setLauncherForm(parsed)
|
||||
setLauncherBaseline(parsed)
|
||||
}, [launcherConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoStartStatus) return
|
||||
setAutoStartEnabled(autoStartStatus.enabled)
|
||||
setAutoStartBaseline(autoStartStatus.enabled)
|
||||
}, [autoStartStatus])
|
||||
|
||||
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
|
||||
const launcherDirty =
|
||||
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
|
||||
const autoStartDirty = autoStartEnabled !== autoStartBaseline
|
||||
const isDirty = configDirty || launcherDirty || autoStartDirty
|
||||
|
||||
const autoStartSupported = autoStartStatus?.supported !== false
|
||||
const autoStartHint = autoStartError
|
||||
? t("pages.config.autostart_load_error")
|
||||
: !autoStartSupported
|
||||
? t("pages.config.autostart_unsupported")
|
||||
: t("pages.config.autostart_hint")
|
||||
|
||||
const launcherHint = launcherError
|
||||
? t("pages.config.launcher_load_error")
|
||||
: t("pages.config.launcher_restart_hint")
|
||||
|
||||
const updateField = <K extends keyof CoreConfigForm>(
|
||||
key: K,
|
||||
value: CoreConfigForm[K],
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const updateLauncherField = <K extends keyof LauncherForm>(
|
||||
key: K,
|
||||
value: LauncherForm[K],
|
||||
) => {
|
||||
setLauncherForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setForm(baseline)
|
||||
setLauncherForm(launcherBaseline)
|
||||
setAutoStartEnabled(autoStartBaseline)
|
||||
toast.info(t("pages.config.reset_success"))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
|
||||
if (configDirty) {
|
||||
const workspace = form.workspace.trim()
|
||||
const dmScope = form.dmScope.trim()
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace path is required.")
|
||||
}
|
||||
if (!dmScope) {
|
||||
throw new Error("Session scope is required.")
|
||||
}
|
||||
|
||||
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
|
||||
min: 1,
|
||||
})
|
||||
const maxToolIterations = parseIntField(
|
||||
form.maxToolIterations,
|
||||
"Max tool iterations",
|
||||
{ min: 1 },
|
||||
)
|
||||
const summarizeMessageThreshold = parseIntField(
|
||||
form.summarizeMessageThreshold,
|
||||
"Summarize message threshold",
|
||||
{ min: 1 },
|
||||
)
|
||||
const summarizeTokenPercent = parseIntField(
|
||||
form.summarizeTokenPercent,
|
||||
"Summarize token percent",
|
||||
{ min: 1, max: 100 },
|
||||
)
|
||||
const heartbeatInterval = parseIntField(
|
||||
form.heartbeatInterval,
|
||||
"Heartbeat interval",
|
||||
{ min: 1 },
|
||||
)
|
||||
|
||||
await patchAppConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace,
|
||||
restrict_to_workspace: form.restrictToWorkspace,
|
||||
max_tokens: maxTokens,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
summarize_message_threshold: summarizeMessageThreshold,
|
||||
summarize_token_percent: summarizeTokenPercent,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
dm_scope: dmScope,
|
||||
},
|
||||
heartbeat: {
|
||||
enabled: form.heartbeatEnabled,
|
||||
interval: heartbeatInterval,
|
||||
},
|
||||
devices: {
|
||||
enabled: form.devicesEnabled,
|
||||
monitor_usb: form.monitorUSB,
|
||||
},
|
||||
})
|
||||
|
||||
setBaseline(form)
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
|
||||
if (launcherDirty) {
|
||||
const port = parseIntField(launcherForm.port, "Service port", {
|
||||
min: 1,
|
||||
max: 65535,
|
||||
})
|
||||
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
|
||||
const savedLauncherConfig = await updateLauncherConfig({
|
||||
port,
|
||||
public: launcherForm.publicAccess,
|
||||
allowed_cidrs: allowedCIDRs,
|
||||
})
|
||||
const parsedLauncher: LauncherForm = {
|
||||
port: String(savedLauncherConfig.port),
|
||||
publicAccess: savedLauncherConfig.public,
|
||||
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||
"\n",
|
||||
),
|
||||
}
|
||||
setLauncherForm(parsedLauncher)
|
||||
setLauncherBaseline(parsedLauncher)
|
||||
queryClient.setQueryData(
|
||||
["system", "launcher-config"],
|
||||
savedLauncherConfig,
|
||||
)
|
||||
}
|
||||
|
||||
if (autoStartDirty) {
|
||||
if (!autoStartSupported) {
|
||||
throw new Error(t("pages.config.autostart_unsupported"))
|
||||
}
|
||||
const status = await updateAutoStartEnabled(autoStartEnabled)
|
||||
setAutoStartEnabled(status.enabled)
|
||||
setAutoStartBaseline(status.enabled)
|
||||
queryClient.setQueryData(["system", "autostart"], status)
|
||||
}
|
||||
|
||||
toast.success(t("pages.config.save_success"))
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("pages.config.save_error"),
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.config")}
|
||||
children={
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/config/raw">
|
||||
<IconCode className="size-4" />
|
||||
{t("pages.config.open_raw")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
||||
<div className="mx-auto w-full max-w-[1000px] space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-6 text-sm">
|
||||
{t("labels.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{t("pages.config.load_error")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{isDirty && (
|
||||
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<LauncherSection
|
||||
launcherForm={launcherForm}
|
||||
onFieldChange={updateLauncherField}
|
||||
launcherHint={launcherHint}
|
||||
disabled={saving || isLauncherLoading}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DevicesSection
|
||||
form={form}
|
||||
onFieldChange={updateField}
|
||||
autoStartEnabled={autoStartEnabled}
|
||||
autoStartHint={autoStartHint}
|
||||
autoStartDisabled={
|
||||
isAutoStartLoading ||
|
||||
Boolean(autoStartError) ||
|
||||
!autoStartSupported ||
|
||||
saving
|
||||
}
|
||||
onAutoStartChange={setAutoStartEnabled}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<AdvancedSection />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || saving}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||
<IconDeviceFloppy className="size-4" />
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { IconCode } from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
type CoreConfigForm,
|
||||
DM_SCOPE_OPTIONS,
|
||||
type LauncherForm,
|
||||
} from "@/components/config/form-model"
|
||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
type UpdateCoreField = <K extends keyof CoreConfigForm>(
|
||||
key: K,
|
||||
value: CoreConfigForm[K],
|
||||
) => void
|
||||
|
||||
type UpdateLauncherField = <K extends keyof LauncherForm>(
|
||||
key: K,
|
||||
value: LauncherForm[K],
|
||||
) => void
|
||||
|
||||
interface AgentDefaultsSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
}
|
||||
|
||||
export function AgentDefaultsSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
}: AgentDefaultsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.workspace")}
|
||||
hint={t("pages.config.workspace_hint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||
placeholder="~/.picoclaw/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.restrict_workspace")}
|
||||
hint={t("pages.config.restrict_workspace_hint")}
|
||||
checked={form.restrictToWorkspace}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("restrictToWorkspace", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tokens")}
|
||||
hint={t("pages.config.max_tokens_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tool_iterations")}
|
||||
hint={t("pages.config.max_tool_iterations_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxToolIterations}
|
||||
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_threshold")}
|
||||
hint={t("pages.config.summarize_threshold_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.summarizeMessageThreshold}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_token_percent")}
|
||||
hint={t("pages.config.summarize_token_percent_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.summarizeTokenPercent}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface RuntimeSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
}
|
||||
|
||||
export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const selectedDmScopeOption = DM_SCOPE_OPTIONS.find(
|
||||
(scope) => scope.value === form.dmScope,
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.session_scope")}
|
||||
hint={t("pages.config.session_scope_hint")}
|
||||
>
|
||||
<Select
|
||||
value={form.dmScope}
|
||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{selectedDmScopeOption
|
||||
? t(
|
||||
selectedDmScopeOption.labelKey,
|
||||
selectedDmScopeOption.labelDefault,
|
||||
)
|
||||
: form.dmScope}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||
<SelectItem key={scope.value} value={scope.value}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{t(scope.labelKey)}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(scope.descKey)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.heartbeat_enabled")}
|
||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||
checked={form.heartbeatEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("heartbeatEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{form.heartbeatEnabled && (
|
||||
<Field
|
||||
label={t("pages.config.heartbeat_interval")}
|
||||
hint={t("pages.config.heartbeat_interval_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.heartbeatInterval}
|
||||
onChange={(e) =>
|
||||
onFieldChange("heartbeatInterval", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface LauncherSectionProps {
|
||||
launcherForm: LauncherForm
|
||||
onFieldChange: UpdateLauncherField
|
||||
launcherHint: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function LauncherSection({
|
||||
launcherForm,
|
||||
onFieldChange,
|
||||
launcherHint,
|
||||
disabled,
|
||||
}: LauncherSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.server_port")}
|
||||
hint={t("pages.config.server_port_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={launcherForm.port}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
checked={launcherForm.publicAccess}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.allowed_cidrs")}
|
||||
hint={t("pages.config.allowed_cidrs_hint")}
|
||||
>
|
||||
<Textarea
|
||||
value={launcherForm.allowedCIDRsText}
|
||||
disabled={disabled}
|
||||
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
||||
className="min-h-[88px]"
|
||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface DevicesSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
autoStartEnabled: boolean
|
||||
autoStartHint: string
|
||||
autoStartDisabled: boolean
|
||||
onAutoStartChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function DevicesSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
autoStartEnabled,
|
||||
autoStartHint,
|
||||
autoStartDisabled,
|
||||
onAutoStartChange,
|
||||
}: DevicesSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<SwitchCardField
|
||||
label={t("pages.config.devices_enabled")}
|
||||
hint={t("pages.config.devices_enabled_hint")}
|
||||
checked={form.devicesEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("devicesEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.monitor_usb")}
|
||||
hint={t("pages.config.monitor_usb_hint")}
|
||||
checked={form.monitorUSB}
|
||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.autostart_label")}
|
||||
hint={autoStartHint}
|
||||
checked={autoStartEnabled}
|
||||
disabled={autoStartDisabled}
|
||||
onCheckedChange={onAutoStartChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdvancedSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("pages.config.advanced_desc")}
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/config/raw">
|
||||
<IconCode className="size-4" />
|
||||
{t("pages.config.open_raw")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
export type JsonRecord = Record<string, unknown>
|
||||
|
||||
export interface CoreConfigForm {
|
||||
workspace: string
|
||||
restrictToWorkspace: boolean
|
||||
maxTokens: string
|
||||
maxToolIterations: string
|
||||
summarizeMessageThreshold: string
|
||||
summarizeTokenPercent: string
|
||||
dmScope: string
|
||||
heartbeatEnabled: boolean
|
||||
heartbeatInterval: string
|
||||
devicesEnabled: boolean
|
||||
monitorUSB: boolean
|
||||
}
|
||||
|
||||
export interface LauncherForm {
|
||||
port: string
|
||||
publicAccess: boolean
|
||||
allowedCIDRsText: string
|
||||
}
|
||||
|
||||
export const DM_SCOPE_OPTIONS = [
|
||||
{
|
||||
value: "per-channel-peer",
|
||||
labelKey: "pages.config.session_scope_per_channel_peer",
|
||||
labelDefault: "Per Channel + Peer",
|
||||
descKey: "pages.config.session_scope_per_channel_peer_desc",
|
||||
descDefault: "Separate context for each user in each channel.",
|
||||
},
|
||||
{
|
||||
value: "per-channel",
|
||||
labelKey: "pages.config.session_scope_per_channel",
|
||||
labelDefault: "Per Channel",
|
||||
descKey: "pages.config.session_scope_per_channel_desc",
|
||||
descDefault: "One shared context per channel.",
|
||||
},
|
||||
{
|
||||
value: "per-peer",
|
||||
labelKey: "pages.config.session_scope_per_peer",
|
||||
labelDefault: "Per Peer",
|
||||
descKey: "pages.config.session_scope_per_peer_desc",
|
||||
descDefault: "One context per user across channels.",
|
||||
},
|
||||
{
|
||||
value: "global",
|
||||
labelKey: "pages.config.session_scope_global",
|
||||
labelDefault: "Global",
|
||||
descKey: "pages.config.session_scope_global_desc",
|
||||
descDefault: "All messages share one global context.",
|
||||
},
|
||||
] as const
|
||||
|
||||
export const EMPTY_FORM: CoreConfigForm = {
|
||||
workspace: "",
|
||||
restrictToWorkspace: true,
|
||||
maxTokens: "32768",
|
||||
maxToolIterations: "50",
|
||||
summarizeMessageThreshold: "20",
|
||||
summarizeTokenPercent: "75",
|
||||
dmScope: "per-channel-peer",
|
||||
heartbeatEnabled: true,
|
||||
heartbeatInterval: "30",
|
||||
devicesEnabled: false,
|
||||
monitorUSB: true,
|
||||
}
|
||||
|
||||
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||
port: "18800",
|
||||
publicAccess: false,
|
||||
allowedCIDRsText: "",
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as JsonRecord
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean {
|
||||
return value === true
|
||||
}
|
||||
|
||||
function asNumberString(value: unknown, fallback: string): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||
const root = asRecord(config)
|
||||
const agents = asRecord(root.agents)
|
||||
const defaults = asRecord(agents.defaults)
|
||||
const session = asRecord(root.session)
|
||||
const heartbeat = asRecord(root.heartbeat)
|
||||
const devices = asRecord(root.devices)
|
||||
|
||||
return {
|
||||
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
||||
restrictToWorkspace:
|
||||
defaults.restrict_to_workspace === undefined
|
||||
? EMPTY_FORM.restrictToWorkspace
|
||||
: asBool(defaults.restrict_to_workspace),
|
||||
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
||||
maxToolIterations: asNumberString(
|
||||
defaults.max_tool_iterations,
|
||||
EMPTY_FORM.maxToolIterations,
|
||||
),
|
||||
summarizeMessageThreshold: asNumberString(
|
||||
defaults.summarize_message_threshold,
|
||||
EMPTY_FORM.summarizeMessageThreshold,
|
||||
),
|
||||
summarizeTokenPercent: asNumberString(
|
||||
defaults.summarize_token_percent,
|
||||
EMPTY_FORM.summarizeTokenPercent,
|
||||
),
|
||||
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
|
||||
heartbeatEnabled:
|
||||
heartbeat.enabled === undefined
|
||||
? EMPTY_FORM.heartbeatEnabled
|
||||
: asBool(heartbeat.enabled),
|
||||
heartbeatInterval: asNumberString(
|
||||
heartbeat.interval,
|
||||
EMPTY_FORM.heartbeatInterval,
|
||||
),
|
||||
devicesEnabled:
|
||||
devices.enabled === undefined
|
||||
? EMPTY_FORM.devicesEnabled
|
||||
: asBool(devices.enabled),
|
||||
monitorUSB:
|
||||
devices.monitor_usb === undefined
|
||||
? EMPTY_FORM.monitorUSB
|
||||
: asBool(devices.monitor_usb),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseIntField(
|
||||
rawValue: string,
|
||||
label: string,
|
||||
options: { min?: number; max?: number } = {},
|
||||
): number {
|
||||
const value = Number(rawValue)
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error(`${label} must be an integer.`)
|
||||
}
|
||||
if (options.min !== undefined && value < options.min) {
|
||||
throw new Error(`${label} must be >= ${options.min}.`)
|
||||
}
|
||||
if (options.max !== undefined && value > options.max) {
|
||||
throw new Error(`${label} must be <= ${options.max}.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseCIDRText(raw: string): string[] {
|
||||
if (!raw.trim()) {
|
||||
return []
|
||||
}
|
||||
return raw
|
||||
.split(/[\n,]/)
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function RawJsonPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch config")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (newConfig: string) => {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: newConfig,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save config")
|
||||
}
|
||||
},
|
||||
onSuccess: (_, submittedConfig) => {
|
||||
toast.success(t("pages.config.save_success"))
|
||||
try {
|
||||
const savedConfig = JSON.parse(submittedConfig)
|
||||
setLastSavedConfig(savedConfig)
|
||||
setIsDirty(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
} catch {
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("pages.config.save_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const [editorValue, setEditorValue] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null)
|
||||
|
||||
const effectiveEditorValue =
|
||||
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
JSON.parse(effectiveEditorValue)
|
||||
mutation.mutate(effectiveEditorValue)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.invalid_json",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(effectiveEditorValue),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
setEditorValue(formatted)
|
||||
toast.success(t("pages.config.format_success"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.format_error",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||
|
||||
const confirmReset = () => {
|
||||
if (lastSavedConfig) {
|
||||
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
||||
} else if (config) {
|
||||
setEditorValue(JSON.stringify(config, null, 2))
|
||||
}
|
||||
setIsDirty(false)
|
||||
toast.info(t("pages.config.reset_success"))
|
||||
setShowResetDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
|
||||
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p>{t("labels.loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isDirty && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted/30 relative rounded-lg border">
|
||||
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
||||
<Textarea
|
||||
value={effectiveEditorValue}
|
||||
onChange={(e) => {
|
||||
setEditorValue(e.target.value)
|
||||
setIsDirty(true)
|
||||
}}
|
||||
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
|
||||
placeholder={t("pages.config.json_placeholder")}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormat}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{t("pages.config.format")}
|
||||
</Button>
|
||||
<AlertDialog
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isDirty}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("pages.config.reset_confirm_title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("pages.config.reset_confirm_desc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>
|
||||
{t("common.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
IconKey,
|
||||
IconLoader2,
|
||||
IconPlayerStopFilled,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
import { CredentialCard } from "./credential-card"
|
||||
|
||||
interface AnthropicCredentialCardProps {
|
||||
status?: OAuthProviderStatus
|
||||
activeAction: string
|
||||
token: string
|
||||
onTokenChange: (value: string) => void
|
||||
onStopLoading: () => void
|
||||
onSaveToken: () => void
|
||||
onAskLogout: () => void
|
||||
}
|
||||
|
||||
export function AnthropicCredentialCard({
|
||||
status,
|
||||
activeAction,
|
||||
token,
|
||||
onTokenChange,
|
||||
onStopLoading,
|
||||
onSaveToken,
|
||||
onAskLogout,
|
||||
}: AnthropicCredentialCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const actionBusy = activeAction !== ""
|
||||
const tokenLoading = activeAction === "anthropic:token"
|
||||
const stopLabel = t("credentials.actions.stopLoading")
|
||||
|
||||
return (
|
||||
<CredentialCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||
<IconSparkles className="size-3.5" />
|
||||
</span>
|
||||
<span>Anthropic</span>
|
||||
</span>
|
||||
}
|
||||
description={t("credentials.providers.anthropic.description")}
|
||||
status={status?.status ?? "not_logged_in"}
|
||||
authMethod={status?.auth_method}
|
||||
actions={
|
||||
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<Input
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
type="password"
|
||||
placeholder={t("credentials.fields.anthropicToken")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={actionBusy || !token.trim()}
|
||||
onClick={onSaveToken}
|
||||
>
|
||||
{tokenLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconKey className="size-4" />
|
||||
{t("credentials.actions.saveToken")}
|
||||
</Button>
|
||||
{tokenLoading && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={onStopLoading}
|
||||
aria-label={stopLabel}
|
||||
title={stopLabel}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<IconPlayerStopFilled className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
status?.logged_in ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={onAskLogout}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{activeAction === "anthropic:logout" && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t("credentials.actions.logout")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
IconBrandGoogle,
|
||||
IconLoader2,
|
||||
IconLockOpen,
|
||||
IconPlayerStopFilled,
|
||||
} from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
import { CredentialCard } from "./credential-card"
|
||||
|
||||
interface AntigravityCredentialCardProps {
|
||||
status?: OAuthProviderStatus
|
||||
activeAction: string
|
||||
onStopLoading: () => void
|
||||
onStartBrowserOAuth: () => void
|
||||
onAskLogout: () => void
|
||||
}
|
||||
|
||||
export function AntigravityCredentialCard({
|
||||
status,
|
||||
activeAction,
|
||||
onStopLoading,
|
||||
onStartBrowserOAuth,
|
||||
onAskLogout,
|
||||
}: AntigravityCredentialCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const actionBusy = activeAction !== ""
|
||||
const browserLoading = activeAction === "google-antigravity:browser"
|
||||
|
||||
return (
|
||||
<CredentialCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||
<IconBrandGoogle className="size-3.5" />
|
||||
</span>
|
||||
<span>Google Antigravity</span>
|
||||
</span>
|
||||
}
|
||||
description={t("credentials.providers.antigravity.description")}
|
||||
status={status?.status ?? "not_logged_in"}
|
||||
authMethod={status?.auth_method}
|
||||
details={
|
||||
<div className="space-y-1">
|
||||
{status?.email && (
|
||||
<p>
|
||||
{t("credentials.labels.email")}: {status.email}
|
||||
</p>
|
||||
)}
|
||||
{status?.project_id && (
|
||||
<p>
|
||||
{t("credentials.labels.project")}: {status.project_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionBusy}
|
||||
onClick={onStartBrowserOAuth}
|
||||
>
|
||||
{browserLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconLockOpen className="size-4" />
|
||||
{t("credentials.actions.browser")}
|
||||
</Button>
|
||||
{browserLoading && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="secondary"
|
||||
onClick={onStopLoading}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<IconPlayerStopFilled className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
status?.logged_in ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={onAskLogout}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{activeAction === "google-antigravity:logout" && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t("credentials.actions.logout")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
|
||||
import { ProviderStatusLine } from "./provider-status-line"
|
||||
|
||||
interface CredentialCardProps {
|
||||
title: ReactNode
|
||||
description: string
|
||||
status: OAuthProviderStatus["status"]
|
||||
authMethod?: string
|
||||
details?: ReactNode
|
||||
actions: ReactNode
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function CredentialCard({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
authMethod,
|
||||
details,
|
||||
actions,
|
||||
footer,
|
||||
}: CredentialCardProps) {
|
||||
return (
|
||||
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
|
||||
<div className="min-h-16">
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
</div>
|
||||
|
||||
<ProviderStatusLine status={status} authMethod={authMethod} />
|
||||
<div className="text-muted-foreground mt-3 min-h-11 text-xs leading-5">
|
||||
{details}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4 pt-4">
|
||||
<div className="min-h-[112px]">{actions}</div>
|
||||
<div className="min-h-8">{footer}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { useCredentialsPage } from "@/hooks/use-credentials-page"
|
||||
|
||||
import { AnthropicCredentialCard } from "./anthropic-credential-card"
|
||||
import { AntigravityCredentialCard } from "./antigravity-credential-card"
|
||||
import { DeviceCodeSheet } from "./device-code-sheet"
|
||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
||||
import { OpenAICredentialCard } from "./openai-credential-card"
|
||||
|
||||
export function CredentialsPage() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
activeAction,
|
||||
activeFlow,
|
||||
flowHint,
|
||||
openAIToken,
|
||||
anthropicToken,
|
||||
openaiStatus,
|
||||
anthropicStatus,
|
||||
antigravityStatus,
|
||||
logoutDialogOpen,
|
||||
logoutConfirmProvider,
|
||||
logoutProviderLabel,
|
||||
deviceSheetOpen,
|
||||
deviceFlow,
|
||||
setOpenAIToken,
|
||||
setAnthropicToken,
|
||||
startBrowserOAuth,
|
||||
startOpenAIDeviceCode,
|
||||
stopLoading,
|
||||
saveToken,
|
||||
askLogout,
|
||||
handleConfirmLogout,
|
||||
handleLogoutDialogOpenChange,
|
||||
handleDeviceSheetOpenChange,
|
||||
} = useCredentialsPage()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.credentials")} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
|
||||
<div className="pt-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("credentials.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeFlow && (
|
||||
<div className="bg-muted mt-4 rounded-lg border px-4 py-3 text-sm">
|
||||
<p className="font-medium">{t("credentials.flow.current")}</p>
|
||||
<p className="text-muted-foreground mt-1">{flowHint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-10 text-sm">
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
{t("credentials.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 py-5 lg:auto-rows-fr lg:grid-cols-3">
|
||||
<OpenAICredentialCard
|
||||
status={openaiStatus}
|
||||
activeAction={activeAction}
|
||||
token={openAIToken}
|
||||
onTokenChange={setOpenAIToken}
|
||||
onStartBrowserOAuth={() => void startBrowserOAuth("openai")}
|
||||
onStartDeviceCode={() => void startOpenAIDeviceCode()}
|
||||
onStopLoading={stopLoading}
|
||||
onSaveToken={() => void saveToken("openai", openAIToken.trim())}
|
||||
onAskLogout={() => askLogout("openai")}
|
||||
/>
|
||||
|
||||
<AnthropicCredentialCard
|
||||
status={anthropicStatus}
|
||||
activeAction={activeAction}
|
||||
token={anthropicToken}
|
||||
onTokenChange={setAnthropicToken}
|
||||
onStopLoading={stopLoading}
|
||||
onSaveToken={() =>
|
||||
void saveToken("anthropic", anthropicToken.trim())
|
||||
}
|
||||
onAskLogout={() => askLogout("anthropic")}
|
||||
/>
|
||||
|
||||
<AntigravityCredentialCard
|
||||
status={antigravityStatus}
|
||||
activeAction={activeAction}
|
||||
onStopLoading={stopLoading}
|
||||
onStartBrowserOAuth={() =>
|
||||
void startBrowserOAuth("google-antigravity")
|
||||
}
|
||||
onAskLogout={() => askLogout("google-antigravity")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LogoutConfirmDialog
|
||||
open={logoutDialogOpen}
|
||||
providerLabel={logoutProviderLabel}
|
||||
isSubmitting={activeAction === `${logoutConfirmProvider}:logout`}
|
||||
onOpenChange={handleLogoutDialogOpenChange}
|
||||
onConfirm={handleConfirmLogout}
|
||||
/>
|
||||
|
||||
<DeviceCodeSheet
|
||||
open={deviceSheetOpen}
|
||||
flow={deviceFlow}
|
||||
flowHint={flowHint}
|
||||
onOpenChange={handleDeviceSheetOpenChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { IconRefresh } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthFlowState } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
interface DeviceCodeSheetProps {
|
||||
open: boolean
|
||||
flow: OAuthFlowState | null
|
||||
flowHint: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DeviceCodeSheet({
|
||||
open,
|
||||
flow,
|
||||
flowHint,
|
||||
onOpenChange,
|
||||
}: DeviceCodeSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="data-[side=right]:!w-full data-[side=right]:sm:!w-[480px] data-[side=right]:sm:!max-w-[480px]"
|
||||
>
|
||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||
<SheetTitle>{t("credentials.device.title")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("credentials.device.description")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">
|
||||
{t("credentials.device.code")}
|
||||
</p>
|
||||
<p className="mt-1 rounded-md border px-3 py-2 font-mono text-lg font-semibold tracking-wide">
|
||||
{flow?.user_code || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">
|
||||
{t("credentials.device.url")}
|
||||
</p>
|
||||
<a
|
||||
href={flow?.verify_url || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary mt-1 block text-sm break-all underline"
|
||||
>
|
||||
{flow?.verify_url || "-"}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<IconRefresh className="size-4" />
|
||||
{t("credentials.device.polling")}
|
||||
</div>
|
||||
|
||||
{flow && (
|
||||
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
|
||||
{flowHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button asChild disabled={!flow?.verify_url}>
|
||||
<a href={flow?.verify_url || "#"} target="_blank" rel="noreferrer">
|
||||
{t("credentials.device.open")}
|
||||
</a>
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
interface LogoutConfirmDialogProps {
|
||||
open: boolean
|
||||
providerLabel: string
|
||||
isSubmitting: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function LogoutConfirmDialog({
|
||||
open,
|
||||
providerLabel,
|
||||
isSubmitting,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: LogoutConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("credentials.logoutDialog.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"credentials.logoutDialog.description",
|
||||
"This will remove your saved credential for {{provider}}.",
|
||||
{ provider: providerLabel },
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} variant="destructive">
|
||||
{isSubmitting && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{t("credentials.actions.logout")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
IconBrandOpenai,
|
||||
IconClockHour4,
|
||||
IconKey,
|
||||
IconLoader2,
|
||||
IconPlayerStopFilled,
|
||||
} from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
import { CredentialCard } from "./credential-card"
|
||||
|
||||
interface OpenAICredentialCardProps {
|
||||
status?: OAuthProviderStatus
|
||||
activeAction: string
|
||||
token: string
|
||||
onTokenChange: (value: string) => void
|
||||
onStartBrowserOAuth: () => void
|
||||
onStartDeviceCode: () => void
|
||||
onStopLoading: () => void
|
||||
onSaveToken: () => void
|
||||
onAskLogout: () => void
|
||||
}
|
||||
|
||||
export function OpenAICredentialCard({
|
||||
status,
|
||||
activeAction,
|
||||
token,
|
||||
onTokenChange,
|
||||
onStartBrowserOAuth,
|
||||
onStartDeviceCode,
|
||||
onStopLoading,
|
||||
onSaveToken,
|
||||
onAskLogout,
|
||||
}: OpenAICredentialCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const actionBusy = activeAction !== ""
|
||||
const browserLoading = activeAction === "openai:browser"
|
||||
const deviceLoading = activeAction === "openai:device"
|
||||
const oauthLoading = browserLoading || deviceLoading
|
||||
const tokenLoading = activeAction === "openai:token"
|
||||
|
||||
return (
|
||||
<CredentialCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||
<IconBrandOpenai className="size-3.5" />
|
||||
</span>
|
||||
<span>OpenAI</span>
|
||||
</span>
|
||||
}
|
||||
description={t("credentials.providers.openai.description")}
|
||||
status={status?.status ?? "not_logged_in"}
|
||||
authMethod={status?.auth_method}
|
||||
details={
|
||||
status?.account_id ? (
|
||||
<p>
|
||||
{t("credentials.labels.account")}: {status.account_id}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<div className="border-muted flex h-[120px] flex-col rounded-lg border p-3">
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="min-h-8">
|
||||
<div className="flex flex-nowrap items-center gap-2 overflow-x-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionBusy}
|
||||
onClick={onStartBrowserOAuth}
|
||||
>
|
||||
{browserLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconBrandOpenai className="size-4" />
|
||||
{t("credentials.actions.browser")}
|
||||
</Button>
|
||||
|
||||
{oauthLoading && !deviceLoading && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="secondary"
|
||||
onClick={onStopLoading}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<IconPlayerStopFilled className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionBusy}
|
||||
onClick={onStartDeviceCode}
|
||||
>
|
||||
{deviceLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconClockHour4 className="size-4" />
|
||||
{t("credentials.actions.deviceCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-9 flex-1">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<Input
|
||||
value={token}
|
||||
onChange={(e) => onTokenChange(e.target.value)}
|
||||
type="password"
|
||||
placeholder={t("credentials.fields.openaiToken")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={actionBusy || !token.trim()}
|
||||
onClick={onSaveToken}
|
||||
>
|
||||
{tokenLoading && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
<IconKey className="size-4" />
|
||||
{t("credentials.actions.saveToken")}
|
||||
</Button>
|
||||
{tokenLoading && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={onStopLoading}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<IconPlayerStopFilled className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
status?.logged_in ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={onAskLogout}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{activeAction === "openai:logout" && (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t("credentials.actions.logout")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { OAuthProviderStatus } from "@/api/oauth"
|
||||
|
||||
interface ProviderStatusLineProps {
|
||||
status: OAuthProviderStatus["status"]
|
||||
authMethod?: string
|
||||
}
|
||||
|
||||
export function ProviderStatusLine({
|
||||
status,
|
||||
authMethod,
|
||||
}: ProviderStatusLineProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const style =
|
||||
status === "connected"
|
||||
? "bg-green-500/10 text-green-700 dark:text-green-300"
|
||||
: status === "needs_refresh"
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: status === "expired"
|
||||
? "bg-red-500/10 text-red-700 dark:text-red-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`rounded px-2 py-1 text-xs font-medium ${style}`}>
|
||||
{status === "connected"
|
||||
? t("credentials.status.connected")
|
||||
: status === "needs_refresh"
|
||||
? t("credentials.status.needsRefresh")
|
||||
: status === "expired"
|
||||
? t("credentials.status.expired")
|
||||
: t("credentials.status.notLoggedIn")}
|
||||
</span>
|
||||
{authMethod && (
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
{authMethod}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { addModel, setDefaultModel } from "@/api/models"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
SwitchCardField,
|
||||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
interface AddForm {
|
||||
modelName: string
|
||||
model: string
|
||||
apiBase: string
|
||||
apiKey: string
|
||||
proxy: string
|
||||
authMethod: string
|
||||
connectMode: string
|
||||
workspace: string
|
||||
rpm: string
|
||||
maxTokensField: string
|
||||
requestTimeout: string
|
||||
thinkingLevel: string
|
||||
}
|
||||
|
||||
const EMPTY_ADD_FORM: AddForm = {
|
||||
modelName: "",
|
||||
model: "",
|
||||
apiBase: "",
|
||||
apiKey: "",
|
||||
proxy: "",
|
||||
authMethod: "",
|
||||
connectMode: "",
|
||||
workspace: "",
|
||||
rpm: "",
|
||||
maxTokensField: "",
|
||||
requestTimeout: "",
|
||||
thinkingLevel: "",
|
||||
}
|
||||
|
||||
interface AddModelSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
existingModelNames: string[]
|
||||
}
|
||||
|
||||
export function AddModelSheet({
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
existingModelNames,
|
||||
}: AddModelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||
const [fieldErrors, setFieldErrors] = useState<
|
||||
Partial<Record<keyof AddForm, string>>
|
||||
>({})
|
||||
const [serverError, setServerError] = useState("")
|
||||
const apiKeyPlaceholder = maskedSecretPlaceholder(
|
||||
form.apiKey,
|
||||
t("models.field.apiKeyPlaceholder"),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(EMPTY_ADD_FORM)
|
||||
setSetAsDefault(false)
|
||||
setFieldErrors({})
|
||||
setServerError("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: Partial<Record<keyof AddForm, string>> = {}
|
||||
const modelName = form.modelName.trim()
|
||||
if (!modelName) {
|
||||
errors.modelName = t("models.add.errorRequired")
|
||||
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
||||
errors.modelName = t("models.add.errorDuplicateModelName")
|
||||
}
|
||||
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||
setFieldErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const setField =
|
||||
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
if (fieldErrors[key]) {
|
||||
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
try {
|
||||
const modelName = form.modelName.trim()
|
||||
const modelId = form.model.trim()
|
||||
await addModel({
|
||||
model_name: modelName,
|
||||
model: modelId,
|
||||
api_base: form.apiBase.trim() || undefined,
|
||||
api_key: form.apiKey.trim() || undefined,
|
||||
proxy: form.proxy.trim() || undefined,
|
||||
auth_method: form.authMethod.trim() || undefined,
|
||||
connect_mode: form.connectMode.trim() || undefined,
|
||||
workspace: form.workspace.trim() || undefined,
|
||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||
max_tokens_field: form.maxTokensField.trim() || undefined,
|
||||
request_timeout: form.requestTimeout
|
||||
? Number(form.requestTimeout)
|
||||
: undefined,
|
||||
thinking_level: form.thinkingLevel.trim() || undefined,
|
||||
})
|
||||
if (setAsDefault) {
|
||||
await setDefaultModel(modelName)
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setServerError(e instanceof Error ? e.message : t("models.add.saveError"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||
>
|
||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||
<SheetTitle className="text-base">{t("models.add.title")}</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
{t("models.add.description")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<Field
|
||||
label={t("models.add.modelName")}
|
||||
hint={t("models.add.modelNameHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.modelName}
|
||||
onChange={setField("modelName")}
|
||||
placeholder={t("models.add.modelNamePlaceholder")}
|
||||
aria-invalid={!!fieldErrors.modelName}
|
||||
/>
|
||||
{fieldErrors.modelName && (
|
||||
<p className="text-destructive text-xs">
|
||||
{fieldErrors.modelName}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.add.modelId")}
|
||||
hint={t("models.add.modelIdHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={setField("model")}
|
||||
placeholder={t("models.add.modelIdPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
aria-invalid={!!fieldErrors.model}
|
||||
/>
|
||||
{fieldErrors.model && (
|
||||
<p className="text-destructive text-xs">{fieldErrors.model}</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t("models.field.apiKey")}>
|
||||
<KeyInput
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("models.field.apiBase")}>
|
||||
<Input
|
||||
value={form.apiBase}
|
||||
onChange={setField("apiBase")}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("models.defaultOnSave.label")}
|
||||
hint={t("models.defaultOnSave.description")}
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
/>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("models.field.proxy")}
|
||||
hint={t("models.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.proxy}
|
||||
onChange={setField("proxy")}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.authMethod")}
|
||||
hint={t("models.field.authMethodHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.authMethod}
|
||||
onChange={setField("authMethod")}
|
||||
placeholder="oauth"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.connectMode")}
|
||||
hint={t("models.field.connectModeHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.connectMode}
|
||||
onChange={setField("connectMode")}
|
||||
placeholder="stdio"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.workspace")}
|
||||
hint={t("models.field.workspaceHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={setField("workspace")}
|
||||
placeholder="/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.requestTimeout")}
|
||||
hint={t("models.field.requestTimeoutHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.requestTimeout}
|
||||
onChange={setField("requestTimeout")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.rpm")}
|
||||
hint={t("models.field.rpmHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.rpm}
|
||||
onChange={setField("rpm")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.thinkingLevel")}
|
||||
hint={t("models.field.thinkingLevelHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.thinkingLevel}
|
||||
onChange={setField("thinkingLevel")}
|
||||
placeholder="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.maxTokensField")}
|
||||
hint={t("models.field.maxTokensFieldHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.maxTokensField}
|
||||
onChange={setField("maxTokensField")}
|
||||
placeholder="max_completion_tokens"
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
|
||||
{serverError && (
|
||||
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
||||
{serverError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{t("models.add.confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { type ModelInfo, deleteModel } from "@/api/models"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
interface DeleteModelDialogProps {
|
||||
model: ModelInfo | null
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
export function DeleteModelDialog({
|
||||
model,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: DeleteModelDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!model) return
|
||||
if (model.is_default) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteModel(model.index)
|
||||
onDeleted()
|
||||
} catch {
|
||||
// ignore, user can retry from list
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={model !== null} onOpenChange={(v) => !v && onClose()}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("models.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("models.delete.description", { name: model?.model_name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onClose} disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{t("models.delete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import {
|
||||
AdvancedSection,
|
||||
Field,
|
||||
KeyInput,
|
||||
SwitchCardField,
|
||||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
interface EditForm {
|
||||
apiKey: string
|
||||
apiBase: string
|
||||
proxy: string
|
||||
authMethod: string
|
||||
connectMode: string
|
||||
workspace: string
|
||||
rpm: string
|
||||
maxTokensField: string
|
||||
requestTimeout: string
|
||||
thinkingLevel: string
|
||||
}
|
||||
|
||||
interface EditModelSheetProps {
|
||||
model: ModelInfo | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function EditModelSheet({
|
||||
model,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditModelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<EditForm>({
|
||||
apiKey: "",
|
||||
apiBase: "",
|
||||
proxy: "",
|
||||
authMethod: "",
|
||||
connectMode: "",
|
||||
workspace: "",
|
||||
rpm: "",
|
||||
maxTokensField: "",
|
||||
requestTimeout: "",
|
||||
thinkingLevel: "",
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
setForm({
|
||||
apiKey: "",
|
||||
apiBase: model.api_base ?? "",
|
||||
proxy: model.proxy ?? "",
|
||||
authMethod: model.auth_method ?? "",
|
||||
connectMode: model.connect_mode ?? "",
|
||||
workspace: model.workspace ?? "",
|
||||
rpm: model.rpm ? String(model.rpm) : "",
|
||||
maxTokensField: model.max_tokens_field ?? "",
|
||||
requestTimeout: model.request_timeout
|
||||
? String(model.request_timeout)
|
||||
: "",
|
||||
thinkingLevel: model.thinking_level ?? "",
|
||||
})
|
||||
setSetAsDefault(model.is_default)
|
||||
setError("")
|
||||
}
|
||||
}, [model])
|
||||
|
||||
const setField =
|
||||
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!model) return
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
await updateModel(model.index, {
|
||||
model_name: model.model_name,
|
||||
model: model.model,
|
||||
api_base: form.apiBase || undefined,
|
||||
api_key: form.apiKey || undefined,
|
||||
proxy: form.proxy || undefined,
|
||||
auth_method: form.authMethod || undefined,
|
||||
connect_mode: form.connectMode || undefined,
|
||||
workspace: form.workspace || undefined,
|
||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||
max_tokens_field: form.maxTokensField || undefined,
|
||||
request_timeout: form.requestTimeout
|
||||
? Number(form.requestTimeout)
|
||||
: undefined,
|
||||
thinking_level: form.thinkingLevel || undefined,
|
||||
})
|
||||
if (setAsDefault) {
|
||||
await setDefaultModel(model.model_name)
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("models.edit.saveError"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isOAuth = model?.auth_method === "oauth"
|
||||
const apiKeyPlaceholder = model?.configured
|
||||
? maskedSecretPlaceholder(
|
||||
model.api_key,
|
||||
t("models.field.apiKeyPlaceholderSet"),
|
||||
)
|
||||
: t("models.field.apiKeyPlaceholder")
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||
>
|
||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||
<SheetTitle className="text-base">
|
||||
{t("models.edit.title", { name: model?.model_name })}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono text-xs">
|
||||
{model?.model}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{!isOAuth && (
|
||||
<Field
|
||||
label={t("models.field.apiKey")}
|
||||
hint={
|
||||
model?.configured ? t("models.edit.apiKeyHint") : undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={t("models.field.apiBase")}
|
||||
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||
>
|
||||
<Input
|
||||
value={form.apiBase}
|
||||
onChange={setField("apiBase")}
|
||||
placeholder="https://api.example.com/v1"
|
||||
disabled={isOAuth}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("models.defaultOnSave.label")}
|
||||
hint={t("models.defaultOnSave.description")}
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
/>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("models.field.proxy")}
|
||||
hint={t("models.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.proxy}
|
||||
onChange={setField("proxy")}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.authMethod")}
|
||||
hint={t("models.field.authMethodHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.authMethod}
|
||||
onChange={setField("authMethod")}
|
||||
placeholder="oauth"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.connectMode")}
|
||||
hint={t("models.field.connectModeHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.connectMode}
|
||||
onChange={setField("connectMode")}
|
||||
placeholder="stdio"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.workspace")}
|
||||
hint={t("models.field.workspaceHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={setField("workspace")}
|
||||
placeholder="/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.requestTimeout")}
|
||||
hint={t("models.field.requestTimeoutHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.requestTimeout}
|
||||
onChange={setField("requestTimeout")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.rpm")}
|
||||
hint={t("models.field.rpmHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.rpm}
|
||||
onChange={setField("rpm")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.thinkingLevel")}
|
||||
hint={t("models.field.thinkingLevelHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.thinkingLevel}
|
||||
onChange={setField("thinkingLevel")}
|
||||
placeholder="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.maxTokensField")}
|
||||
hint={t("models.field.maxTokensFieldHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.maxTokensField}
|
||||
onChange={setField("maxTokensField")}
|
||||
placeholder="max_completion_tokens"
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
IconEdit,
|
||||
IconKey,
|
||||
IconLoader2,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ModelInfo } from "@/api/models"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ModelCardProps {
|
||||
model: ModelInfo
|
||||
onEdit: (model: ModelInfo) => void
|
||||
onSetDefault: (model: ModelInfo) => void
|
||||
onDelete: (model: ModelInfo) => void
|
||||
settingDefault: boolean
|
||||
}
|
||||
|
||||
export function ModelCard({
|
||||
model,
|
||||
onEdit,
|
||||
onSetDefault,
|
||||
onDelete,
|
||||
settingDefault,
|
||||
}: ModelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const isOAuth = model.auth_method === "oauth"
|
||||
const canSetDefault = model.configured && !model.is_default
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
|
||||
model.configured
|
||||
? "border-border/60 bg-card"
|
||||
: "border-border/50 bg-card/60",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
||||
model.is_default
|
||||
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
|
||||
: model.configured
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/25",
|
||||
].join(" ")}
|
||||
title={
|
||||
model.configured
|
||||
? t("models.status.configured")
|
||||
: t("models.status.unconfigured")
|
||||
}
|
||||
/>
|
||||
<span className="text-foreground truncate text-sm font-semibold">
|
||||
{model.model_name}
|
||||
</span>
|
||||
{model.is_default && (
|
||||
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] leading-none font-medium">
|
||||
{t("models.badge.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{model.is_default ? (
|
||||
<span
|
||||
className="text-primary p-1"
|
||||
title={t("models.badge.default")}
|
||||
>
|
||||
<IconStarFilled className="size-3.5" />
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onSetDefault(model)}
|
||||
disabled={settingDefault || !canSetDefault}
|
||||
title={t("models.action.setDefault")}
|
||||
>
|
||||
{settingDefault ? (
|
||||
<IconLoader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<IconStar className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onEdit(model)}
|
||||
title={t("models.action.edit")}
|
||||
>
|
||||
<IconEdit className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onDelete(model)}
|
||||
disabled={model.is_default}
|
||||
title={t("models.action.delete")}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground truncate font-mono text-xs leading-snug">
|
||||
{model.model}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isOAuth ? (
|
||||
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||
OAuth
|
||||
</span>
|
||||
) : model.configured && model.api_key ? (
|
||||
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
|
||||
<IconKey className="size-3" />
|
||||
{model.api_key}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 text-[11px]">
|
||||
{t("models.status.unconfigured")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
import { AddModelSheet } from "./add-model-sheet"
|
||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||
import { EditModelSheet } from "./edit-model-sheet"
|
||||
import { getProviderKey, getProviderLabel } from "./provider-label"
|
||||
import { ProviderSection } from "./provider-section"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
volcengine: 0,
|
||||
openai: 1,
|
||||
gemini: 2,
|
||||
anthropic: 3,
|
||||
zhipu: 4,
|
||||
deepseek: 5,
|
||||
openrouter: 6,
|
||||
qwen: 7,
|
||||
moonshot: 8,
|
||||
groq: 9,
|
||||
"github-copilot": 10,
|
||||
antigravity: 11,
|
||||
nvidia: 12,
|
||||
cerebras: 13,
|
||||
shengsuanyun: 14,
|
||||
ollama: 15,
|
||||
vllm: 16,
|
||||
mistral: 17,
|
||||
avian: 18,
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
key: string
|
||||
label: string
|
||||
models: ModelInfo[]
|
||||
hasDefault: boolean
|
||||
configuredCount: number
|
||||
}
|
||||
|
||||
export function ModelsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
|
||||
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
|
||||
const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
try {
|
||||
const data = await getModels()
|
||||
const sorted = [...data.models].sort((a, b) => {
|
||||
if (a.is_default && !b.is_default) return -1
|
||||
if (!a.is_default && b.is_default) return 1
|
||||
if (a.configured && !b.configured) return -1
|
||||
if (!a.configured && b.configured) return 1
|
||||
return a.model_name.localeCompare(b.model_name)
|
||||
})
|
||||
setModels(sorted)
|
||||
setFetchError("")
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
}, [fetchModels])
|
||||
|
||||
const handleSetDefault = async (model: ModelInfo) => {
|
||||
setSettingDefaultIndex(model.index)
|
||||
try {
|
||||
await setDefaultModel(model.model_name)
|
||||
await fetchModels()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSettingDefaultIndex(null)
|
||||
}
|
||||
}
|
||||
|
||||
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}
|
||||
for (const model of models) {
|
||||
const providerKey = getProviderKey(model.model)
|
||||
if (!grouped[providerKey]) {
|
||||
grouped[providerKey] = {
|
||||
label: getProviderLabel(model.model),
|
||||
models: [],
|
||||
}
|
||||
}
|
||||
grouped[providerKey].models.push(model)
|
||||
}
|
||||
|
||||
const providerGroups: ProviderGroup[] = Object.entries(grouped)
|
||||
.map(([key, group]) => {
|
||||
const configuredCount = group.models.filter(
|
||||
(model) => model.configured,
|
||||
).length
|
||||
return {
|
||||
key,
|
||||
label: group.label,
|
||||
models: group.models,
|
||||
hasDefault: group.models.some((model) => model.is_default),
|
||||
configuredCount,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.hasDefault && !b.hasDefault) return -1
|
||||
if (!a.hasDefault && b.hasDefault) return 1
|
||||
|
||||
if (a.configuredCount !== b.configuredCount) {
|
||||
return b.configuredCount - a.configuredCount
|
||||
}
|
||||
|
||||
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER
|
||||
const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
|
||||
const defaultModel = models.find((model) => model.is_default)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.models")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||
<IconPlus className="size-4" />
|
||||
{t("models.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 sm:px-6">
|
||||
<div className="pt-2">
|
||||
{!defaultModel && (
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
||||
<span>{t("models.noDefaultHintPrefix")}</span>
|
||||
<IconStar className="size-3.5 shrink-0" />
|
||||
<span>{t("models.noDefaultHintSuffix")}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("models.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<IconLoader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchError && (
|
||||
<div className="text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm">
|
||||
{fetchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && (
|
||||
<div className="pb-8">
|
||||
{providerGroups.map((providerGroup) => (
|
||||
<ProviderSection
|
||||
key={providerGroup.key}
|
||||
provider={providerGroup.label}
|
||||
providerKey={providerGroup.key}
|
||||
models={providerGroup.models}
|
||||
onEdit={setEditingModel}
|
||||
onSetDefault={handleSetDefault}
|
||||
onDelete={setDeletingModel}
|
||||
settingDefaultIndex={settingDefaultIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditModelSheet
|
||||
model={editingModel}
|
||||
open={editingModel !== null}
|
||||
onClose={() => setEditingModel(null)}
|
||||
onSaved={fetchModels}
|
||||
/>
|
||||
|
||||
<AddModelSheet
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSaved={fetchModels}
|
||||
existingModelNames={models.map((model) => model.model_name)}
|
||||
/>
|
||||
|
||||
<DeleteModelDialog
|
||||
model={deletingModel}
|
||||
onClose={() => setDeletingModel(null)}
|
||||
onDeleted={fetchModels}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "googlegemini",
|
||||
deepseek: "deepseek",
|
||||
qwen: "alibabacloud",
|
||||
groq: "groq",
|
||||
openrouter: "openrouter",
|
||||
nvidia: "nvidia",
|
||||
cerebras: "cerebras",
|
||||
volcengine: "bytedance",
|
||||
"github-copilot": "githubcopilot",
|
||||
ollama: "ollama",
|
||||
mistral: "mistralai",
|
||||
zhipu: "zhipu",
|
||||
}
|
||||
|
||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
||||
openai: "openai.com",
|
||||
anthropic: "anthropic.com",
|
||||
gemini: "gemini.google.com",
|
||||
deepseek: "deepseek.com",
|
||||
qwen: "qwenlm.ai",
|
||||
moonshot: "moonshot.ai",
|
||||
groq: "groq.com",
|
||||
openrouter: "openrouter.ai",
|
||||
nvidia: "nvidia.com",
|
||||
cerebras: "cerebras.ai",
|
||||
volcengine: "volcengine.com",
|
||||
shengsuanyun: "shengsuanyun.com",
|
||||
antigravity: "antigravity.google",
|
||||
"github-copilot": "github.com",
|
||||
ollama: "ollama.com",
|
||||
mistral: "mistral.ai",
|
||||
avian: "avian.io",
|
||||
vllm: "vllm.ai",
|
||||
zhipu: "zhipuai.cn",
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
providerKey: string
|
||||
providerLabel: string
|
||||
}
|
||||
|
||||
export function ProviderIcon({
|
||||
providerKey,
|
||||
providerLabel,
|
||||
}: ProviderIconProps) {
|
||||
const [sourceIndex, setSourceIndex] = useState(0)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const initial = providerLabel.trim().charAt(0).toUpperCase() || "?"
|
||||
const iconUrls = useMemo(() => {
|
||||
const slug = PROVIDER_ICON_SLUGS[providerKey]
|
||||
const domain = PROVIDER_DOMAINS[providerKey]
|
||||
const urls: string[] = []
|
||||
if (slug) {
|
||||
urls.push(`https://cdn.simpleicons.org/${slug}`)
|
||||
}
|
||||
if (domain) {
|
||||
urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`)
|
||||
}
|
||||
return urls
|
||||
}, [providerKey])
|
||||
|
||||
const iconUrl = iconUrls[sourceIndex]
|
||||
|
||||
if (!iconUrl || loadFailed) {
|
||||
return (
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-black/70">
|
||||
{initial}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-black/10 bg-white p-0.5 dark:border-white/20">
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt={`${providerLabel} logo`}
|
||||
className="size-full object-contain"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
if (sourceIndex < iconUrls.length - 1) {
|
||||
setSourceIndex((idx) => idx + 1)
|
||||
return
|
||||
}
|
||||
setLoadFailed(true)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
gemini: "Google Gemini",
|
||||
deepseek: "DeepSeek",
|
||||
qwen: "Qwen (阿里云)",
|
||||
moonshot: "Moonshot (月之暗面)",
|
||||
groq: "Groq",
|
||||
openrouter: "OpenRouter",
|
||||
nvidia: "NVIDIA",
|
||||
cerebras: "Cerebras",
|
||||
volcengine: "Volcengine (火山引擎)",
|
||||
shengsuanyun: "ShengsuanYun (神算云)",
|
||||
antigravity: "Google Code Assist",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
ollama: "Ollama (local)",
|
||||
mistral: "Mistral AI",
|
||||
avian: "Avian",
|
||||
vllm: "VLLM (local)",
|
||||
zhipu: "Zhipu AI (智谱)",
|
||||
}
|
||||
|
||||
export function getProviderKey(model: string): string {
|
||||
return model.split("/")[0]
|
||||
}
|
||||
|
||||
export function getProviderLabel(model: string): string {
|
||||
const prefix = getProviderKey(model)
|
||||
const labels: Record<string, string> = {
|
||||
...PROVIDER_LABELS,
|
||||
}
|
||||
return labels[prefix] ?? prefix
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { IconChevronDown } from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { ModelInfo } from "@/api/models"
|
||||
|
||||
import { ModelCard } from "./model-card"
|
||||
import { ProviderIcon } from "./provider-icon"
|
||||
|
||||
interface ProviderSectionProps {
|
||||
provider: string
|
||||
providerKey: string
|
||||
models: ModelInfo[]
|
||||
onEdit: (model: ModelInfo) => void
|
||||
onSetDefault: (model: ModelInfo) => void
|
||||
onDelete: (model: ModelInfo) => void
|
||||
settingDefaultIndex: number | null
|
||||
}
|
||||
|
||||
export function ProviderSection({
|
||||
provider,
|
||||
providerKey,
|
||||
models,
|
||||
onEdit,
|
||||
onSetDefault,
|
||||
onDelete,
|
||||
settingDefaultIndex,
|
||||
}: ProviderSectionProps) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<section className="my-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="mb-3 grid w-full grid-cols-[1fr_auto_1fr_auto] items-center gap-2 px-1 py-1.5 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="border-border/40 border-t" />
|
||||
<span className="text-foreground/80 text-center text-xs font-semibold tracking-wide uppercase">
|
||||
<span className="bg-background inline-flex items-center gap-1.5 px-2">
|
||||
<ProviderIcon providerKey={providerKey} providerLabel={provider} />
|
||||
{provider}
|
||||
</span>
|
||||
</span>
|
||||
<div className="border-border/40 border-t" />
|
||||
<span className="flex justify-end">
|
||||
<IconChevronDown
|
||||
className={[
|
||||
"text-muted-foreground size-4 transition-transform",
|
||||
open ? "rotate-180" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<ModelCard
|
||||
key={model.index}
|
||||
model={model}
|
||||
onEdit={onEdit}
|
||||
onSetDefault={onSetDefault}
|
||||
onDelete={onDelete}
|
||||
settingDefault={settingDefaultIndex === model.index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IconMenu2 } from "@tabler/icons-react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
titleExtra?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, titleExtra, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between px-6 pt-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<SidebarTrigger className="border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5">
|
||||
<IconMenu2 />
|
||||
</SidebarTrigger>
|
||||
<h2 className="text-foreground/90 text-xl font-medium tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{titleExtra}
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function maskedSecretPlaceholder(value: unknown, fallback = ""): string {
|
||||
const secret = typeof value === "string" ? value.trim() : ""
|
||||
if (!secret) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if (secret.length < 7) {
|
||||
const first = secret[0]
|
||||
const last = secret[secret.length - 1]
|
||||
return `${first}***${last}`
|
||||
}
|
||||
|
||||
const prefix = secret.slice(0, Math.min(3, secret.length))
|
||||
const suffix = secret.slice(-Math.min(4, secret.length))
|
||||
return `${prefix}***${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { IconChevronDown, IconEye, IconEyeOff } from "@tabler/icons-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
Field as UiField,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Field({ label, hint, error, required, children }: FieldProps) {
|
||||
return (
|
||||
<UiField className="gap-2.5">
|
||||
<div className="space-y-1">
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
{hint && (
|
||||
<FieldDescription className="text-xs leading-normal">
|
||||
{hint}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
{error && (
|
||||
<FieldDescription className="text-destructive text-xs leading-normal">
|
||||
{error}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</UiField>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeyInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={show ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
tabIndex={-1}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2 transition-colors"
|
||||
>
|
||||
{show ? (
|
||||
<IconEyeOff className="size-4" />
|
||||
) : (
|
||||
<IconEye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SwitchCardFieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function SwitchCardField({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
children,
|
||||
}: SwitchCardFieldProps) {
|
||||
return (
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hint && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
</div>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
{error && (
|
||||
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdvancedSection({ children }: AdvancedSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border-border/50 rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="hover:bg-muted/40 flex w-full items-center justify-between rounded-lg px-4 py-3 transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("models.advanced.toggle")}
|
||||
</span>
|
||||
<IconChevronDown
|
||||
className={[
|
||||
"text-muted-foreground size-4 transition-transform duration-200",
|
||||
open ? "rotate-180" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/30 space-y-5 border-t px-4 pt-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
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-6 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 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(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-normal font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { IconCheck, IconChevronRight } from "@tabler/icons-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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]:overflow-hidden 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", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<IconCheck
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<IconCheck
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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-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", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-3 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<IconSelector className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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-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", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<IconCheck className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronUp
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronDown
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { IconX } from "@tabler/icons-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4"
|
||||
size="icon-sm"
|
||||
>
|
||||
<IconX
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { IconLayoutSidebar } from "@tabler/icons-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<IconLayoutSidebar />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background 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=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
Reference in New Issue
Block a user