This commit is contained in:
mlogclub
2026-04-09 10:01:23 +08:00
commit efe801b8bf
707 changed files with 110595 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
"use client"
import type { ComponentProps } from "react"
import Link from "next/link"
import { useMemo } from "react"
import {
filterDashboardNavForSession,
filterDashboardSecondaryNavForSession,
} from "@/lib/navigation"
import { useAuth } from "@/components/auth-provider"
import { NavMain } from "@/components/nav-main"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { BotMessageSquareIcon } from "lucide-react"
export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
const { session } = useAuth()
const navSections = useMemo(
() => filterDashboardNavForSession(session?.permissions, session?.roles),
[session?.permissions, session?.roles]
)
const secondaryNavItems = useMemo(
() => filterDashboardSecondaryNavForSession(session?.permissions, session?.roles),
[session?.permissions, session?.roles]
)
const user = {
name: session?.user.nickname || session?.user.username || "未登录",
email: session?.user.username || "guest",
avatar: session?.user.avatar || "",
}
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[slot=sidebar-menu-button]:p-1.5!"
render={<Link href="/" />}
>
<BotMessageSquareIcon className="size-5!" />
<span className="text-base font-semibold">AGENT</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{navSections.map((section) => (
<NavMain key={section.title} title={section.title} items={section.items} />
))}
{secondaryNavItems.length > 0 ? (
<NavSecondary items={secondaryNavItems} className="mt-auto" />
) : null}
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
)
}
+106
View File
@@ -0,0 +1,106 @@
"use client"
import {
createContext,
startTransition,
useContext,
useCallback,
useEffect,
useState,
type ReactNode,
} from "react"
import { usePathname, useRouter } from "next/navigation"
import { fetchProfile, logout } from "@/lib/api/auth"
import {
clearSession,
readSession,
writeSession,
type AuthSession,
} from "@/lib/auth"
type AuthContextValue = {
session: AuthSession | null
ready: boolean
refreshProfile: () => Promise<void>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const [session, setSession] = useState<AuthSession | null>(null)
const [ready, setReady] = useState(false)
const refreshProfile = useCallback(async () => {
const stored = readSession()
if (!stored) {
setSession(null)
setReady(true)
return
}
try {
const profile = await fetchProfile()
const nextSession: AuthSession = {
...stored,
user: profile.user,
permissions: profile.permissions,
roles: profile.roles,
}
writeSession(nextSession)
setSession(nextSession)
} catch {
clearSession()
setSession(null)
if (pathname && !pathname.startsWith("/login")) {
startTransition(() => {
router.replace("/login")
})
}
} finally {
setReady(true)
}
}, [pathname, router])
async function signOut() {
const current = readSession()
await logout(current?.refreshToken)
setSession(null)
startTransition(() => {
router.replace("/login")
})
}
useEffect(() => {
const stored = readSession()
setSession(stored)
if (stored) {
void refreshProfile()
return
}
setReady(true)
if (pathname && !pathname.startsWith("/login")) {
startTransition(() => {
router.replace("/login")
})
}
}, [pathname, refreshProfile, router])
return (
<AuthContext.Provider value={{ session, ready, refreshProfile, signOut }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider")
}
return ctx
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { toast } from "sonner";
import { changeSelfPassword } from "@/lib/api/admin";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ProjectDialog } from "@/components/project-dialog";
const changePasswordSchema = z
.object({
password: z.string().trim().min(1, "新密码不能为空"),
confirmPassword: z.string().trim().min(1, "确认密码不能为空"),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "两次输入的密码不一致",
});
type ChangePasswordForm = z.infer<typeof changePasswordSchema>;
const changePasswordResolver = zodResolver(
changePasswordSchema as never,
) as Resolver<
z.input<typeof changePasswordSchema>,
undefined,
z.output<typeof changePasswordSchema>
>;
const emptyForm: ChangePasswordForm = {
password: "",
confirmPassword: "",
};
type ChangePasswordDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => Promise<void>;
};
export function ChangePasswordDialog({
open,
onOpenChange,
onSuccess,
}: ChangePasswordDialogProps) {
const form = useForm<
z.input<typeof changePasswordSchema>,
undefined,
z.output<typeof changePasswordSchema>
>({
resolver: changePasswordResolver,
defaultValues: emptyForm,
});
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting },
} = form;
useEffect(() => {
if (open) {
reset(emptyForm);
}
}, [open, reset]);
async function onSubmit(values: ChangePasswordForm) {
try {
await changeSelfPassword(values.password.trim());
toast.success("密码已修改,请重新登录");
onOpenChange(false);
await onSuccess();
} catch (error) {
toast.error(error instanceof Error ? error.message : "修改密码失败");
}
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title="修改密码"
description="修改当前登录账号的密码,提交后需要重新登录。"
size="sm"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
</Button>
<Button type="submit" form="change-password-form" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "确认修改"}
</Button>
</>
}
>
<form id="change-password-form" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4 px-6 py-4">
<Field data-invalid={!!errors.password}>
<FieldLabel htmlFor="change-password-password"></FieldLabel>
<FieldContent>
<Input
id="change-password-password"
type="password"
placeholder="请输入新密码"
autoComplete="new-password"
aria-invalid={!!errors.password}
{...register("password")}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.confirmPassword}>
<FieldLabel htmlFor="change-password-confirm"></FieldLabel>
<FieldContent>
<Input
id="change-password-confirm"
type="password"
placeholder="请再次输入新密码"
autoComplete="new-password"
aria-invalid={!!errors.confirmPassword}
{...register("confirmPassword")}
/>
<FieldError errors={[errors.confirmPassword]} />
</FieldContent>
</Field>
</div>
</form>
</ProjectDialog>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
const chartData = [
{ date: "2026-03-01", activeSessions: 18, indexedDocs: 12 },
{ date: "2026-03-02", activeSessions: 22, indexedDocs: 15 },
{ date: "2026-03-03", activeSessions: 21, indexedDocs: 16 },
{ date: "2026-03-04", activeSessions: 28, indexedDocs: 20 },
{ date: "2026-03-05", activeSessions: 32, indexedDocs: 24 },
{ date: "2026-03-06", activeSessions: 31, indexedDocs: 26 },
{ date: "2026-03-07", activeSessions: 36, indexedDocs: 30 },
{ date: "2026-03-08", activeSessions: 34, indexedDocs: 32 },
{ date: "2026-03-09", activeSessions: 39, indexedDocs: 34 },
{ date: "2026-03-10", activeSessions: 41, indexedDocs: 37 },
{ date: "2026-03-11", activeSessions: 43, indexedDocs: 40 },
{ date: "2026-03-12", activeSessions: 46, indexedDocs: 44 },
{ date: "2026-03-13", activeSessions: 44, indexedDocs: 46 },
{ date: "2026-03-14", activeSessions: 49, indexedDocs: 50 },
]
const chartConfig = {
activeSessions: {
label: "活跃会话",
color: "var(--primary)",
},
indexedDocs: {
label: "知识文档",
color: "var(--chart-2)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const [timeRange, setTimeRange] = React.useState("14d")
const filteredData = chartData.slice(timeRange === "7d" ? -7 : -14)
return (
<Card className="@container/card">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardAction>
<ToggleGroup
multiple={false}
value={timeRange ? [timeRange] : []}
onValueChange={(value) => {
setTimeRange(value[0] ?? "14d")
}}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="14d"> 14 </ToggleGroupItem>
<ToggleGroupItem value="7d"> 7 </ToggleGroupItem>
</ToggleGroup>
<Select
value={timeRange}
onValueChange={(value) => {
if (value) {
setTimeRange(value)
}
}}
>
<SelectTrigger
className="flex w-32 @[767px]/card:hidden"
size="sm"
aria-label="选择时间范围"
>
<SelectValue placeholder="近 14 天" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="14d" className="rounded-lg">
14
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
7
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillSessions" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-activeSessions)" stopOpacity={0.9} />
<stop offset="95%" stopColor="var(--color-activeSessions)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillDocs" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-indexedDocs)" stopOpacity={0.7} />
<stop offset="95%" stopColor="var(--color-indexedDocs)" stopOpacity={0.08} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(5)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="activeSessions"
type="natural"
fill="url(#fillSessions)"
stroke="var(--color-activeSessions)"
stackId="a"
/>
<Area
dataKey="indexedDocs"
type="natural"
fill="url(#fillDocs)"
stroke="var(--color-indexedDocs)"
stackId="b"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
+243
View File
@@ -0,0 +1,243 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"
import { toast } from "sonner"
import { EditDialog as CompanyEditDialog } from "@/app/(console)/companies/_components/edit"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
createCompany,
fetchCompanies,
fetchCompany,
type AdminCompany,
type CreateAdminCompanyPayload,
} from "@/lib/api/company"
import { cn } from "@/lib/utils"
type CompanyPickerProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
}
export function CompanyPicker({
value,
onChange,
disabled = false,
placeholder = "请选择公司",
}: CompanyPickerProps) {
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState("")
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<AdminCompany[]>([])
const [selectedCompany, setSelectedCompany] = useState<AdminCompany | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createSaving, setCreateSaving] = useState(false)
const trimmedKeyword = keyword.trim()
const normalizedKeyword = trimmedKeyword.toLowerCase()
useEffect(() => {
let cancelled = false
if (!open) {
return
}
setLoading(true)
void (async () => {
try {
const data = await fetchCompanies({
status: 0,
page: 1,
limit: 20,
name: trimmedKeyword || undefined,
})
if (cancelled) {
return
}
setOptions(data.results)
} catch (error) {
if (!cancelled) {
setOptions([])
toast.error(error instanceof Error ? error.message : "加载公司列表失败")
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [open, trimmedKeyword])
useEffect(() => {
let cancelled = false
const companyId = Number(value)
if (companyId <= 0) {
setSelectedCompany(null)
return
}
if (selectedCompany?.id === companyId) {
return
}
void (async () => {
try {
const data = await fetchCompany(companyId)
if (!cancelled) {
setSelectedCompany(data)
}
} catch {
if (!cancelled) {
setSelectedCompany(null)
}
}
})()
return () => {
cancelled = true
}
}, [selectedCompany?.id, value])
const canCreate = useMemo(() => {
if (!trimmedKeyword) {
return false
}
return !options.some((item) => item.name.trim().toLowerCase() === normalizedKeyword)
}, [normalizedKeyword, options, trimmedKeyword])
const buttonLabel =
Number(value) > 0 ? selectedCompany?.name || `公司 #${value}` : placeholder
function handleSelectCompany(company: AdminCompany) {
setSelectedCompany(company)
onChange(String(company.id))
setOpen(false)
setKeyword("")
}
function handleClear() {
setSelectedCompany(null)
onChange("0")
setOpen(false)
setKeyword("")
}
async function handleCreateCompany(payload: CreateAdminCompanyPayload) {
setCreateSaving(true)
try {
const created = await createCompany(payload)
setSelectedCompany(created)
onChange(String(created.id))
setCreateOpen(false)
setOpen(false)
setKeyword("")
toast.success(`已创建公司:${created.name}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建公司失败")
throw error
} finally {
setCreateSaving(false)
}
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
disabled={disabled}
/>
}
>
<span className={cn("truncate", Number(value) > 0 ? "text-foreground" : "text-muted-foreground")}>
{buttonLabel}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
value={keyword}
onValueChange={setKeyword}
placeholder="搜索公司名称"
/>
<CommandList>
{loading ? <CommandEmpty>...</CommandEmpty> : null}
{!loading && options.length === 0 ? <CommandEmpty></CommandEmpty> : null}
{!loading ? (
<CommandGroup heading="搜索结果">
<CommandItem
value="none"
data-checked={Number(value) <= 0}
onSelect={handleClear}
>
<span></span>
</CommandItem>
{options.map((item) => (
<CommandItem
key={item.id}
value={`${item.name} ${item.code}`}
data-checked={item.id === Number(value)}
onSelect={() => handleSelectCompany(item)}
>
<div className="flex min-w-0 flex-col">
<span className="truncate">{item.name}</span>
{item.code ? (
<span className="truncate text-xs text-muted-foreground">{item.code}</span>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{canCreate ? (
<>
<CommandSeparator />
<CommandGroup heading="操作">
<CommandItem
value={`create ${trimmedKeyword}`}
onSelect={() => setCreateOpen(true)}
>
<PlusIcon className="size-4" />
<span className="truncate">{trimmedKeyword}</span>
</CommandItem>
</CommandGroup>
</>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<CompanyEditDialog
open={createOpen}
saving={createSaving}
itemId={null}
initialValues={{ name: trimmedKeyword }}
onOpenChange={setCreateOpen}
onSubmit={handleCreateCompany}
/>
</>
)
}
+124
View File
@@ -0,0 +1,124 @@
"use client"
import {
createContext,
useCallback,
useContext,
useRef,
useState,
type ReactNode,
} from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
type ConfirmOptions = {
title?: ReactNode
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: "default" | "destructive"
}
type ConfirmContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>
}
type ConfirmState = ConfirmOptions & {
open: boolean
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
const defaultState: ConfirmState = {
open: false,
title: "请确认操作",
description: "确认后将继续执行当前操作。",
confirmText: "确认",
cancelText: "取消",
variant: "default",
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ConfirmState>(defaultState)
const resolverRef = useRef<((value: boolean) => void) | null>(null)
const close = useCallback((result: boolean) => {
resolverRef.current?.(result)
resolverRef.current = null
setState((current) => ({ ...current, open: false }))
}, [])
const confirm = useCallback((options: ConfirmOptions) => {
if (resolverRef.current) {
resolverRef.current(false)
}
setState({
open: true,
title: options.title ?? defaultState.title,
description: options.description ?? defaultState.description,
confirmText: options.confirmText ?? defaultState.confirmText,
cancelText: options.cancelText ?? defaultState.cancelText,
variant: options.variant ?? defaultState.variant,
})
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve
})
}, [])
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<Dialog
open={state.open}
onOpenChange={(open) => {
if (!open) {
close(false)
}
}}
>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>{state.title}</DialogTitle>
<DialogDescription>{state.description}</DialogDescription>
</DialogHeader>
<DialogFooter className="p-2">
<Button
type="button"
variant="outline"
onClick={() => close(false)}
>
{state.cancelText}
</Button>
<Button
type="button"
variant={state.variant}
onClick={() => close(true)}
>
{state.confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmContext.Provider>
)
}
export function useConfirm() {
const ctx = useContext(ConfirmContext)
if (!ctx) {
throw new Error("useConfirm must be used within ConfirmProvider")
}
return ctx.confirm
}
export type { ConfirmOptions }
+26
View File
@@ -0,0 +1,26 @@
import MarkdownIt from "markdown-it"
import TurndownService from "turndown"
const markdownIt = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*",
strongDelimiter: "**",
})
turndownService.keep(["table", "thead", "tbody", "tr", "th", "td"])
export function markdownToHtml(markdown: string) {
return markdownIt.render(markdown ?? "")
}
export function htmlToMarkdown(html: string) {
return turndownService.turndown(html ?? "")
}
@@ -0,0 +1,49 @@
"use client"
import { cn } from "@/lib/utils"
import type { ContentMode } from "./types"
type EditorModeSwitchProps = {
value: ContentMode
disabled?: boolean
onChange: (nextMode: ContentMode) => void
}
const MODE_OPTIONS: Array<{ value: ContentMode; label: string }> = [
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]
export function EditorModeSwitch({
value,
disabled = false,
onChange,
}: EditorModeSwitchProps) {
return (
<div className="mx-0.5 rounded-[3px] border border-border/80 bg-transparent p-0">
<div className="flex items-center">
{MODE_OPTIONS.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onChange(option.value)}
className={cn(
"px-1.5 py-0 text-[12px] leading-6 whitespace-nowrap transition-colors",
"hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-[#333]",
active
? "bg-[#f2f2f2] text-[#3f4a54] dark:bg-[#333] dark:text-[#999]"
: "bg-transparent text-[#3f4a54] dark:text-[#999]"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,362 @@
"use client"
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
type ChangeEvent,
} from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import Image from "@tiptap/extension-image"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import StarterKit from "@tiptap/starter-kit"
import Underline from "@tiptap/extension-underline"
import {
BoldIcon,
Code2Icon,
Heading1Icon,
Heading2Icon,
ImageIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
RedoIcon,
RotateCcwIcon,
StrikethroughIcon,
EyeIcon,
Maximize2Icon,
Minimize2Icon,
UnderlineIcon,
} from "lucide-react"
import { EditorModeSwitch } from "./editor-mode-switch"
import { EditorToolbar } from "./toolbar"
import type { ContentMode, EditorToolbarAction, UploadImageHandler } from "./types"
export type HtmlEditorRef = {
focus: () => void
}
type HtmlEditorProps = {
value: string
onChange: (nextValue: string) => void
mode: ContentMode
onModeChange: (nextMode: ContentMode) => void
fullscreen: boolean
onToggleFullscreen: () => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height: string
}
export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
function HtmlEditor(
{
value,
onChange,
mode,
onModeChange,
fullscreen,
onToggleFullscreen,
placeholder = "",
disabled = false,
onUploadImage,
height,
},
ref
) {
const imageInputRef = useRef<HTMLInputElement>(null)
const [previewOnly, setPreviewOnly] = useState(false)
const proseClassName =
"h-full overflow-y-auto px-4 py-3 text-sm leading-7 text-foreground outline-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p]:mb-2 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:mb-1 [&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_img]:my-2 [&_img]:max-h-80 [&_img]:rounded-md [&_img]:object-contain [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2],
},
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Image,
Link.configure({
openOnClick: false,
autolink: true,
}),
Underline,
Placeholder.configure({
placeholder,
}),
],
content: value,
editable: !disabled,
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getHTML())
},
editorProps: {
attributes: {
class: proseClassName,
},
},
})
useImperativeHandle(ref, () => ({
focus() {
editor?.commands.focus()
},
}), [editor])
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
}
}, [editor, value])
useEffect(() => {
if (editor) {
editor.setEditable(!disabled)
}
}, [disabled, editor])
const handleTogglePreviewOnly = () => {
setPreviewOnly((current: boolean) => !current)
}
const handleInsertLink = () => {
if (!editor || disabled) {
return
}
const previousUrl = editor.getAttributes("link").href as string | undefined
const url = window.prompt("输入链接地址", previousUrl || "https://")
if (url === null) {
return
}
if (!url.trim()) {
editor.chain().focus().unsetLink().run()
return
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run()
}
const handleSelectImage = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || !onUploadImage || disabled) {
return
}
const uploaded = await onUploadImage(file)
if (!uploaded?.url) {
return
}
editor
.chain()
.focus()
.setImage({
src: uploaded.url,
alt: uploaded.alt || file.name || "image",
title: uploaded.title || "",
})
.run()
}
const actions: EditorToolbarAction[] = [
{
key: "mode-switch",
type: "custom",
content: (
<EditorModeSwitch
value={mode}
disabled={disabled}
onChange={onModeChange}
/>
),
},
{ key: "separator-mode", type: "separator" },
{
key: "bold",
label: "粗体",
icon: BoldIcon,
disabled,
pressed: !!editor?.isActive("bold"),
onClick: () => editor?.chain().focus().toggleBold().run(),
},
{
key: "underline",
label: "下划线",
icon: UnderlineIcon,
disabled,
pressed: !!editor?.isActive("underline"),
onClick: () => editor?.chain().focus().toggleUnderline().run(),
},
{
key: "italic",
label: "斜体",
icon: ItalicIcon,
disabled,
pressed: !!editor?.isActive("italic"),
onClick: () => editor?.chain().focus().toggleItalic().run(),
},
{
key: "strike",
label: "删除线",
icon: StrikethroughIcon,
disabled,
pressed: !!editor?.isActive("strike"),
onClick: () => editor?.chain().focus().toggleStrike().run(),
},
{ key: "separator-1", type: "separator" },
{
key: "h1",
label: "一级标题",
icon: Heading1Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 1 }),
onClick: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
key: "h2",
label: "二级标题",
icon: Heading2Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 2 }),
onClick: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
key: "quote",
label: "引用",
icon: QuoteIcon,
disabled,
pressed: !!editor?.isActive("blockquote"),
onClick: () => editor?.chain().focus().toggleBlockquote().run(),
},
{
key: "bullet-list",
label: "无序列表",
icon: ListIcon,
disabled,
pressed: !!editor?.isActive("bulletList"),
onClick: () => editor?.chain().focus().toggleBulletList().run(),
},
{
key: "ordered-list",
label: "有序列表",
icon: ListOrderedIcon,
disabled,
pressed: !!editor?.isActive("orderedList"),
onClick: () => editor?.chain().focus().toggleOrderedList().run(),
},
{ key: "separator-2", type: "separator" },
{
key: "code",
label: "行内代码",
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("code"),
onClick: () => editor?.chain().focus().toggleCode().run(),
},
{
key: "code-block",
label: "代码块",
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("codeBlock"),
onClick: () => editor?.chain().focus().toggleCodeBlock().run(),
},
{ key: "separator-3", type: "separator" },
{
key: "link",
label: "链接",
icon: LinkIcon,
disabled,
pressed: !!editor?.isActive("link"),
onClick: handleInsertLink,
},
{
key: "image",
label: "图片",
icon: ImageIcon,
disabled: disabled || !onUploadImage,
onClick: () => imageInputRef.current?.click(),
},
{ key: "separator-4", type: "separator" },
{
key: "undo-tail",
label: "撤销",
icon: RotateCcwIcon,
disabled: disabled || !editor?.can().undo(),
onClick: () => editor?.chain().focus().undo().run(),
},
{
key: "redo-tail",
label: "重做",
icon: RedoIcon,
disabled: disabled || !editor?.can().redo(),
onClick: () => editor?.chain().focus().redo().run(),
},
{ key: "separator-fullscreen", type: "separator" },
{
key: "fullscreen",
label: fullscreen ? "退出全屏" : "全屏",
icon: fullscreen ? Minimize2Icon : Maximize2Icon,
disabled,
pressed: fullscreen,
onClick: onToggleFullscreen,
},
{ key: "separator-preview", type: "separator" },
{
key: "preview-only",
label: "仅预览",
icon: EyeIcon,
disabled,
pressed: previewOnly,
onClick: handleTogglePreviewOnly,
},
]
if (!editor) {
return null
}
return (
<div
className="flex w-full flex-col rounded-lg border bg-background"
style={{ height }}
>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleSelectImage(event)
}}
/>
<EditorToolbar actions={actions} />
<div className="min-h-0 flex-1 p-2">
{previewOnly ? (
<div
className={proseClassName}
dangerouslySetInnerHTML={{ __html: value }}
/>
) : (
<EditorContent editor={editor} className="h-full" />
)}
</div>
</div>
)
}
)
+150
View File
@@ -0,0 +1,150 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import { htmlToMarkdown, markdownToHtml } from "./convert"
import { HtmlEditor } from "./html-editor"
import { MarkdownEditor } from "./markdown-editor"
import type { ContentMode, ContentValue, UploadImageHandler } from "./types"
type ContentEditorProps = {
value: ContentValue
onChange: (next: ContentValue) => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height?: number | string
}
function normalizeHeight(height?: number | string) {
if (typeof height === "number") {
return `${height}px`
}
if (typeof height === "string" && height.trim()) {
return height
}
return "400px"
}
function getModeLabel(mode: ContentMode) {
return mode === "markdown" ? "Markdown" : "HTML"
}
function convertContent(mode: ContentMode, raw: string) {
if (mode === "markdown") {
return markdownToHtml(raw)
}
return htmlToMarkdown(raw)
}
export function ContentEditor({
value,
onChange,
placeholder,
disabled = false,
onUploadImage,
height,
}: ContentEditorProps) {
const editorHeight = normalizeHeight(height)
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!fullscreen) {
return
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener("keydown", handleKeyDown)
}
}, [fullscreen])
const handleModeChange = useCallback(
(nextMode: ContentMode) => {
if (disabled || nextMode === value.mode) {
return
}
const currentText = value.raw.trim()
if (!currentText) {
onChange({ mode: nextMode, raw: "" })
return
}
const confirmed = window.confirm(
`切换到 ${getModeLabel(nextMode)} 模式会尝试自动转换内容,复杂格式可能有损。是否继续?`
)
if (!confirmed) {
return
}
onChange({
mode: nextMode,
raw: convertContent(value.mode, value.raw),
})
},
[disabled, onChange, value.mode, value.raw]
)
const content = (
<div
className={cn(
"w-full",
fullscreen && "fixed inset-0 z-[10000] overflow-hidden bg-background p-4"
)}
>
{value.mode === "markdown" ? (
<MarkdownEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "markdown", raw: nextRaw })}
mode={value.mode}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
/>
) : (
<HtmlEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "html", raw: nextRaw })}
mode={value.mode}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
/>
)}
</div>
)
if (fullscreen && mounted) {
return createPortal(content, document.body)
}
return content
}
export type { ContentMode, ContentValue, UploadImageHandler, UploadImageResult } from "./types"
@@ -0,0 +1,48 @@
.content-editor-markdown {
height: 100%;
}
.content-editor-markdown .md-editor {
height: 100%;
border: 0;
box-shadow: none;
background: transparent;
}
.content-editor-markdown .md-editor-footer {
display: none;
}
.content-editor-markdown .md-editor-toolbar {
border-bottom: 0;
}
.content-editor-markdown .md-editor-content {
height: calc(100% - 37px);
}
.content-editor-markdown .md-editor-content-wrapper,
.content-editor-markdown .md-editor-input-wrapper {
height: 100%;
}
.content-editor-markdown .md-editor-input-wrapper {
border-right: 0;
}
.content-editor-markdown .cm-editor {
height: 100%;
background: transparent;
}
.content-editor-markdown .cm-scroller {
height: 100%;
overflow: auto;
}
.content-editor-markdown .cm-content,
.content-editor-markdown .cm-line {
font-family: var(--font-geist-mono), monospace;
font-size: 0.875rem;
line-height: 1.75;
}
@@ -0,0 +1,149 @@
"use client"
import {
forwardRef,
useId,
useImperativeHandle,
useMemo,
useRef,
} from "react"
import { Maximize2Icon, Minimize2Icon } from "lucide-react"
import { MdEditor, NormalToolbar, type ExposeParam } from "md-editor-rt"
import { useTheme } from "next-themes"
import "./markdown-editor.css"
import { EditorModeSwitch } from "./editor-mode-switch"
import type { ContentMode, UploadImageHandler } from "./types"
export type MarkdownEditorRef = {
focus: () => void
}
type MarkdownEditorProps = {
value: string
onChange: (nextValue: string) => void
mode: ContentMode
onModeChange: (nextMode: ContentMode) => void
fullscreen: boolean
onToggleFullscreen: () => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height: string
}
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
function MarkdownEditor(
{
value,
onChange,
mode,
onModeChange,
fullscreen,
onToggleFullscreen,
placeholder = "",
disabled = false,
onUploadImage,
height,
},
ref
) {
const editorId = useId()
const editorRef = useRef<ExposeParam>(null)
const { resolvedTheme } = useTheme()
const defToolbars = useMemo(
() => [
<EditorModeSwitch
key="mode-switch"
value={mode}
disabled={disabled}
onChange={onModeChange}
/>,
<NormalToolbar
key="toggle-fullscreen"
title={fullscreen ? "退出全屏" : "全屏"}
disabled={disabled}
onClick={onToggleFullscreen}
>
{fullscreen ? (
<Minimize2Icon className="h-[16px] w-[16px]" />
) : (
<Maximize2Icon className="h-[16px] w-[16px]" />
)}
</NormalToolbar>,
],
[disabled, fullscreen, mode, onModeChange, onToggleFullscreen]
)
useImperativeHandle(ref, () => ({
focus() {
editorRef.current?.focus()
},
}))
return (
<div
className="w-full rounded-lg border bg-background"
style={{ height }}
>
<div className="content-editor-markdown h-full">
<MdEditor
ref={editorRef}
id={editorId}
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? "dark" : "light"}
preview={false}
toolbars={[
0,
"-",
"bold",
"underline",
"italic",
"strikeThrough",
"-",
"title",
"quote",
"unorderedList",
"orderedList",
"-",
"codeRow",
"code",
"link",
"image",
"-",
"revoke",
"next",
1,
"=",
"preview",
"previewOnly",
]}
defToolbars={defToolbars}
footers={[]}
noMermaid
noKatex
noHighlight
placeholder={placeholder}
disabled={disabled}
style={{ height: "100%" }}
onUploadImg={
onUploadImage
? async (files, callback) => {
const uploadedUrls: string[] = []
for (const file of files) {
const uploaded = await onUploadImage(file)
if (uploaded?.url) {
uploadedUrls.push(uploaded.url)
}
}
callback(uploadedUrls)
}
: undefined
}
/>
</div>
</div>
)
}
)
+12
View File
@@ -0,0 +1,12 @@
.content-editor-toolbar {
scrollbar-width: none;
cursor: grab;
}
.content-editor-toolbar::-webkit-scrollbar {
height: 0 !important;
}
.content-editor-toolbar:active {
cursor: grabbing;
}
+142
View File
@@ -0,0 +1,142 @@
"use client"
import "./toolbar.css"
import { useRef, type PointerEvent as ReactPointerEvent } from "react"
import { cn } from "@/lib/utils"
import type { EditorToolbarAction } from "./types"
type EditorToolbarProps = {
actions: ReadonlyArray<EditorToolbarAction>
}
function isSeparatorAction(
action: EditorToolbarAction
): action is Extract<EditorToolbarAction, { type: "separator" }> {
return "type" in action && action.type === "separator"
}
function isCustomAction(
action: EditorToolbarAction
): action is Extract<EditorToolbarAction, { type: "custom" }> {
return "type" in action && action.type === "custom"
}
export function EditorToolbar({ actions }: EditorToolbarProps) {
const containerRef = useRef<HTMLDivElement>(null)
const dragStateRef = useRef<{
pointerId: number
startX: number
startScrollLeft: number
moved: boolean
} | null>(null)
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return
}
const container = containerRef.current
if (!container || container.scrollWidth <= container.clientWidth) {
return
}
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: container.scrollLeft,
moved: false,
}
container.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
const dragState = dragStateRef.current
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const deltaX = event.clientX - dragState.startX
if (Math.abs(deltaX) > 3) {
dragState.moved = true
}
container.scrollLeft = dragState.startScrollLeft - deltaX
}
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
const dragState = dragStateRef.current
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
return
}
if (container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId)
}
window.setTimeout(() => {
dragStateRef.current = null
}, 0)
}
return (
<div
ref={containerRef}
className="content-editor-toolbar overflow-x-auto overflow-y-hidden border-b border-border px-1 py-1 h-9.25"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
>
<div className="flex min-w-max items-center justify-between">
<div className="flex items-center">
{actions.map((action) => {
if (isSeparatorAction(action)) {
return (
<span
key={action.key}
aria-hidden="true"
className="relative mx-2 inline-block h-[0.9em] w-px self-center bg-border"
/>
)
}
if (isCustomAction(action)) {
return <div key={action.key}>{action.content}</div>
}
const Icon = action.icon
return (
<button
key={action.key}
type="button"
aria-label={action.label}
title={action.label}
disabled={action.disabled}
onClick={action.onClick}
onClickCapture={(event) => {
if (dragStateRef.current?.moved) {
event.preventDefault()
event.stopPropagation()
}
}}
data-pressed={action.pressed ? "true" : "false"}
className={cn(
"mx-[2px] cursor-pointer list-none rounded-[3px] border-none bg-transparent text-[#3f4a54] transition-all duration-300 select-none hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 data-[pressed=true]:bg-[#f2f2f2] dark:text-[#999] dark:hover:bg-[#333] dark:data-[pressed=true]:bg-[#333]",
action.icon && !action.content
? "flex flex-col items-center px-[2px] py-0"
: "flex flex-col items-center px-[6px] py-0"
)}
>
{Icon ? <Icon className="box-content size-4 p-1" /> : null}
{action.content ? (
<span className="text-[12px] leading-none whitespace-nowrap">
{action.content}
</span>
) : null}
</button>
)
})}
</div>
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import type { ComponentType, ReactNode } from "react"
export type ContentMode = "markdown" | "html"
export type ContentValue = {
mode: ContentMode
raw: string
}
export type UploadImageResult = {
url: string
alt?: string
title?: string
}
export type UploadImageHandler = (file: File) => Promise<UploadImageResult | null>
export type EditorToolbarButtonAction = {
key: string
label: string
icon?: ComponentType<{ className?: string }>
content?: ReactNode
onClick: () => void
disabled?: boolean
pressed?: boolean
}
export type EditorToolbarSeparatorAction = {
key: string
type: "separator"
}
export type EditorToolbarCustomAction = {
key: string
type: "custom"
content: ReactNode
}
export type EditorToolbarAction =
| EditorToolbarButtonAction
| EditorToolbarSeparatorAction
| EditorToolbarCustomAction
@@ -0,0 +1,163 @@
"use client"
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { CircleXIcon } from "lucide-react"
import { toast } from "sonner"
import { closeConversation } from "@/lib/api/admin"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
type ConversationCloseDialogProps = {
open: boolean
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
const closeSchema = z.object({
closeReason: z.string().trim().min(1, "请输入关闭原因"),
})
type CloseForm = z.infer<typeof closeSchema>
const closeResolver = zodResolver(closeSchema as never) as Resolver<
z.input<typeof closeSchema>,
undefined,
z.output<typeof closeSchema>
>
const emptyForm: CloseForm = {
closeReason: "",
}
export function ConversationCloseDialog({
open,
conversationId,
onOpenChange,
onSuccess,
}: ConversationCloseDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ConversationCloseDialogBody
key={conversationId ? `close-${conversationId}` : "close"}
conversationId={conversationId}
onOpenChange={onOpenChange}
onSuccess={onSuccess}
/>
) : null}
</Dialog>
)
}
type ConversationCloseDialogBodyProps = {
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
function ConversationCloseDialogBody({
conversationId,
onOpenChange,
onSuccess,
}: ConversationCloseDialogBodyProps) {
const [saving, setSaving] = useState(false)
const form = useForm<
z.input<typeof closeSchema>,
undefined,
z.output<typeof closeSchema>
>({
resolver: closeResolver,
defaultValues: emptyForm,
})
const {
handleSubmit,
reset,
register,
formState: { errors },
} = form
useEffect(() => {
reset(emptyForm)
}, [conversationId, reset])
async function onFormSubmit(values: CloseForm) {
if (!conversationId) {
toast.error("会话不存在")
return
}
setSaving(true)
try {
await closeConversation(conversationId, values.closeReason.trim())
toast.success(`已关闭会话:#${conversationId}`)
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "关闭会话失败")
} finally {
setSaving(false)
}
}
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle></DialogTitle>
{/* <DialogDescription>
当前会话:{conversationId ? `#${conversationId}` : "-"}
</DialogDescription> */}
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.closeReason}>
<FieldLabel htmlFor="conversation-close-reason"></FieldLabel>
<FieldContent>
<Textarea
id="conversation-close-reason"
rows={4}
placeholder="填写关闭原因,关闭后会写入操作记录"
aria-invalid={!!errors.closeReason}
{...register("closeReason")}
/>
<FieldError errors={[errors.closeReason]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" disabled={saving}>
<CircleXIcon />
{saving ? "关闭中..." : "确认关闭"}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
@@ -0,0 +1,230 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowRightLeftIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import {
assignConversation,
transferConversation,
fetchAgentProfilesAll,
type AdminAgentProfile,
} from "@/lib/api/admin"
type ConversationTransferDialogProps = {
open: boolean
mode: "assign" | "transfer"
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
const transferSchema = z.object({
toUserId: z.string().trim().min(1, "请选择目标客服"),
reason: z.string().trim(),
})
type TransferForm = z.infer<typeof transferSchema>
const emptyForm: TransferForm = {
toUserId: "",
reason: "",
}
const transferResolver = zodResolver(transferSchema as never) as Resolver<
z.input<typeof transferSchema>,
undefined,
z.output<typeof transferSchema>
>
export function ConversationTransferDialog({
open,
mode,
conversationId,
onOpenChange,
onSuccess,
}: ConversationTransferDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ConversationTransferDialogBody
key={conversationId ? `transfer-${conversationId}` : "transfer"}
mode={mode}
conversationId={conversationId}
onOpenChange={onOpenChange}
onSuccess={onSuccess}
/>
) : null}
</Dialog>
)
}
type ConversationTransferDialogBodyProps = {
mode: "assign" | "transfer"
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
function ConversationTransferDialogBody({
mode,
conversationId,
onOpenChange,
onSuccess,
}: ConversationTransferDialogBodyProps) {
const [saving, setSaving] = useState(false)
const [loadingAgents, setLoadingAgents] = useState(false)
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const userOptions = agents.map((agent) => ({
value: String(agent.userId),
label: agent.displayName || agent.nickname || agent.username || `客服 #${agent.userId}`,
}))
const form = useForm<
z.input<typeof transferSchema>,
undefined,
z.output<typeof transferSchema>
>({
resolver: transferResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form
useEffect(() => {
reset(emptyForm)
}, [conversationId, reset])
useEffect(() => {
setLoadingAgents(true)
fetchAgentProfilesAll()
.then((data) => {
setAgents(data.filter((item) => item.serviceStatus === 0))
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "加载客服列表失败")
})
.finally(() => {
setLoadingAgents(false)
})
}, [])
async function onFormSubmit(values: TransferForm) {
if (!conversationId) {
toast.error("会话不存在")
return
}
const toUserId = Number(values.toUserId)
const reason = values.reason.trim()
setSaving(true)
try {
if (mode === "assign") {
await assignConversation(conversationId, toUserId, reason)
toast.success(`已分配会话:#${conversationId}`)
} else {
await transferConversation(conversationId, toUserId, reason)
toast.success(`已转接会话:#${conversationId}`)
}
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : mode === "assign" ? "分配会话失败" : "转接会话失败")
} finally {
setSaving(false)
}
}
const isAssign = mode === "assign"
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{isAssign ? "分配会话" : "转接会话"}</DialogTitle>
{/* <DialogDescription>
当前会话:{conversationId ? `#${conversationId}` : "-"}
</DialogDescription> */}
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}>
<FieldLabel htmlFor="conversation-transfer-user"></FieldLabel>
<FieldContent>
<Controller
control={control}
name="toUserId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={userOptions}
placeholder={loadingAgents ? "加载中..." : "选择目标客服"}
searchPlaceholder="搜索客服"
emptyText="暂无可选客服"
disabled={saving || loadingAgents}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.toUserId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel htmlFor="conversation-transfer-reason">
{isAssign ? "分配说明" : "转接原因"}
</FieldLabel>
<FieldContent>
<Textarea
id="conversation-transfer-reason"
rows={4}
placeholder={isAssign ? "填写分配说明,便于后续追踪" : "填写转接原因,便于后续追踪"}
aria-invalid={!!errors.reason}
{...register("reason")}
/>
<FieldError errors={[errors.reason]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button type="submit" disabled={saving}>
<ArrowRightLeftIcon />
{saving ? (isAssign ? "分配中..." : "转接中...") : isAssign ? "确认分配" : "确认转接"}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
+88
View File
@@ -0,0 +1,88 @@
"use client"
import { useState } from "react"
import {
CustomerForm,
type CustomerFormSavePayload,
} from "@/components/customer-form"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
export type CustomerFormDialogProps = {
open: boolean
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSave: (payload: CustomerFormSavePayload) => Promise<void>
}
/** 客户新建/编辑表单弹窗(ProjectDialog + CustomerForm),供客户管理页与会话工作台等复用。 */
export function CustomerFormDialog({
open,
saving,
itemId,
onOpenChange,
onSave,
}: CustomerFormDialogProps) {
if (!open) return null
return (
<CustomerFormDialogBody
key={itemId ? `edit-${itemId}` : "create"}
saving={saving}
itemId={itemId}
onOpenChange={onOpenChange}
onSave={onSave}
/>
)
}
type CustomerFormDialogBodyProps = Omit<CustomerFormDialogProps, "open">
function CustomerFormDialogBody({
saving,
itemId,
onOpenChange,
onSave,
}: CustomerFormDialogBodyProps) {
const formId = "customer-form-dialog"
const [loadingDetail, setLoadingDetail] = useState(() => Boolean(itemId))
return (
<ProjectDialog
open
onOpenChange={(next) => onOpenChange(next)}
title={itemId ? "编辑客户" : "新建客户"}
allowFullscreen
size="xl"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button
type="submit"
form={formId}
disabled={saving || loadingDetail}
>
{saving ? "保存中..." : itemId ? "保存" : "创建"}
</Button>
</>
}
>
<CustomerForm
formId={formId}
itemId={itemId}
onSave={onSave}
fieldIdPrefix="customer"
className="space-y-4"
onLoadingDetailChange={setLoadingDetail}
/>
</ProjectDialog>
)
}
+498
View File
@@ -0,0 +1,498 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Controller,
useFieldArray,
useForm,
type Resolver,
type UseFormReturn,
} from "react-hook-form"
import { PlusIcon, Trash2Icon } from "lucide-react"
import { z } from "zod/v4"
import { CompanyPicker } from "@/components/company-picker"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { fetchCustomerContacts, type AdminCustomerContact } from "@/lib/api/customer-contact"
import {
fetchCustomer,
type AdminCustomer,
type SaveCustomerProfilePayload,
} from "@/lib/api/customer"
import { getEnumLabel, getEnumOptions } from "@/lib/enums"
import { ContactType, ContactTypeLabels, Gender, GenderLabels } from "@/lib/generated/enums"
const genderOptions = [
...getEnumOptions(GenderLabels).map((item) => ({
value: String(item.value),
label: item.label,
})),
] as const
const genderValueOptions = [
String(Gender.Unknown),
String(Gender.Male),
String(Gender.Female),
] as const
const contactTypeValues = [
ContactType.Mobile,
ContactType.Email,
ContactType.Other,
] as const
const contactRowSchema = z.object({
id: z.number().optional(),
contactType: z.enum(contactTypeValues),
contactValue: z.string(),
remark: z.string(),
isPrimary: z.boolean(),
})
const customerFormSchema = z.object({
name: z.string().trim().min(1, "客户名称不能为空"),
gender: z.enum(genderValueOptions, { message: "请选择性别" }),
companyId: z.string().trim().regex(/^\d+$/, "请选择所属公司"),
remark: z.string().trim(),
contacts: z.array(contactRowSchema),
})
export type CustomerFormValues = z.infer<typeof customerFormSchema>
export type CustomerContactFormRow = {
id?: number
contactType: (typeof contactTypeValues)[number]
contactValue: string
remark: string
isPrimary: boolean
}
const customerFormResolver = zodResolver(customerFormSchema as never) as Resolver<
z.input<typeof customerFormSchema>,
undefined,
z.output<typeof customerFormSchema>
>
function defaultContactRow(isPrimary: boolean): CustomerContactFormRow {
return {
contactType: ContactType.Mobile,
contactValue: "",
remark: "",
isPrimary,
}
}
const emptyCustomerForm: CustomerFormValues = {
name: "",
gender: "0",
companyId: "0",
remark: "",
contacts: [defaultContactRow(true)],
}
function buildCustomerMainFromAdmin(item: AdminCustomer | null): Omit<CustomerFormValues, "contacts"> {
if (!item) {
return {
name: "",
gender: "0",
companyId: "0",
remark: "",
}
}
return {
name: item.name,
gender: String(item.gender) as "0" | "1" | "2",
companyId: String(item.companyId ?? 0),
remark: item.remark ?? "",
}
}
function buildContactsFromApi(list: AdminCustomerContact[]): CustomerContactFormRow[] {
if (list.length === 0) {
return [defaultContactRow(true)]
}
return list.map((c) => ({
id: c.id,
contactType: c.contactType as CustomerContactFormRow["contactType"],
contactValue: c.contactValue ?? "",
remark: c.remark ?? "",
isPrimary: c.isPrimary,
}))
}
/** 过滤空行并保证至多一条主联系方式(有一条有值时至少一条主) */
export function normalizeContactsForSubmit(rows: CustomerContactFormRow[]): CustomerContactFormRow[] {
const withValue = rows.filter((r) => r.contactValue.trim() !== "")
if (withValue.length === 0) {
return []
}
const primaryIdx = withValue.findIndex((r) => r.isPrimary)
if (primaryIdx < 0) {
return withValue.map((r, i) => ({ ...r, isPrimary: i === 0 }))
}
return withValue.map((r, i) => ({
...r,
isPrimary: i === primaryIdx,
}))
}
export type CustomerFormSavePayload = SaveCustomerProfilePayload
function getGenderLabel(value: string) {
return getEnumLabel(GenderLabels, Number(value) as Gender)
}
function getContactTypeLabel(value: string) {
return ContactTypeLabels[value as ContactType] ?? value
}
type CustomerFormFieldsProps = {
form: UseFormReturn<CustomerFormValues>
fieldIdPrefix?: string
remarkRows?: number
}
function CustomerFormFields({
form,
fieldIdPrefix = "customer",
remarkRows = 4,
}: CustomerFormFieldsProps) {
const {
control,
register,
formState: { errors },
watch,
setValue,
getValues,
} = form
const { fields, append, remove } = useFieldArray({ control, name: "contacts" })
const id = (suffix: string) => `${fieldIdPrefix}-${suffix}`
function setPrimaryIndex(index: number) {
fields.forEach((_, i) => {
setValue(`contacts.${i}.isPrimary`, i === index)
})
}
function addContactRow() {
append(defaultContactRow(fields.length === 0))
}
function removeContactRow(index: number) {
const wasPrimary = watch(`contacts.${index}.isPrimary`)
remove(index)
if (wasPrimary) {
requestAnimationFrame(() => {
const list = getValues("contacts")
if (list.length > 0) {
list.forEach((_, i) => setValue(`contacts.${i}.isPrimary`, i === 0))
}
})
}
}
return (
<div className="space-y-8">
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground"></h3>
<div className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor={id("name")}></FieldLabel>
<FieldContent>
<Input
id={id("name")}
placeholder="请输入客户名称"
aria-invalid={!!errors.name}
autoComplete="off"
{...register("name")}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.gender}>
<FieldLabel htmlFor={id("gender")}></FieldLabel>
<FieldContent>
<Controller
control={control}
name="gender"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} modal={false}>
<SelectTrigger id={id("gender")}>
<SelectValue>{getGenderLabel(field.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{genderOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.gender]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.companyId}>
<FieldLabel htmlFor={id("company")}></FieldLabel>
<FieldContent>
<Controller
control={control}
name="companyId"
render={({ field }) => (
<CompanyPicker
value={field.value}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.companyId]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.remark}>
<FieldLabel htmlFor={id("remark")}></FieldLabel>
<FieldContent>
<Textarea
id={id("remark")}
placeholder="可选"
rows={remarkRows}
aria-invalid={!!errors.remark}
{...register("remark")}
/>
<FieldError errors={[errors.remark]} />
</FieldContent>
</Field>
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground"></h3>
<div className="hidden gap-2 border-b border-border pb-2 text-xs font-medium text-muted-foreground sm:grid sm:grid-cols-[108px_minmax(0,1fr)_minmax(0,1fr)_5.5rem_2.25rem] sm:items-center sm:gap-x-2">
<span></span>
<span></span>
<span></span>
<span className="text-center"></span>
<span className="sr-only"></span>
</div>
<div className="space-y-1">
{fields.map((field, index) => {
const err = errors.contacts?.[index]
return (
<div
key={field.id}
className="grid grid-cols-1 gap-2 border-b border-border py-2 last:border-b-0 sm:grid-cols-[108px_minmax(0,1fr)_minmax(0,1fr)_5.5rem_2.25rem] sm:items-center sm:gap-x-2"
>
<div className="min-w-0 space-y-1 sm:space-y-0">
<span className="text-xs text-muted-foreground sm:hidden"></span>
<Controller
control={control}
name={`contacts.${index}.contactType`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange} modal={false}>
<SelectTrigger className="w-full" id={id(`ct-${index}`)}>
<SelectValue>{getContactTypeLabel(f.value)}</SelectValue>
</SelectTrigger>
<SelectContent>
{contactTypeValues.map((v) => (
<SelectItem key={v} value={v}>
{getContactTypeLabel(v)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<Field data-invalid={!!err?.contactValue} className="min-w-0 gap-1 sm:gap-0">
<FieldLabel className="text-xs text-muted-foreground sm:sr-only"></FieldLabel>
<FieldContent>
<Input
placeholder={
watch(`contacts.${index}.contactType`) === ContactType.Email
? "邮箱"
: "号码 / 账号"
}
aria-invalid={!!err?.contactValue}
{...register(`contacts.${index}.contactValue`)}
/>
<FieldError errors={[err?.contactValue]} />
</FieldContent>
</Field>
<Field className="min-w-0 gap-1 sm:gap-0">
<FieldLabel htmlFor={id(`tag-${index}`)} className="text-xs text-muted-foreground sm:sr-only">
</FieldLabel>
<FieldContent>
<Input
id={id(`tag-${index}`)}
placeholder="可选"
{...register(`contacts.${index}.remark`)}
/>
</FieldContent>
</Field>
<div className="flex items-center justify-start gap-2 sm:justify-center">
<span className="text-xs text-muted-foreground sm:hidden"></span>
<input
type="radio"
className="size-4 shrink-0 accent-primary"
name={id("primary-group")}
checked={watch(`contacts.${index}.isPrimary`)}
onChange={() => setPrimaryIndex(index)}
id={id(`primary-${index}`)}
aria-label="设为主联系方式"
/>
<label htmlFor={id(`primary-${index}`)} className="hidden cursor-pointer text-sm sm:inline">
</label>
</div>
<div className="flex justify-end sm:justify-center">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => removeContactRow(index)}
aria-label="删除此条联系方式"
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
)
})}
</div>
<Button type="button" variant="outline" size="sm" className="gap-1" onClick={addContactRow}>
<PlusIcon className="size-4" />
</Button>
</div>
</div>
)
}
export type CustomerFormProps = {
formId: string
onSave: (payload: CustomerFormSavePayload) => Promise<void> | void
itemId?: number | null
fieldIdPrefix?: string
remarkRows?: number
className?: string
onLoadingDetailChange?: (loading: boolean) => void
}
export function CustomerForm({
formId,
onSave,
itemId,
fieldIdPrefix = "customer",
remarkRows = 4,
className,
onLoadingDetailChange,
}: CustomerFormProps) {
const [loadingDetail, setLoadingDetail] = useState(() => Boolean(itemId))
const form = useForm<CustomerFormValues>({
resolver: customerFormResolver,
defaultValues: emptyCustomerForm,
})
const { handleSubmit, reset } = form
const onLoadingDetailChangeRef = useRef(onLoadingDetailChange)
onLoadingDetailChangeRef.current = onLoadingDetailChange
useEffect(() => {
async function loadDetail() {
const notify = (loading: boolean) => {
onLoadingDetailChangeRef.current?.(loading)
}
if (!itemId) {
setLoadingDetail(false)
notify(false)
reset(emptyCustomerForm)
return
}
setLoadingDetail(true)
notify(true)
try {
const [customer, contacts] = await Promise.all([
fetchCustomer(itemId),
fetchCustomerContacts(itemId),
])
reset({
...buildCustomerMainFromAdmin(customer),
contacts: buildContactsFromApi(contacts),
})
} finally {
setLoadingDetail(false)
notify(false)
}
}
void loadDetail()
}, [itemId, reset])
async function onFormSubmit(values: CustomerFormValues) {
const contacts = normalizeContactsForSubmit(values.contacts as CustomerContactFormRow[])
const body: SaveCustomerProfilePayload = {
name: values.name.trim(),
gender: Number(values.gender),
companyId: Number(values.companyId),
remark: values.remark.trim(),
contacts: contacts.map((c) => ({
id: c.id,
contactType: c.contactType,
contactValue: c.contactValue.trim(),
remark: c.remark.trim(),
isPrimary: c.isPrimary,
})),
}
if (itemId) {
body.id = itemId
}
await onSave(body)
}
if (loadingDetail) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">...</div>
</div>
)
}
return (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className={className}>
<CustomerFormFields
form={form}
fieldIdPrefix={fieldIdPrefix}
remarkRows={remarkRows}
/>
</form>
)
}
@@ -0,0 +1,271 @@
"use client"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { CustomerForm, type CustomerFormSavePayload } from "@/components/customer-form"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { linkConversationToCustomer } from "@/lib/api/agent"
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
import { linkTicketToCustomer } from "@/lib/api/ticket"
export type CustomerLinkOrCreateDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** 传入时会话侧:关联已有或新建后绑定该会话 */
conversationId?: number | null
/** 传入时工单侧:关联已有或新建后绑定该工单 */
ticketId?: number | null
/** 绑定成功或仅新建成功后的回调 */
onSuccess?: () => void | Promise<void>
}
const createFormId = "customer-link-or-create-form"
export function CustomerLinkOrCreateDialog({
open,
onOpenChange,
conversationId,
ticketId,
onSuccess,
}: CustomerLinkOrCreateDialogProps) {
const [searchText, setSearchText] = useState("")
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<AdminCustomer[]>([])
const [showCreate, setShowCreate] = useState(false)
const [linkingId, setLinkingId] = useState<number | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
return
}
setSearchText("")
setResults([])
setShowCreate(false)
setLinkingId(null)
}, [open])
const runSearch = async () => {
const q = searchText.trim()
if (!q) {
toast.error("请输入关键词(姓名、手机、邮箱、公司、联系方式等)")
return
}
setSearching(true)
try {
const data = await fetchCustomers({
keyword: q,
page: 1,
limit: 50,
status: 0,
})
setResults(data.results)
if (data.results.length === 0) {
toast.message("未找到匹配客户,可点击下方填写新客户")
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "搜索失败")
} finally {
setSearching(false)
}
}
const handleLinkExisting = async (customer: AdminCustomer) => {
if (!conversationId && !ticketId) {
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
onOpenChange(false)
await onSuccess?.()
return
}
setLinkingId(customer.id)
try {
if (conversationId) {
await linkConversationToCustomer({
conversationId,
customerId: customer.id,
})
} else if (ticketId) {
await linkTicketToCustomer({
ticketId,
customerId: customer.id,
})
}
toast.success("已关联客户")
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : "关联失败")
} finally {
setLinkingId(null)
}
}
const onCreateSave = async (payload: CustomerFormSavePayload) => {
setSaving(true)
try {
const created = await saveCustomerProfile(payload)
if (conversationId) {
await linkConversationToCustomer({
conversationId,
customerId: created.id,
})
toast.success("已创建客户并关联当前会话")
} else if (ticketId) {
await linkTicketToCustomer({
ticketId,
customerId: created.id,
})
toast.success("已创建客户并关联当前工单")
} else {
toast.success("已创建客户")
}
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败")
} finally {
setSaving(false)
}
}
const description = (
<>
{conversationId
? "选中即可关联当前会话。"
: ticketId
? "选中即可关联当前工单。"
: "未接入上下文时仅创建或定位客户。"}
{conversationId
? ",保存后将自动关联会话。"
: ticketId
? ",保存后将自动关联工单。"
: "。"}
</>
)
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => onOpenChange(nextOpen)}
title="关联或创建客户"
description={description}
allowFullscreen
size="xl"
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
{showCreate ? (
<Button type="submit" form={createFormId} disabled={saving}>
{saving
? "提交中…"
: conversationId
? "创建并关联会话"
: ticketId
? "创建并关联工单"
: "创建客户"}
</Button>
) : null}
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Input
placeholder="姓名 / 手机 / 邮箱 / 公司 / 联系方式"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void runSearch();
}
}}
/>
<Button
type="button"
variant="secondary"
disabled={searching}
onClick={() => void runSearch()}
>
{searching ? "搜索中…" : "搜索"}
</Button>
</div>
{results.length > 0 ? (
<ul className="max-h-48 space-y-1.5 overflow-y-auto rounded-md border border-border p-2 text-sm">
{results.map((row) => (
<li
key={row.id}
className="flex items-center justify-between gap-2 rounded border border-transparent px-2 py-1.5 hover:bg-muted/40"
>
<div className="min-w-0">
<div className="truncate font-medium flex items-center gap-2">
<span>{row.name || `客户 #${row.id}`}</span>
<span className="text-muted-foreground">
{row.primaryMobile}
</span>
<span className="text-muted-foreground">
{row.primaryEmail}
</span>
</div>
{row.company?.name ? (
<div className="truncate text-muted-foreground text-xs">
{row.company.name}
</div>
) : null}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0"
disabled={linkingId !== null}
onClick={() => void handleLinkExisting(row)}
>
{linkingId === row.id
? "处理中…"
: conversationId
? "关联"
: ticketId
? "关联"
: "选用"}
</Button>
</li>
))}
</ul>
) : null}
<div className="border-t border-border pt-2">
<button
type="button"
className="text-sm text-primary underline-offset-4 hover:underline"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? "收起新建表单" : "未找到?填写新客户"}
</button>
</div>
{showCreate ? (
<CustomerForm
formId={createFormId}
onSave={onCreateSave}
fieldIdPrefix="link-or-create"
remarkRows={2}
className="flex flex-col gap-3 rounded-lg border border-border bg-muted/10 p-3"
/>
) : null}
</div>
</ProjectDialog>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { ArrowUpRightIcon, Clock3Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type DashboardPlaceholderProps = {
eyebrow: string
title: string
description: string
nextSteps: string[]
}
export function DashboardPlaceholder({
eyebrow,
title,
description,
nextSteps,
}: DashboardPlaceholderProps) {
return (
<div className="flex flex-1 flex-col gap-6 p-4 pt-4 lg:p-6 lg:pt-6">
<Card className="border-dashed">
<CardHeader className="gap-3">
<span className="text-xs font-medium tracking-[0.24em] uppercase text-muted-foreground">
{eyebrow}
</span>
<CardTitle className="text-3xl">{title}</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-6">
{description}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-[1.2fr_0.8fr]">
<div className="rounded-2xl border bg-muted/40 p-5">
<p className="text-sm font-medium"></p>
<div className="mt-4 grid gap-3">
{nextSteps.map((item) => (
<div
key={item}
className="flex items-start gap-3 rounded-xl bg-background p-3"
>
<Clock3Icon className="mt-0.5 size-4 text-muted-foreground" />
<p className="text-sm">{item}</p>
</div>
))}
</div>
</div>
<div className="flex flex-col justify-between rounded-2xl border bg-background p-5">
<div>
<p className="text-sm font-medium"></p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
API
</p>
</div>
<Button variant="outline" className="mt-6 justify-between">
<ArrowUpRightIcon />
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
"use client"
import {
ArrowUpRightIcon,
CircleCheckIcon,
Clock3Icon,
FilterIcon,
} from "lucide-react"
import { formatDateTime } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
type DashboardTask = {
id: number
module: string
owner: string
status: string
progress: string
updatedAt: string
}
export function DataTable({ data }: { data: DashboardTask[] }) {
return (
<Tabs
defaultValue="modules"
className="w-full flex-col justify-start gap-6 px-4 lg:px-6"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl"></CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Input className="w-full md:w-64" placeholder="搜索模块名称" />
<Button variant="outline">
<FilterIcon />
</Button>
</div>
</div>
<TabsList className="w-fit">
<TabsTrigger value="modules"></TabsTrigger>
<TabsTrigger value="milestones"></TabsTrigger>
</TabsList>
<TabsContent value="modules" className="m-0">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Skill
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.module}</TableCell>
<TableCell>{item.owner}</TableCell>
<TableCell>
<Badge variant="outline" className="px-1.5">
{item.status === "已完成" ? (
<CircleCheckIcon className="fill-green-500 text-green-500" />
) : (
<Clock3Icon className="text-amber-500" />
)}
{item.status}
</Badge>
</TableCell>
<TableCell>{item.progress}</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatDateTime(item.updatedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="milestones" className="m-0">
<Card className="border-dashed">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
UI API
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{[
"打通登录、用户、角色、权限列表接口。",
"补充表单弹窗、分页查询与错误处理。",
"接入知识库与渠道模块的实际配置能力。",
].map((item) => (
<div
key={item}
className="flex items-center justify-between rounded-xl border px-4 py-3"
>
<span className="text-sm">{item}</span>
<ArrowUpRightIcon className="size-4 text-muted-foreground" />
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}
+235
View File
@@ -0,0 +1,235 @@
# 统一编辑器设计(TipTap 单内核 + 双格式存储)
## 1. 背景与目标
当前项目存在两类编辑需求:
- `markdown`:知识文档等偏结构化内容
- `html` 富文本:所见即所得编辑、IM 消息等
现状是不同场景存在分叉实现,维护成本高,交互也不一致。
本设计目标是在不破坏现有后端接口(`contentType + content`)前提下,统一前端编辑器体系。
### 目标
- 统一编辑内核:前端所有编辑场景尽量使用 TipTap/ProseMirror
- 兼容双格式:继续保留 `contentType = "markdown" | "html"`
- 保持可演进:后续可扩展图片上传、草稿、快捷键、插件化工具栏
- 降低迁移风险:分阶段替换,不一次性重写全部页面
### 非目标
- 不追求 markdown 与 html 的绝对无损双向转换
- 不在第一阶段实现完整协作编辑(OT/CRDT)
---
## 2. 核心结论
可以使用 TipTap 作为单一编辑内核,但不建议把 markdown/html 简化为“完全等价格式”。
- TipTap 的内部模型是 ProseMirror 文档,不是 markdown AST
- markdown 与 html 语义存在差异,复杂结构双向转换会有损
- 正确做法是:**内核统一 + 存储分型 + 转换可控**
---
## 3. 总体架构
建议目录(以 `web/components/editor` 为中心):
```text
web/components/editor/
index.tsx # 统一入口组件 UnifiedEditor
html.tsx # HtmlEditorTipTap 配置)
markdown.tsx # MarkdownEditorTipTap markdown 模式)
viewer.tsx # 统一只读渲染入口(可选)
toolbar.tsx # 通用工具栏(可按能力裁剪)
schema.ts # TipTap 扩展与能力分组
convert.ts # markdown/html 与 editor doc 的转换封装
sanitize.ts # HTML 白名单清洗(渲染前)
types.ts # 统一类型定义
DESIGN.md # 本文档
```
---
## 4. 数据模型与接口
## 4.1 类型定义(建议)
```ts
export type EditorMode = "markdown" | "html"
export type EditorValue = {
mode: EditorMode
raw: string
}
export type UnifiedEditorProps = {
value: EditorValue
onChange: (next: EditorValue) => void
placeholder?: string
disabled?: boolean
features?: {
image?: boolean
link?: boolean
table?: boolean
codeBlock?: boolean
}
}
```
说明:
- `raw` 存放最终持久化内容(markdown 文本或 html 字符串)
- 外层业务不再关心“用什么编辑器实现”,只处理 `value/onChange`
## 4.2 现有接口兼容
与当前后端接口保持一致:
- `contentType` <- `value.mode`
- `content` <- `value.raw`
无需改后端数据结构。
---
## 5. 模式策略(重点)
## 5.1 html 模式
- 导入:`setContent(html)`
- 编辑:TipTap 常规富文本
- 导出:`editor.getHTML()`
## 5.2 markdown 模式
- 导入:`markdown -> editor doc`
- 编辑:仍使用 TipTap 内核(可配置 markdown 友好的工具栏)
- 导出:`editor doc -> markdown`
## 5.3 模式切换
当用户手动切换 `markdown/html` 时:
1. 弹出确认提示:告知可能发生格式损失
2. 用户可选:
- 仅切换模式(保留原始内容,不做转换)
- 执行转换(尝试 markdown/html 互转)
3. 转换失败时回退并提示错误原因
---
## 6. 转换与边界规则
## 6.1 支持稳定转换的子集
第一阶段建议仅保证以下元素稳定:
- 段落、标题(h1-h3
- 粗体、斜体、删除线
- 无序/有序列表
- 引用
- 行内代码、代码块
- 链接
- 图片(基本属性)
## 6.2 明确有损边界
以下能力不承诺无损往返(可在 UI 上提示):
- 复杂表格
- 自定义 HTML 属性与内联样式
- 任意嵌套块与第三方嵌入节点
---
## 7. 安全策略
所有 HTML 渲染都应先经过 sanitize,再进入 `dangerouslySetInnerHTML`
建议白名单:
- 标签:`p`, `br`, `strong`, `em`, `del`, `blockquote`, `ul`, `ol`, `li`, `code`, `pre`, `a`, `img`, `h1`, `h2`, `h3`
- 属性:
- `a`: `href`, `target`, `rel`
- `img`: `src`, `alt`, `title`
安全要点:
- 禁止 `script`, `style`, `iframe` 等危险标签
- 过滤事件属性(如 `onclick`
- 限制 `href/src` 协议(如仅 `http`, `https`, `data:image/*` 按需)
---
## 8. 组件分层建议
保持以下分层,避免页面散落编辑逻辑:
- `UnifiedEditor`:模式分发、通用 props、统一事件
- `HtmlEditor` / `MarkdownEditor`:各自实现细节
- `EditorToolbar`:按 `features` 开关按钮
- `convert.ts`:只做内容转换,不掺杂 UI
- `viewer.tsx`:只读渲染,统一 sanitize + 样式
---
## 9. 分阶段实施计划
## Phase 1(低风险统一入口)
-`web/components/editor` 补齐 `index.tsx``html.tsx``markdown.tsx` 的最小实现
- 先迁移知识库文档编辑页到 `UnifiedEditor`
- 保持 IM 场景暂不动,避免一次性改动过大
验收标准:
- 业务页不再直接判断 `Textarea` vs `RichTextEditor`
- 保存结果与当前接口完全兼容
## Phase 2(收敛富文本能力)
- 抽象 TipTap schema/toolbar,沉淀为复用能力
- 迁移 IM 编辑器到统一内核配置(保留其发送快捷键与图片上传行为)
验收标准:
- 共享核心扩展与样式策略
- 场景差异通过 `features` 开关控制
## Phase 3(体验与可靠性增强)
- 引入草稿自动保存(localStorage 或服务端草稿)
- 增加快捷键、字数统计、粘贴规则统一
- 完善 sanitize 策略与回归测试
---
## 10. 测试建议
至少覆盖以下场景:
- markdown/html 各自编辑与保存
- 模式切换提示与转换失败回退
- 图片上传占位图替换成功/失败
- HTML 渲染安全(XSS 用例)
- 关键快捷键行为(Enter/Shift+Enter/Cmd+B
---
## 11. 风险与取舍
- 风险:追求“全格式无损转换”会导致实现复杂度急剧上升
- 取舍:先定义“可稳定支持的语法子集”,其余场景用提示+降级策略处理
- 收益:统一内核后,后续功能(草稿、插件、统计、主题)可一次开发多处复用
---
## 12. 与当前项目的直接对应
建议优先替换知识库文档编辑页中的分支逻辑(markdown 文本域 vs html 富文本),统一接入 `UnifiedEditor`
IM 编辑器可在下一阶段迁移到同一核心配置,避免破坏现有发送交互。
+148
View File
@@ -0,0 +1,148 @@
"use client"
import { useEffect } from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import Placeholder from "@tiptap/extension-placeholder"
import StarterKit from "@tiptap/starter-kit"
import {
BoldIcon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
RedoIcon,
UndoIcon,
} from "lucide-react"
import { EditorToolbar } from "./toolbar"
import type { BaseEditorProps } from "./types"
export type HtmlEditorProps = BaseEditorProps & {
value: string
onChange: (nextValue: string) => void
}
export function HtmlEditor({
value,
onChange,
placeholder = "请输入内容...",
disabled = false,
}: HtmlEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder,
}),
],
content: value,
editable: !disabled,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editorProps: {
attributes: {
class:
"min-h-64 max-h-96 overflow-y-auto px-4 py-3 text-sm leading-7 text-slate-900 outline-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p]:mb-2 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:mb-1 [&_blockquote]:border-l-4 [&_blockquote]:border-muted-foreground [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground",
},
},
})
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value)
}
}, [editor, value])
useEffect(() => {
if (editor) {
editor.setEditable(!disabled)
}
}, [disabled, editor])
if (!editor) {
return null
}
const toolbarActions = [
{
key: "undo",
label: "撤销",
icon: UndoIcon,
disabled: !editor.can().undo() || disabled,
onClick: () => editor.chain().focus().undo().run(),
},
{
key: "redo",
label: "重做",
icon: RedoIcon,
disabled: !editor.can().redo() || disabled,
onClick: () => editor.chain().focus().redo().run(),
},
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: "粗体",
icon: BoldIcon,
disabled,
pressed: editor.isActive("bold"),
onClick: () => editor.chain().focus().toggleBold().run(),
},
{
key: "italic",
label: "斜体",
icon: ItalicIcon,
disabled,
pressed: editor.isActive("italic"),
onClick: () => editor.chain().focus().toggleItalic().run(),
},
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: "无序列表",
icon: ListIcon,
disabled,
pressed: editor.isActive("bulletList"),
onClick: () => editor.chain().focus().toggleBulletList().run(),
},
{
key: "orderedList",
label: "有序列表",
icon: ListOrderedIcon,
disabled,
pressed: editor.isActive("orderedList"),
onClick: () => editor.chain().focus().toggleOrderedList().run(),
},
{
key: "blockquote",
label: "引用",
icon: QuoteIcon,
disabled,
pressed: editor.isActive("blockquote"),
onClick: () => editor.chain().focus().toggleBlockquote().run(),
},
] as const
return (
<div className="rounded-lg border bg-background">
<EditorToolbar actions={toolbarActions} />
<div className="p-2">
<EditorContent editor={editor} />
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { HtmlEditor } from "./html"
import { MarkdownEditor } from "./markdown"
import type { BaseEditorProps, EditorValue } from "./types"
export type UnifiedEditorProps = BaseEditorProps & {
value: EditorValue
onChange: (next: EditorValue) => void
markdownRows?: number
}
export function UnifiedEditor({
value,
onChange,
placeholder,
disabled,
features,
className,
markdownRows,
}: UnifiedEditorProps) {
if (value.mode === "markdown") {
return (
<MarkdownEditor
value={value.raw}
onChange={(nextRaw) => onChange({ ...value, raw: nextRaw })}
placeholder={placeholder}
disabled={disabled}
features={features}
className={className}
rows={markdownRows}
/>
)
}
return (
<HtmlEditor
value={value.raw}
onChange={(nextRaw) => onChange({ ...value, raw: nextRaw })}
placeholder={placeholder}
disabled={disabled}
features={features}
className={className}
/>
)
}
export * from "./types"
+171
View File
@@ -0,0 +1,171 @@
"use client"
import { useRef } from "react"
import {
BoldIcon,
CodeIcon,
Heading1Icon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
} from "lucide-react"
import { Textarea } from "@/components/ui/textarea"
import { EditorToolbar } from "./toolbar"
import type { BaseEditorProps } from "./types"
export type MarkdownEditorProps = BaseEditorProps & {
value: string
onChange: (nextValue: string) => void
rows?: number
}
export function MarkdownEditor({
value,
onChange,
placeholder = ".",
disabled = false,
rows = 16,
className,
}: MarkdownEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const handleWrapSelection = (prefix: string, suffix = prefix) => {
const textarea = textareaRef.current
if (!textarea || disabled) {
return
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const selected = value.slice(start, end)
const next = `${value.slice(0, start)}${prefix}${selected}${suffix}${value.slice(end)}`
onChange(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(start + prefix.length, end + prefix.length)
})
}
const handleInsertLinePrefix = (prefix: string) => {
const textarea = textareaRef.current
if (!textarea || disabled) {
return
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const lineStart = value.lastIndexOf("\n", start - 1) + 1
const lineEndRaw = value.indexOf("\n", end)
const lineEnd = lineEndRaw === -1 ? value.length : lineEndRaw
const selectedLines = value.slice(lineStart, lineEnd)
const nextLines = selectedLines
.split("\n")
.map((line) => `${prefix}${line}`)
.join("\n")
const next = `${value.slice(0, lineStart)}${nextLines}${value.slice(lineEnd)}`
onChange(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart + nextLines.length)
})
}
const handleInsertLink = () => {
const textarea = textareaRef.current
if (!textarea || disabled) {
return
}
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
const selected = value.slice(start, end) || "链接文本"
const markdown = `[${selected}](https://)`
const next = `${value.slice(0, start)}${markdown}${value.slice(end)}`
onChange(next)
requestAnimationFrame(() => {
textarea.focus()
const urlStart = start + markdown.lastIndexOf("https://")
textarea.setSelectionRange(urlStart, urlStart + "https://".length)
})
}
const toolbarActions = [
{
key: "heading1",
label: "一级标题",
icon: Heading1Icon,
disabled,
onClick: () => handleInsertLinePrefix("# "),
},
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: "粗体",
icon: BoldIcon,
disabled,
onClick: () => handleWrapSelection("**"),
},
{
key: "italic",
label: "斜体",
icon: ItalicIcon,
disabled,
onClick: () => handleWrapSelection("*"),
},
{
key: "code",
label: "行内代码",
icon: CodeIcon,
disabled,
onClick: () => handleWrapSelection("`"),
},
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: "无序列表",
icon: ListIcon,
disabled,
onClick: () => handleInsertLinePrefix("- "),
},
{
key: "orderedList",
label: "有序列表",
icon: ListOrderedIcon,
disabled,
onClick: () => handleInsertLinePrefix("1. "),
},
{
key: "blockquote",
label: "引用",
icon: QuoteIcon,
disabled,
onClick: () => handleInsertLinePrefix("> "),
},
{
key: "link",
label: "链接",
icon: LinkIcon,
disabled,
onClick: handleInsertLink,
},
] as const
return (
<div className="rounded-lg border bg-background">
<EditorToolbar actions={toolbarActions} />
<div className="p-2">
<Textarea
ref={textareaRef}
value={value}
rows={rows}
disabled={disabled}
placeholder={placeholder}
className={`min-h-64 max-h-96 resize-y border-0 px-2 py-2 text-sm leading-7 shadow-none focus-visible:ring-0 ${className ?? ""}`}
onChange={(event) => onChange(event.target.value)}
/>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
"use client"
import type { LucideIcon } from "lucide-react"
import { Separator } from "@/components/ui/separator"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
type EditorToolbarButtonAction = {
key: string
label: string
icon: LucideIcon
onClick: () => void
disabled?: boolean
pressed?: boolean
}
type EditorToolbarSeparatorAction = {
key: string
type: "separator"
}
export type EditorToolbarAction = EditorToolbarButtonAction | EditorToolbarSeparatorAction
type EditorToolbarProps = {
actions: ReadonlyArray<EditorToolbarAction>
}
function isSeparatorAction(
action: EditorToolbarAction
): action is EditorToolbarSeparatorAction {
return "type" in action && action.type === "separator"
}
export function EditorToolbar({ actions }: EditorToolbarProps) {
return (
<div className="flex items-center gap-1 border-b p-2">
<ToggleGroup className="flex-wrap gap-1">
{actions.map((action) => {
if (isSeparatorAction(action)) {
return <Separator key={action.key} orientation="vertical" className="mx-1 h-6" />
}
const Icon = action.icon
return (
<ToggleGroupItem
key={action.key}
value={action.key}
aria-label={action.label}
disabled={action.disabled}
pressed={action.pressed}
onClick={action.onClick}
>
<Icon className="size-4" />
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
export type EditorMode = "markdown" | "html"
export type EditorValue = {
mode: EditorMode
raw: string
}
export type EditorFeatures = {
image?: boolean
link?: boolean
table?: boolean
codeBlock?: boolean
}
export type BaseEditorProps = {
placeholder?: string
disabled?: boolean
features?: EditorFeatures
className?: string
}
+412
View File
@@ -0,0 +1,412 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Image from "@tiptap/extension-image"
import Placeholder from "@tiptap/extension-placeholder"
import { ImageIcon, MessageSquareTextIcon, PaperclipIcon, SendIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
import { generateUUID } from "@/lib/utils"
type UploadedImage = {
url: string
filename?: string
}
type ImMessageEditorProps = {
disabled?: boolean
uploadingAsset?: boolean
onSend: (html: string) => Promise<void>
onUploadImage: (file: File) => Promise<UploadedImage | null>
onSendAttachment: (file: File) => Promise<void>
}
export function ImMessageEditor({
disabled = false,
uploadingAsset = false,
onSend,
onUploadImage,
onSendAttachment,
}: ImMessageEditorProps) {
const imageInputRef = useRef<HTMLInputElement>(null)
const attachmentInputRef = useRef<HTMLInputElement>(null)
const onSendRef = useRef(onSend)
const onUploadImageRef = useRef(onUploadImage)
const onSendAttachmentRef = useRef(onSendAttachment)
const shouldRestoreFocusRef = useRef(false)
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(false)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
useEffect(() => {
onSendRef.current = onSend
}, [onSend])
useEffect(() => {
onUploadImageRef.current = onUploadImage
}, [onUploadImage])
useEffect(() => {
onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
let cancelled = false
setLoadingQuickReplies(true)
void fetchQuickReplyListAll()
.then((list) => {
if (!cancelled) {
setQuickReplies(list)
}
})
.catch((error) => {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
}
})
.finally(() => {
if (!cancelled) {
setLoadingQuickReplies(false)
}
})
return () => {
cancelled = true
}
}, [])
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
horizontalRule: false,
}),
Image,
Placeholder.configure({
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
}),
],
content: "",
editorProps: {
attributes: {
class:
"h-full min-h-12 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+img]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-md [&_img]:object-contain [&_p.is-editor-empty:first-child]:before:text-muted-foreground",
},
handleKeyDown: (_view, event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
void handleSend()
return true
}
return false
},
handlePaste: (_view, event) => {
if (disabled || uploadingAsset) {
return false
}
const imageFile = getClipboardImageFile(event.clipboardData)
if (!imageFile) {
return false
}
event.preventDefault()
void insertUploadedImage(imageFile)
return true
},
},
})
useEffect(() => {
if (!editor) {
return
}
editor.setEditable(!disabled && !uploadingAsset)
}, [disabled, editor, uploadingAsset])
useEffect(() => {
if (!editor || disabled || uploadingAsset || !shouldRestoreFocusRef.current) {
return
}
requestAnimationFrame(() => {
editor.commands.focus()
})
}, [disabled, editor, uploadingAsset])
const handleSend = async () => {
if (!editor || disabled || uploadingAsset) {
return
}
const html = editor.getHTML()
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
const handleSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || disabled || uploadingAsset) {
if (editor && shouldRestoreFocusRef.current) {
requestAnimationFrame(() => {
editor.commands.focus()
})
}
return
}
await insertUploadedImage(file)
}
const insertUploadedImage = async (file: File) => {
if (!editor || disabled || uploadingAsset) {
return
}
shouldRestoreFocusRef.current = true
const objectUrl = URL.createObjectURL(file)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
.focus()
.setImage({
src: objectUrl,
alt: file.name || "uploading-image",
title: placeholderId,
})
.run()
try {
const uploaded = await onUploadImageRef.current(file)
if (!uploaded?.url) {
removeImageByTitle(editor, placeholderId)
return
}
replaceImageSourceByTitle(editor, placeholderId, uploaded.url, uploaded.filename || "image")
} finally {
URL.revokeObjectURL(objectUrl)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
}
}
const handleSelectAttachment = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || disabled || uploadingAsset) {
if (editor && shouldRestoreFocusRef.current) {
requestAnimationFrame(() => {
editor.commands.focus()
})
}
return
}
shouldRestoreFocusRef.current = editor?.isFocused ?? true
await onSendAttachmentRef.current(file)
requestAnimationFrame(() => {
if (editor && !disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
}
const handleInsertQuickReply = (item: AdminQuickReply) => {
if (!editor || disabled || uploadingAsset) {
return
}
if (!item.content.trim()) {
return
}
editor.chain().focus().insertContent(item.content).run()
setQuickReplyPickerOpen(false)
}
return (
<div className="flex h-full min-h-0 flex-col p-2">
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleSelectImage}
/>
<input
ref={attachmentInputRef}
type="file"
className="hidden"
onChange={handleSelectAttachment}
/>
<div className="flex h-full min-h-0 flex-col rounded-sm border border-border bg-card">
<div className="min-h-0 flex-1 px-2 py-1">
<EditorContent editor={editor} className="h-full" />
</div>
<div className="flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
shouldRestoreFocusRef.current = editor?.isFocused ?? true
imageInputRef.current?.click()
}}
disabled={disabled || uploadingAsset}
>
<ImageIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
shouldRestoreFocusRef.current = editor?.isFocused ?? true
attachmentInputRef.current?.click()
}}
disabled={disabled || uploadingAsset}
>
<PaperclipIcon className="size-4" />
</Button>
<Popover open={quickReplyPickerOpen} onOpenChange={setQuickReplyPickerOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-8"
disabled={disabled || uploadingAsset || loadingQuickReplies}
onMouseDown={(event) => event.preventDefault()}
/>
}
>
<MessageSquareTextIcon className="size-4" />
</PopoverTrigger>
<PopoverContent className="w-[30rem] p-0" align="start">
<Command>
<CommandInput placeholder="搜索快捷回复" />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup>
{quickReplies.map((item) => (
<CommandItem
key={item.id}
value={`${item.groupName} ${item.title} ${item.content}`}
onSelect={() => handleInsertQuickReply(item)}
>
<div className="flex min-w-0 flex-col gap-0.5 py-0.5">
<span className="line-clamp-1 text-sm">
{item.groupName ? `${item.groupName} / ${item.title}` : item.title}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">
{item.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">Enter </p>
<Button size="sm" onClick={() => void handleSend()} disabled={disabled || uploadingAsset}>
<SendIcon className="mr-1 size-4" />
{uploadingAsset ? "上传中..." : "发送"}
</Button>
</div>
</div>
</div>
</div>
)
}
function isMeaningfulHTML(html: string) {
const normalized = html
.replace(/<p><\/p>/g, "")
.replace(/<p><br><\/p>/g, "")
.replace(/\s+/g, "")
if (/<img[\s\S]*?>/i.test(normalized)) {
return true
}
const plainText = normalized.replace(/<[^>]+>/g, "").trim()
return plainText !== ""
}
function getClipboardImageFile(clipboardData: DataTransfer | null) {
if (!clipboardData) {
return null
}
for (const item of Array.from(clipboardData.items)) {
if (item.kind === "file" && item.type.startsWith("image/")) {
return item.getAsFile()
}
}
return null
}
function removeImageByTitle(editor: NonNullable<ReturnType<typeof useEditor>>, title: string) {
const { state } = editor
let targetPos: number | null = null
state.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs.title === title) {
targetPos = pos
return false
}
return true
})
if (targetPos === null) {
return
}
editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run()
}
function replaceImageSourceByTitle(
editor: NonNullable<ReturnType<typeof useEditor>>,
title: string,
src: string,
alt: string
) {
const { state, view } = editor
let targetPos: number | null = null
state.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs.title === title) {
targetPos = pos
return false
}
return true
})
if (targetPos === null) {
return
}
const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
...view.state.doc.nodeAt(targetPos)?.attrs,
src,
alt,
title: "",
})
view.dispatch(transaction)
}
+86
View File
@@ -0,0 +1,86 @@
"use client"
import { memo, useEffect, useRef } from "react"
type ImMessageHTMLProps = {
html: string
className?: string
onImageSettled?: () => void
onImageClick?: (src: string, alt?: string) => void
}
function ImMessageHTMLComponent({
html,
className = "",
onImageSettled,
onImageClick,
}: ImMessageHTMLProps) {
const containerRef = useRef<HTMLDivElement>(null)
const onImageSettledRef = useRef(onImageSettled)
const onImageClickRef = useRef(onImageClick)
useEffect(() => {
onImageSettledRef.current = onImageSettled
}, [onImageSettled])
useEffect(() => {
onImageClickRef.current = onImageClick
}, [onImageClick])
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
const images = Array.from(container.querySelectorAll("img"))
if (images.length === 0) {
return
}
const cleanups = images.map((image) => {
const handleSettled = () => onImageSettledRef.current?.()
const handleClick = () => {
const src = image.getAttribute("src")
if (src) {
const alt = image.getAttribute("alt") ?? undefined
onImageClickRef.current?.(src, alt)
}
}
image.addEventListener("load", handleSettled)
image.addEventListener("error", handleSettled)
image.addEventListener("click", handleClick)
if (image.complete) {
onImageSettledRef.current?.()
}
image.classList.add("cursor-zoom-in")
return () => {
image.removeEventListener("load", handleSettled)
image.removeEventListener("error", handleSettled)
image.removeEventListener("click", handleClick)
}
})
return () => {
cleanups.forEach((cleanup) => cleanup())
}
}, [html, onImageClick, onImageSettled])
return (
<div
ref={containerRef}
className={`break-words text-sm [&_p]:m-0 [&_p+*]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-md [&_img]:object-contain [&_.im-attachment]:min-w-0 [&_.im-attachment-link]:flex [&_.im-attachment-link]:min-w-0 [&_.im-attachment-link]:items-center [&_.im-attachment-link]:gap-3 [&_.im-attachment-link]:rounded-xl [&_.im-attachment-link]:no-underline [&_.im-attachment-link]:transition-colors hover:[&_.im-attachment-link]:bg-black/5 [&_.im-attachment-icon]:flex [&_.im-attachment-icon]:size-10 [&_.im-attachment-icon]:shrink-0 [&_.im-attachment-icon]:items-center [&_.im-attachment-icon]:justify-center [&_.im-attachment-icon]:rounded-xl [&_.im-attachment-icon]:bg-black/5 [&_.im-attachment-icon_svg]:size-5 [&_.im-attachment-content]:flex [&_.im-attachment-content]:min-w-0 [&_.im-attachment-content]:flex-col [&_.im-attachment-title]:truncate [&_.im-attachment-title]:font-medium [&_.im-attachment-meta]:text-xs [&_.im-attachment-meta]:opacity-70 ${className}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export const ImMessageHTML = memo(
ImMessageHTMLComponent,
(prevProps, nextProps) =>
prevProps.html === nextProps.html &&
prevProps.className === nextProps.className &&
prevProps.onImageSettled === nextProps.onImageSettled &&
prevProps.onImageClick === nextProps.onImageClick
)
+139
View File
@@ -0,0 +1,139 @@
"use client"
import { useRef, useState } from "react"
import { UploadIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { uploadAsset } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
export type ImageInputProps = {
value?: string
onChange?: (value: string) => void
disabled?: boolean
accept?: string
maxSize?: number
prefix?: string
placeholder?: string
className?: string
}
export function ImageInput({
value,
onChange,
disabled,
accept = "image/*",
maxSize = 5 * 1024 * 1024,
prefix,
placeholder = "点击上传图片",
className,
}: ImageInputProps) {
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
function handleClick() {
if (disabled || uploading) {
return
}
fileInputRef.current?.click()
}
function handleClear(event: React.MouseEvent) {
event.stopPropagation()
onChange?.("")
}
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) {
return
}
if (!file.type.startsWith("image/")) {
toast.error("请选择图片文件")
return
}
if (file.size > maxSize) {
const maxSizeMB = (maxSize / 1024 / 1024).toFixed(0)
toast.error(`图片大小不能超过 ${maxSizeMB}MB`)
return
}
setUploading(true)
try {
const result = await uploadAsset(file, prefix)
onChange?.(result.url)
toast.success("图片上传成功")
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传图片失败")
} finally {
setUploading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
const isDisabled = disabled || uploading
return (
<div className={cn("relative", className)}>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleFileChange}
disabled={isDisabled}
/>
<div
onClick={handleClick}
className={cn(
"group relative flex size-24 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-input bg-muted transition-colors",
"hover:border-primary hover:bg-muted/50",
"focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none",
isDisabled && "cursor-not-allowed opacity-50"
)}
tabIndex={isDisabled ? -1 : 0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleClick()
}
}}
role="button"
aria-label={value ? "更换图片" : placeholder}
>
{value ? (
<>
<img src={value} alt="已上传图片" className="size-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<span className="text-sm text-white"></span>
</div>
</>
) : (
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<UploadIcon className="size-6" />
<span className="text-xs">{uploading ? "上传中..." : placeholder}</span>
</div>
)}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="size-6 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
)}
</div>
{value && !isDisabled && (
<button
type="button"
onClick={handleClear}
className="absolute -right-2 -top-2 flex size-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm transition-colors hover:bg-destructive/80"
aria-label="删除图片"
>
<XIcon className="size-3" />
</button>
)}
</div>
)
}
+372
View File
@@ -0,0 +1,372 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import {
ExternalLinkIcon,
RefreshCwIcon,
RotateCcwIcon,
RotateCwIcon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { ReactZoomPanPinchContentRef } from "react-zoom-pan-pinch";
import {
TransformComponent,
TransformWrapper,
} from "react-zoom-pan-pinch";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogOverlay,
DialogPortal,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type ImageLightboxItem = {
src: string;
alt?: string;
};
type ImageLightboxContextValue = {
open: (src: string, alt?: string) => void;
close: () => void;
};
const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(
null,
);
export function useImageLightbox(): ImageLightboxContextValue {
const ctx = useContext(ImageLightboxContext);
if (!ctx) {
throw new Error("useImageLightbox 必须在 ImageLightboxProvider 内使用");
}
return ctx;
}
/** 未包裹 Provider 时返回 null,便于渐进接入 */
export function useImageLightboxOptional(): ImageLightboxContextValue | null {
return useContext(ImageLightboxContext);
}
export type ImageLightboxProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
src: string | null;
alt?: string;
};
function canOpenInNewTab(url: string): boolean {
if (!url) {
return false;
}
if (url.startsWith("/")) {
return true;
}
try {
const parsed = new URL(url);
return (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
parsed.protocol === "blob:"
);
} catch {
return false;
}
}
function LightboxImageBody({
src,
alt,
pinchRef,
rotationDeg,
}: {
src: string;
alt?: string;
pinchRef: React.RefObject<ReactZoomPanPinchContentRef | null>;
rotationDeg: number;
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const showOpenTab = canOpenInNewTab(src);
useEffect(() => {
requestAnimationFrame(() => {
pinchRef.current?.centerView(1, 0);
});
}, [rotationDeg, pinchRef]);
return (
<div className="relative h-full min-h-0 w-full min-w-0 flex-1">
{loading && !error ? (
<div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
aria-hidden
>
<div className="size-10 animate-pulse rounded-full bg-white/25" />
</div>
) : null}
{error ? (
<div className="flex min-h-[min(50vh,320px)] flex-col items-center justify-center gap-4 px-6 py-12 text-center text-sm text-white/90">
<p></p>
{showOpenTab ? (
<a
href={src}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
>
</a>
) : null}
</div>
) : (
<TransformWrapper
ref={pinchRef}
initialScale={1}
minScale={0.35}
maxScale={8}
centerOnInit
centerZoomedOut
limitToBounds
wheel={{ step: 0.12 }}
pinch={{ step: 5 }}
panning={{ velocityDisabled: false }}
doubleClick={{ mode: "reset", step: 0.7 }}
>
<TransformComponent
wrapperClass="!h-full !w-full !max-h-full !max-w-full"
contentClass="!flex !h-full !min-h-0 !w-full !min-w-0 !items-center !justify-center !p-4 sm:!p-6"
>
{/* eslint-disable-next-line @next/next/no-img-element -- 外链与任意尺寸大图预览 */}
<img
src={src}
alt={alt || "预览图片"}
draggable={false}
style={{ transform: `rotate(${rotationDeg}deg)` }}
className={cn(
"max-h-[min(85vh,calc(100dvh-3rem))] max-w-full origin-center object-contain transition-transform duration-200 ease-out select-none",
loading ? "opacity-0" : "opacity-100",
)}
onLoad={() => {
setLoading(false);
setError(false);
requestAnimationFrame(() => {
pinchRef.current?.centerView(1, 0);
});
}}
onError={() => {
setLoading(false);
setError(true);
}}
/>
</TransformComponent>
</TransformWrapper>
)}
</div>
);
}
/** 按 src 作为 key 挂载,切换图片时旋转角自动回到 0 */
function ImageLightboxDialogContent({
src,
alt,
}: {
src: string;
alt?: string;
}) {
const pinchRef = useRef<ReactZoomPanPinchContentRef | null>(null);
const [rotationDeg, setRotationDeg] = useState(0);
const showOpenTab = canOpenInNewTab(src);
const titleText = alt?.trim() || "图片预览";
const rotateLeft = useCallback(() => {
setRotationDeg((d) => (d - 90 + 360) % 360);
}, []);
const rotateRight = useCallback(() => {
setRotationDeg((d) => (d + 90) % 360);
}, []);
return (
<DialogPortal>
<DialogOverlay className="z-100 bg-black/85 supports-backdrop-filter:backdrop-blur-xs" />
<DialogPrimitive.Popup
data-slot="image-lightbox-popup"
className={cn(
"fixed inset-0 z-100 flex max-h-dvh min-h-0 flex-col outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 duration-100",
)}
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-white/10 bg-black/55 px-2 py-2 text-white sm:gap-3 sm:px-4">
<DialogTitle className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-snug text-white">
{titleText}
</DialogTitle>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="放大"
onClick={() => pinchRef.current?.zoomIn(0.25)}
>
<ZoomInIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="缩小"
onClick={() => pinchRef.current?.zoomOut(0.25)}
>
<ZoomOutIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="向左旋转"
onClick={rotateLeft}
>
<RotateCcwIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="向右旋转"
onClick={rotateRight}
>
<RotateCwIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="重置缩放、位置与旋转"
onClick={() => {
setRotationDeg(0);
pinchRef.current?.resetTransform(200);
}}
>
<RefreshCwIcon className="size-4" />
</Button>
{showOpenTab ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="在新标签页打开"
onClick={() => {
window.open(src, "_blank", "noopener,noreferrer");
}}
>
<ExternalLinkIcon className="size-4" />
</Button>
) : null}
<DialogClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label="关闭"
/>
}
>
<XIcon className="size-4" />
<span className="sr-only"></span>
</DialogClose>
</div>
</div>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<LightboxImageBody
pinchRef={pinchRef}
rotationDeg={rotationDeg}
src={src}
alt={alt}
/>
</div>
<p className="sr-only">
使
</p>
</DialogPrimitive.Popup>
</DialogPortal>
);
}
export function ImageLightboxView({
open,
onOpenChange,
src,
alt,
}: ImageLightboxProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{src ? (
<ImageLightboxDialogContent key={src} src={src} alt={alt} />
) : null}
</Dialog>
);
}
export function ImageLightboxProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ImageLightboxItem | null>(null);
const open = useCallback((src: string, alt?: string) => {
const trimmed = src?.trim();
if (!trimmed) {
return;
}
setState({ src: trimmed, alt });
}, []);
const close = useCallback(() => {
setState(null);
}, []);
const contextValue = useMemo(
() => ({
open,
close,
}),
[open, close],
);
return (
<ImageLightboxContext.Provider value={contextValue}>
{children}
<ImageLightboxView
open={state !== null}
onOpenChange={(next) => {
if (!next) {
setState(null);
}
}}
src={state?.src ?? null}
alt={state?.alt}
/>
</ImageLightboxContext.Provider>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client"
import { useEffect, useMemo } from "react"
import { cn } from "@/lib/utils"
type JsonCodeEditorProps = {
value: string
onChange: (value: string) => void
onValidationChange?: (error: string | null) => void
disabled?: boolean
className?: string
}
function validateJson(value: string) {
const text = value.trim()
if (!text) {
return null
}
try {
JSON.parse(text)
return null
} catch (error) {
return error instanceof Error ? error.message : "JSON 格式不合法"
}
}
export function JsonCodeEditor({
value,
onChange,
onValidationChange,
disabled = false,
className,
}: JsonCodeEditorProps) {
const error = useMemo(() => validateJson(value), [value])
const lineCount = Math.max(1, value.split("\n").length)
useEffect(() => {
onValidationChange?.(error)
}, [error, onValidationChange])
return (
<div className={cn("rounded-lg border bg-slate-950", className)}>
<div className="flex items-center justify-between border-b border-slate-800 px-3 py-2">
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">JSON</span>
<span
className={cn(
"text-xs",
error ? "text-rose-300" : "text-emerald-300"
)}
>
{error ? "格式错误" : "格式正确"}
</span>
</div>
<div className="flex min-h-52">
<div className="select-none border-r border-slate-800 bg-slate-900/70 px-3 py-3 font-mono text-xs leading-6 text-slate-500">
{Array.from({ length: lineCount }, (_, index) => (
<div key={index + 1}>{index + 1}</div>
))}
</div>
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
spellCheck={false}
className="min-h-52 flex-1 resize-y bg-transparent px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-60"
placeholder={`{\n "key": "value"\n}`}
/>
</div>
<div className="border-t border-slate-800 px-3 py-2 text-xs text-slate-400">
{error || "输入合法 JSON 后即可测试工具调用。"}
</div>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
"use client"
import { cn } from "@/lib/utils"
type JsonViewerProps = {
value: unknown
emptyText?: string
className?: string
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}
function formatJson(value: unknown) {
if (value === undefined) {
return ""
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function highlightJson(value: string) {
const escaped = escapeHtml(value)
return escaped.replace(
/("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let className = "text-slate-200"
if (match.startsWith('"')) {
className = match.endsWith(":") ? "text-sky-300" : "text-emerald-300"
} else if (match === "true" || match === "false") {
className = "text-amber-300"
} else if (match === "null") {
className = "text-rose-300"
} else {
className = "text-violet-300"
}
return `<span class="${className}">${match}</span>`
}
)
}
export function JsonViewer({
value,
emptyText = "暂无数据",
className,
}: JsonViewerProps) {
const formatted = formatJson(value)
if (!formatted) {
return (
<div
className={cn(
"rounded-md border bg-slate-950 px-4 py-3 font-mono text-xs leading-6 text-slate-400",
className
)}
>
{emptyText}
</div>
)
}
return (
<pre
className={cn(
"overflow-x-auto rounded-md border bg-slate-950 px-4 py-3 font-mono text-xs leading-6 text-slate-100",
className
)}
dangerouslySetInnerHTML={{ __html: highlightJson(formatted) }}
/>
)
}
+88
View File
@@ -0,0 +1,88 @@
"use client"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
type ListPaginationProps = {
page: number
total: number
limit: number
loading?: boolean
pageSizeOptions?: number[]
onPageChange: (page: number) => void
onLimitChange: (limit: number) => void
}
export function ListPagination({
page,
total,
limit,
loading = false,
pageSizeOptions = [10, 20, 50, 100],
onPageChange,
onLimitChange,
}: ListPaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / limit))
const canGoPreviousPage = page > 1
const canGoNextPage = page < totalPages
function handleLimitChange(value: string | null) {
if (!value) {
return
}
const nextLimit = Number(value)
if (!Number.isInteger(nextLimit) || nextLimit <= 0 || nextLimit === limit) {
return
}
onLimitChange(nextLimit)
}
return (
<div className="flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<span>
{page} / {totalPages}
</span>
<span> {total} </span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={String(limit)} onValueChange={handleLimitChange}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((pageSize) => (
<SelectItem key={pageSize} value={String(pageSize)}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => onPageChange(page - 1)}
disabled={loading || !canGoPreviousPage}
>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
onClick={() => onPageChange(page + 1)}
disabled={loading || !canGoNextPage}
>
<ChevronRightIcon />
</Button>
</div>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
"use client"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { startTransition, useEffect, useState } from "react"
import { toast } from "sonner"
import { useAuth } from "@/components/auth-provider"
import { loginWithPassword } from "@/lib/api/auth"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
function detectWxWorkEnvironment() {
if (typeof navigator === "undefined") {
return false
}
const userAgent = navigator.userAgent.toLowerCase()
return userAgent.includes("wxwork")
}
export function LoginForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const searchParams = useSearchParams()
const { session } = useAuth()
const [isPending, setIsPending] = useState(false)
const [isWxWorkEnv, setIsWxWorkEnv] = useState(false)
const nextPath = searchParams.get("next")
const wxworkError = searchParams.get("wxworkError")
const redirectPath =
nextPath && nextPath.startsWith("/") ? nextPath : "/"
useEffect(() => {
if (session) {
router.replace(redirectPath)
}
}, [redirectPath, router, session])
useEffect(() => {
if (wxworkError) {
toast.error(wxworkError)
}
}, [wxworkError])
useEffect(() => {
setIsWxWorkEnv(detectWxWorkEnvironment())
}, [])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const username = formData.get("username")?.toString().trim() ?? ""
const password = formData.get("password")?.toString() ?? ""
setIsPending(true)
try {
await loginWithPassword({ username, password })
toast.success("登录成功,正在进入系统")
startTransition(() => {
router.push(redirectPath)
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "登录失败")
} finally {
setIsPending(false)
}
}
return (
<form
className={cn("flex flex-col gap-6", className)}
onSubmit={handleSubmit}
{...props}
>
<FieldGroup>
<div className="flex flex-col gap-2 text-center">
{/* <span className="mx-auto inline-flex rounded-full border border-amber-300/60 bg-amber-50 px-3 py-1 text-[11px] font-medium tracking-[0.22em] text-amber-900 uppercase">
AI Service Console
</span> */}
<h1 className="text-3xl font-semibold tracking-tight"></h1>
<p className="text-sm text-balance text-muted-foreground">
使访
</p>
</div>
<Field>
<FieldLabel htmlFor="username"></FieldLabel>
<Input
id="username"
name="username"
placeholder="admin"
autoComplete="username"
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password"></FieldLabel>
{/* <span className="ml-auto text-xs text-muted-foreground">
演示环境接受任意非空密码
</span> */}
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</Field>
<Field>
<Button type="submit" disabled={isPending}>
{isPending ? "登录中..." : "登录"}
</Button>
</Field>
<Field>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => {
const path = isWxWorkEnv ? "/api/auth/wxwork_login" : "/api/auth/wxwork_qr_login"
window.location.href = `${path}?next=${encodeURIComponent(redirectPath)}`
}}
>
<Image src="/wxwork.svg" alt="" width={16} height={16} className="size-4 shrink-0" />
</Button>
</Field>
</FieldGroup>
</form>
)
}
+95
View File
@@ -0,0 +1,95 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { MoreHorizontalIcon, FolderIcon, ShareIcon, Trash2Icon } from "lucide-react"
export function NavDocuments({
items,
}: {
items: ReadonlyArray<{
name: string
url: string
icon: React.ReactNode
}>
}) {
const pathname = usePathname()
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
render={<Link href={item.url} />}
isActive={pathname === item.url}
>
{item.icon}
<span>{item.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuAction
showOnHover
className="aria-expanded:bg-muted"
/>
}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<FolderIcon
/>
<span></span>
</DropdownMenuItem>
<DropdownMenuItem>
<ShareIcon
/>
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon
/>
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontalIcon className="text-sidebar-foreground/70" />
<span></span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
SidebarGroupLabel,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavMain({
title,
items,
}: {
title: string
items: ReadonlyArray<{
title: string
url: string
icon?: React.ReactNode
}>
}) {
const pathname = usePathname()
const isActive = (itemUrl: string) => {
if (itemUrl === "/") {
return pathname === itemUrl
}
return pathname === itemUrl || pathname.startsWith(itemUrl + "/")
}
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
render={<Link href={item.url} />}
isActive={isActive(item.url)}
>
{item.icon}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: ReadonlyArray<{
title: string
url: string
icon: React.ReactNode
}>
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname()
const isActive = (itemUrl: string) => {
return pathname === itemUrl || pathname.startsWith(itemUrl + "/")
}
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
render={<Link href={item.url} />}
isActive={isActive(item.url)}
>
{item.icon}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
+127
View File
@@ -0,0 +1,127 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useAuth } from "@/components/auth-provider"
import { ChangePasswordDialog } from "@/components/change-password-dialog"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import {
BellIcon,
EllipsisVerticalIcon,
KeyRoundIcon,
LogOutIcon
} from "lucide-react"
export function NavUser({
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
const { signOut } = useAuth()
const { isMobile } = useSidebar()
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<Avatar className="size-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-foreground/70">
{user.email}
</span>
</div>
<EllipsisVerticalIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
setChangePasswordOpen(true)
}}
>
<KeyRoundIcon />
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon />
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
void signOut()
}}
>
<LogOutIcon />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<ChangePasswordDialog
open={changePasswordOpen}
onOpenChange={setChangePasswordOpen}
onSuccess={signOut}
/>
</>
)
}
+106
View File
@@ -0,0 +1,106 @@
"use client"
import type { ReactNode } from "react"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
export type ComboboxOption = {
value: string
label: string
}
type OptionComboboxProps = {
value: string
options: ComboboxOption[]
placeholder: string
searchPlaceholder?: string
emptyText?: string
disabled?: boolean
onChange: (value: string) => void
renderOptionAction?: (option: ComboboxOption) => ReactNode
}
export function OptionCombobox({
value,
options,
placeholder,
searchPlaceholder = "请输入关键字搜索",
emptyText = "没有可选项",
disabled = false,
onChange,
renderOptionAction,
}: OptionComboboxProps) {
const selectedLabel =
options.find((option) => option.value === value)?.label ?? placeholder
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
disabled={disabled}
/>
}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => onChange(option.value)}
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<div className="flex min-w-0 items-center">
<CheckIcon
className={cn(
"mr-2 size-4 shrink-0",
option.value === value ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{option.label}</span>
</div>
{renderOptionAction ? (
<div
className="shrink-0"
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
>
{renderOptionAction(option)}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import type * as React from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Maximize2Icon, Minimize2Icon, XIcon } from "lucide-react";
const dialogSizeClassName = {
sm: "max-w-md sm:max-w-md",
md: "max-w-xl sm:max-w-xl",
lg: "max-w-2xl sm:max-w-2xl",
xl: "max-w-4xl sm:max-w-4xl",
} as const;
type ProjectDialogSize = keyof typeof dialogSizeClassName;
type ProjectDialogProps = React.ComponentProps<typeof Dialog> & {
title: React.ReactNode;
description?: React.ReactNode;
size?: ProjectDialogSize;
children: React.ReactNode;
footer?: React.ReactNode;
contentClassName?: string;
headerClassName?: string;
bodyClassName?: string;
footerClassName?: string;
showCloseButton?: boolean;
closeOnEsc?: boolean;
allowFullscreen?: boolean;
defaultFullscreen?: boolean;
bodyScrollable?: boolean;
};
function ProjectDialog({
open,
onOpenChange,
title,
description,
size = "md",
children,
footer,
contentClassName,
headerClassName,
bodyClassName,
footerClassName,
showCloseButton = true,
closeOnEsc = false,
allowFullscreen = false,
defaultFullscreen = false,
bodyScrollable = true,
}: ProjectDialogProps) {
const [fullscreen, setFullscreen] = useState(defaultFullscreen);
function handleOpenChange(nextOpen: boolean, eventDetails: unknown) {
const reason = (eventDetails as { reason?: string } | undefined)?.reason;
if (!nextOpen && !closeOnEsc && reason === "escape-key") {
return;
}
if (!nextOpen) {
setFullscreen(defaultFullscreen);
}
onOpenChange?.(nextOpen, eventDetails as never);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<style jsx>{`
.project-dialog-native-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.project-dialog-native-scrollbar::-webkit-scrollbar {
width: 10px;
}
.project-dialog-native-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.project-dialog-native-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border: 2px solid transparent;
border-radius: 9999px;
background-clip: content-box;
}
.project-dialog-native-scrollbar::-webkit-scrollbar-thumb:hover {
background: color-mix(
in srgb,
hsl(var(--border)) 80%,
hsl(var(--foreground))
);
border: 2px solid transparent;
background-clip: content-box;
}
`}</style>
<DialogContent
className={cn(
"flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0",
fullscreen
? "top-5 left-5 h-[calc(100vh-40px)] max-h-[calc(100vh-40px)] w-[calc(100vw-40px)] max-w-[calc(100vw-40px)] translate-x-0 translate-y-0 rounded-xl sm:max-w-[calc(100vw-40px)]"
: dialogSizeClassName[size],
contentClassName,
)}
showCloseButton={false}
>
{(allowFullscreen || showCloseButton) && (
<div className="absolute top-2 right-2 z-10 flex items-center gap-1">
{allowFullscreen ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setFullscreen((value) => !value)}
>
{fullscreen ? <Minimize2Icon /> : <Maximize2Icon />}
<span className="sr-only">
{fullscreen ? "退出全屏" : "全屏显示"}
</span>
</Button>
) : null}
{showCloseButton ? (
<DialogClose
render={<Button type="button" variant="ghost" size="icon-sm" />}
>
<XIcon />
<span className="sr-only"></span>
</DialogClose>
) : null}
</div>
)}
<DialogHeader className={cn("shrink-0 px-6 py-3", headerClassName)}>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
{bodyScrollable ? (
<div
className={cn(
"project-dialog-native-scrollbar min-h-0 flex-1 overflow-y-auto",
bodyClassName,
)}
>
<div className="space-y-4 p-6">{children}</div>
</div>
) : (
<div className={cn("min-h-0 flex-1", bodyClassName)}>{children}</div>
)}
{footer ? (
<DialogFooter
className={cn("mx-0 mb-0 shrink-0 px-6 py-4", footerClassName)}
>
{footer}
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
);
}
export { ProjectDialog };
+161
View File
@@ -0,0 +1,161 @@
"use client"
import { useEffect } from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import {
BoldIcon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
RedoIcon,
UndoIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
import { Separator } from "@/components/ui/separator"
type RichTextEditorProps = {
content: string
onChange: (html: string) => void
placeholder?: string
disabled?: boolean
}
export function RichTextEditor({
content,
onChange,
placeholder = "输入内容...",
disabled = false,
}: RichTextEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder,
}),
],
content,
editable: !disabled,
onUpdate: ({ editor }) => {
const html = editor.getHTML()
onChange(html)
},
editorProps: {
attributes: {
class:
"min-h-64 max-h-96 overflow-y-auto px-4 py-3 text-sm leading-7 text-slate-900 outline-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p]:mb-2 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:mb-1 [&_blockquote]:border-l-4 [&_blockquote]:border-muted-foreground [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground",
},
},
})
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content)
}
}, [content, editor])
useEffect(() => {
if (editor) {
editor.setEditable(!disabled)
}
}, [disabled, editor])
if (!editor) {
return null
}
return (
<div className="rounded-lg border bg-background">
<div className="flex items-center gap-1 border-b p-2">
<ToggleGroup className="flex-wrap gap-1">
<ToggleGroupItem
value="undo"
aria-label="撤销"
disabled={!editor.can().undo() || disabled}
onClick={() => editor.chain().focus().undo().run()}
>
<UndoIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="redo"
aria-label="重做"
disabled={!editor.can().redo() || disabled}
onClick={() => editor.chain().focus().redo().run()}
>
<RedoIcon className="size-4" />
</ToggleGroupItem>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToggleGroupItem
value="bold"
aria-label="粗体"
disabled={disabled}
pressed={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<BoldIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label="斜体"
disabled={disabled}
pressed={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<ItalicIcon className="size-4" />
</ToggleGroupItem>
<Separator orientation="vertical" className="mx-1 h-6" />
<ToggleGroupItem
value="bulletList"
aria-label="无序列表"
disabled={disabled}
pressed={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<ListIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="orderedList"
aria-label="有序列表"
disabled={disabled}
pressed={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrderedIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="blockquote"
aria-label="引用"
disabled={disabled}
pressed={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<QuoteIcon className="size-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="p-2">
<EditorContent editor={editor} />
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { TrendingUpIcon, TrendingDownIcon } from "lucide-react"
export function SectionCards() {
return (
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
12
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon
/>
+2
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
26
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon />
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
API {" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
8
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon
/>
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
RAG {" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground"></div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
3
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingDownIcon />
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
IM {" "}
<TrendingDownIcon className="size-4" />
</div>
<div className="text-muted-foreground"></div>
</CardFooter>
</Card>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
"use client"
import { useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import { getPageTitle } from "@/lib/navigation"
import { ThemeToggle } from "@/components/theme-toggle"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"
const SIDEBAR_STORAGE_KEY = "dashboard_sidebar_open"
export function SiteHeader() {
const pathname = usePathname()
const { open, setOpen, isMobile } = useSidebar()
const pageTitle = getPageTitle(pathname)
const hasRestoredRef = useRef(false)
useEffect(() => {
if (hasRestoredRef.current || isMobile) {
return
}
hasRestoredRef.current = true
const storedValue = window.localStorage.getItem(SIDEBAR_STORAGE_KEY)
if (storedValue === null) {
return
}
setOpen(storedValue === "true")
}, [isMobile, setOpen])
useEffect(() => {
if (!hasRestoredRef.current || isMobile) {
return
}
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(open))
}, [isMobile, open])
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center justify-between gap-3 px-4 lg:px-6">
<div className="flex min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 h-4 data-vertical:self-auto"
/>
<div className="min-w-0">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</div>
<div className="flex items-center justify-end gap-3">
<ThemeToggle />
</div>
</div>
</header>
)
}
+17
View File
@@ -0,0 +1,17 @@
"use client"
import type { ReactNode } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
+64
View File
@@ -0,0 +1,64 @@
"use client"
import { useSyncExternalStore } from "react"
import { LaptopIcon, MoonIcon, SunIcon } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
type ThemeMode = "light" | "dark" | "system"
const themeOptions: Array<{
value: ThemeMode
label: string
icon: typeof SunIcon
}> = [
{ value: "light", label: "浅色模式", icon: SunIcon },
{ value: "dark", label: "深色模式", icon: MoonIcon },
{ value: "system", label: "跟随系统", icon: LaptopIcon },
]
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
)
const activeTheme = mounted ? ((theme as ThemeMode | undefined) ?? "system") : "system"
const ActiveIcon =
themeOptions.find((option) => option.value === activeTheme)?.icon ?? LaptopIcon
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />} aria-label="切换主题">
<ActiveIcon />
{/* <span className="hidden sm:inline">主题</span> */}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
<DropdownMenuRadioGroup
value={activeTheme}
onValueChange={(value) => setTheme(value as ThemeMode)}
>
{themeOptions.map((option) => {
const Icon = option.icon
return (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<Icon />
{option.label}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+87
View File
@@ -0,0 +1,87 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+60
View File
@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg 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 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background 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-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+221
View File
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -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-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[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-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug 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-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+356
View File
@@ -0,0 +1,356 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 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 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+297
View File
@@ -0,0 +1,297 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none 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}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**: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",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+271
View File
@@ -0,0 +1,271 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 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 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 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}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 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">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 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">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+157
View File
@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate 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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+131
View File
@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 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 DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.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-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 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 DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground 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}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg 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 )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.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}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
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-4 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-1.5 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-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 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-0.5 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-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 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,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 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:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
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 }
+280
View File
@@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("min-w-36 rounded-lg 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=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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", className )}
{...props}
/>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 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:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn("min-w-32 rounded-lg 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 {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
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
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
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.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
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-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-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-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *: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
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg 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=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 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}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
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.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
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 }
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
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-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] 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=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] 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=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.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 }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
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,
}
+724
View File
@@ -0,0 +1,724 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { 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 { PanelLeftIcon } from "lucide-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) group-data-[side=left]:border-r group-data-[side=right]:border-l",
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}
>
<PanelLeftIcon />
<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-0 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,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
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
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
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
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
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-0", 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-primary data-active:font-medium data-active:text-sidebar-primary-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({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
...(isActive ? { "data-active": "" } : {}),
} as React.ButtonHTMLAttributes<HTMLButtonElement> & Record<string, unknown>,
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
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
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
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({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
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-primary data-active:text-sidebar-primary-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
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,
}
+13
View File
@@ -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 }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
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 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 }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -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-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm 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 data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }