Init
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 # HtmlEditor(TipTap 配置)
|
||||
markdown.tsx # MarkdownEditor(TipTap 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 编辑器可在下一阶段迁移到同一核心配置,避免破坏现有发送交互。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
}
|
||||
|
||||
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) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user