chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { describe, it } from "node:test";
const providerSource = await readFile(
new URL("./agent-realtime-provider.tsx", import.meta.url),
"utf8",
).catch(() => "");
const workbenchLayoutSource = await readFile(
new URL("../app/workbench/layout.tsx", import.meta.url),
"utf8",
);
const dashboardConversationsPageSource = await readFile(
new URL("../app/dashboard/conversations/page.tsx", import.meta.url),
"utf8",
);
const conversationWorkbenchSource = await readFile(
new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
"utf8",
);
describe("AgentRealtimeProvider placement", () => {
it("owns the agent realtime hook", () => {
assert.match(providerSource, /export function AgentRealtimeProvider/);
assert.match(providerSource, /useAgentConversationRealtime\(\)/);
});
it("is mounted at the workbench layout level", () => {
assert.match(workbenchLayoutSource, /import \{ AgentRealtimeProvider \}/);
assert.match(workbenchLayoutSource, /<AgentRealtimeProvider \/>/);
});
it("keeps dashboard conversations realtime without tying it to ConversationWorkbench", () => {
assert.match(dashboardConversationsPageSource, /<AgentRealtimeProvider \/>/);
assert.doesNotMatch(conversationWorkbenchSource, /useAgentConversationRealtime/);
});
});

View File

@@ -0,0 +1,9 @@
"use client"
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime"
export function AgentRealtimeProvider() {
useAgentConversationRealtime()
return null
}

View File

@@ -0,0 +1,45 @@
"use client"
import { createContext, useCallback, useContext, useEffect, type ReactNode } from "react"
import { toast } from "sonner"
import { useI18n } from "@/i18n/provider"
import { isApiRequestError } from "@/lib/api/client"
type ApiErrorHandler = (error: unknown) => void
const ApiErrorContext = createContext<ApiErrorHandler>(() => {})
export function ApiErrorProvider({ children }: { children: ReactNode }) {
const t = useI18n()
const handleApiError = useCallback<ApiErrorHandler>((error) => {
const message = error instanceof Error && error.message
? error.message
: t("api.requestFailed")
toast.error(message)
}, [t])
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
if (!isApiRequestError(event.reason)) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
handleApiError(event.reason)
}
window.addEventListener("unhandledrejection", handleUnhandledRejection, true)
return () => window.removeEventListener("unhandledrejection", handleUnhandledRejection, true)
}, [handleApiError])
return (
<ApiErrorContext.Provider value={handleApiError}>
{children}
</ApiErrorContext.Provider>
)
}
export function useApiErrorHandler() {
return useContext(ApiErrorContext)
}

View File

@@ -0,0 +1,77 @@
"use client"
import type { ComponentProps } from "react"
import { useMemo } from "react"
import { useI18n } from "@/i18n/provider"
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 { WorkspaceSwitcher } from "@/components/workspace-switcher"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
const t = useI18n()
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 || t("common.notSignedIn"),
email: session?.user.username || t("common.guest"),
avatar: session?.user.avatar || "",
}
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<WorkspaceSwitcher
currentWorkspace="dashboard"
variant="sidebar"
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{navSections.map((section) => (
<NavMain
key={section.titleKey}
icon={section.icon}
sectionKey={section.titleKey}
title={t(section.titleKey)}
items={section.items.map((item) => ({
...item,
title: t(item.titleKey),
}))}
/>
))}
{secondaryNavItems.length > 0 ? (
<NavSecondary items={secondaryNavItems} className="mt-auto" />
) : null}
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const source = await readFile(new URL("./session-provider.tsx", import.meta.url), "utf8");
test("refreshSession preserves the stored token when the profile payload omits it", () => {
assert.match(source, /accessToken:\s*profile\.accessToken\s*\|\|\s*stored\.accessToken/);
assert.match(source, /expiresAt:\s*profile\.expiresAt\s*\|\|\s*stored\.expiresAt/);
});
test("refreshSession only clears session for explicit auth error codes", () => {
assert.match(source, /errorCode\s*===\s*3000\s*\|\|\s*errorCode\s*===\s*3002/);
assert.doesNotMatch(source, /catch\s*\([^)]*\)\s*\{\s*clearSession\(\)/);
});

View File

@@ -0,0 +1,76 @@
"use client"
import {
createContext,
startTransition,
useContext,
useCallback,
useEffect,
type ReactNode,
} from "react"
import { usePathname, useRouter } from "next/navigation"
import { SessionProvider, useSession } from "@/components/session-provider"
import { 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 }) {
return (
<SessionProvider>
<DashboardAuthProvider>{children}</DashboardAuthProvider>
</SessionProvider>
)
}
function DashboardAuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const { session, ready, refreshSession, signOut: endSession } = useSession()
const isDashboardLoginRoute = pathname?.startsWith("/dashboard/login") ?? false
const requiresAuth =
((pathname?.startsWith("/dashboard") ?? false) ||
(pathname?.startsWith("/workbench") ?? false)) &&
!isDashboardLoginRoute
const refreshProfile = useCallback(() => refreshSession(), [refreshSession])
async function signOut() {
try {
await endSession()
} finally {
startTransition(() => {
router.replace("/dashboard/login")
})
}
}
useEffect(() => {
if (ready && !session && requiresAuth) {
startTransition(() => {
router.replace("/dashboard/login")
})
}
}, [ready, requiresAuth, router, session])
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
}

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useMemo } 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 { useI18n } from "@/i18n/provider";
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";
function createChangePasswordSchema(t: (key: string) => string) {
return z
.object({
password: z.string().trim().min(1, t("account.passwordRequired")),
confirmPassword: z.string().trim().min(1, t("account.confirmPasswordRequired")),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: t("account.passwordMismatch"),
});
}
type ChangePasswordForm = {
password: string;
confirmPassword: string;
};
const emptyForm: ChangePasswordForm = {
password: "",
confirmPassword: "",
};
type ChangePasswordDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => Promise<void>;
};
export function ChangePasswordDialog({
open,
onOpenChange,
onSuccess,
}: ChangePasswordDialogProps) {
const t = useI18n();
const changePasswordSchema = useMemo(() => createChangePasswordSchema(t), [t]);
const changePasswordResolver = useMemo(
() =>
zodResolver(changePasswordSchema as never) as Resolver<
z.input<typeof changePasswordSchema>,
undefined,
z.output<typeof changePasswordSchema>
>,
[changePasswordSchema],
);
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(t("account.passwordChanged"));
onOpenChange(false);
await onSuccess();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("account.changePasswordFailed"));
}
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={t("account.changePassword")}
description={t("account.changePasswordDescription")}
size="sm"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{t("account.cancel")}
</Button>
<Button type="submit" form="change-password-form" disabled={isSubmitting}>
{isSubmitting ? t("account.submitting") : t("account.confirmChange")}
</Button>
</>
}
>
<form id="change-password-form" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<Field data-invalid={!!errors.password}>
<FieldLabel htmlFor="change-password-password">{t("account.newPassword")}</FieldLabel>
<FieldContent>
<Input
id="change-password-password"
type="password"
placeholder={t("account.newPasswordPlaceholder")}
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">{t("account.confirmPassword")}</FieldLabel>
<FieldContent>
<Input
id="change-password-confirm"
type="password"
placeholder={t("account.confirmPasswordPlaceholder")}
autoComplete="new-password"
aria-invalid={!!errors.confirmPassword}
{...register("confirmPassword")}
/>
<FieldError errors={[errors.confirmPassword]} />
</FieldContent>
</Field>
</div>
</form>
</ProjectDialog>
);
}

View File

@@ -0,0 +1,159 @@
"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"
import { useI18n } from "@/i18n/provider"
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 },
]
export function ChartAreaInteractive() {
const t = useI18n()
const [timeRange, setTimeRange] = React.useState("14d")
const filteredData = chartData.slice(timeRange === "7d" ? -7 : -14)
const chartConfig = {
activeSessions: {
label: t("scaffold.activeSessions"),
color: "var(--primary)",
},
indexedDocs: {
label: t("scaffold.indexedDocs"),
color: "var(--chart-2)",
},
} satisfies ChartConfig
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>{t("scaffold.recentActivity")}</CardTitle>
<CardDescription>
{t("scaffold.activityDescription")}
</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">{t("scaffold.last14Days")}</ToggleGroupItem>
<ToggleGroupItem value="7d">{t("scaffold.last7Days")}</ToggleGroupItem>
</ToggleGroup>
<Select
value={timeRange}
onValueChange={(value) => {
if (value) {
setTimeRange(value)
}
}}
>
<SelectTrigger
className="flex w-32 @[767px]/card:hidden"
size="sm"
aria-label={t("scaffold.selectTimeRange")}
>
<SelectValue placeholder={t("scaffold.last14Days")} />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="14d" className="rounded-lg">
{t("scaffold.last14Days")}
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
{t("scaffold.last7Days")}
</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>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import { memo } from "react"
import { BotIcon, Loader2Icon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
import { useI18n } from "@/i18n/provider"
import { getAgentActivityLabel } from "@/lib/agent-activity-label"
import { cn } from "@/lib/utils"
export type AgentActivityBubbleProps = {
stepCode: string
toolName?: string
className?: string
isCustomer?: boolean
}
export const AgentActivityBubble = memo(function AgentActivityBubble({
stepCode,
toolName,
className,
isCustomer = true,
}: AgentActivityBubbleProps) {
const t = useI18n()
const label = getAgentActivityLabel({ stepCode, toolName, isCustomer }, t)
return (
<ConversationMessageRow
align="start"
avatar={
<Avatar className="size-8">
<AvatarFallback className="bg-primary/10 text-primary">
<BotIcon aria-hidden="true" className="size-4" />
</AvatarFallback>
</Avatar>
}
contentClassName="max-w-[86%] gap-1.5"
className={cn("my-1 animate-in fade-in-50 duration-200", className)}
>
<ConversationMessageBubble
variant="system"
role="status"
aria-live="polite"
className="flex items-center gap-2 rounded-lg border border-solid border-border/60 bg-muted/50 px-3 py-2 text-xs text-muted-foreground shadow-sm"
>
<Loader2Icon aria-hidden="true" className="size-3.5 shrink-0 animate-spin text-primary" />
<span className="font-medium text-foreground/80">{label}</span>
</ConversationMessageBubble>
</ConversationMessageRow>
)
})

View File

@@ -0,0 +1,64 @@
"use client"
import type { ReactNode } from "react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { cn } from "@/lib/utils"
export type ConversationMessageVariant =
| "customer"
| "agent"
| "ai"
| "system"
| "recalled"
type ConversationMessageBubbleProps = React.ComponentProps<"div"> & {
variant: ConversationMessageVariant
children: ReactNode
className?: string
}
function getBubbleVariant(variant: ConversationMessageVariant) {
switch (variant) {
case "customer":
return "secondary" as const
case "system":
return "muted" as const
case "ai":
return "tinted" as const
case "agent":
return "default" as const
case "recalled":
return "outline" as const
default:
return "secondary" as const
}
}
function getContentClassName(variant: ConversationMessageVariant) {
switch (variant) {
case "agent":
return "bg-emerald-600 text-white"
case "system":
return "border-dashed text-muted-foreground"
case "recalled":
return "border-dashed bg-muted/40 text-muted-foreground"
default:
return undefined
}
}
export function ConversationMessageBubble({
variant,
children,
className,
...props
}: ConversationMessageBubbleProps) {
return (
<Bubble variant={getBubbleVariant(variant)} {...props}>
<BubbleContent className={cn("rounded-2xl px-4 py-3 leading-6", getContentClassName(variant), className)}>
{children}
</BubbleContent>
</Bubble>
)
}

View File

@@ -0,0 +1,109 @@
"use client"
import type { ReactNode } from "react"
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message"
import { cn } from "@/lib/utils"
type ConversationMessageRowProps = {
align: "start" | "end"
centered?: boolean
header?: ReactNode
footer?: ReactNode
avatar?: ReactNode
children: ReactNode
className?: string
avatarClassName?: string
contentClassName?: string
headerClassName?: string
footerClassName?: string
}
export function ConversationMessageRow({
align,
centered = false,
header,
footer,
avatar,
children,
className,
avatarClassName,
contentClassName,
headerClassName,
footerClassName,
}: ConversationMessageRowProps) {
if (centered) {
return (
<Message
align="start"
className={cn("justify-center", className)}
>
<MessageContent className={cn("w-fit max-w-[85%] items-center", contentClassName)}>
{header ? (
<MessageHeader className={cn("justify-center text-center", headerClassName)}>
{header}
</MessageHeader>
) : null}
{children}
{footer ? (
<MessageFooter className={cn("justify-center text-center", footerClassName)}>
{footer}
</MessageFooter>
) : null}
</MessageContent>
</Message>
)
}
return (
<Message align={align} className={className}>
{avatar ? (
<MessageAvatar
className={cn(
"self-start group-has-data-[slot=message-footer]/message:translate-y-0 bg-transparent",
avatarClassName,
)}
>
{avatar}
</MessageAvatar>
) : null}
<MessageContent
className={cn(
"max-w-[85%]",
align === "end" ? "items-end" : "items-start",
contentClassName,
)}
>
{header ? (
<MessageHeader
className={cn(
"gap-2 px-0",
align === "end" ? "justify-end text-right" : "justify-start text-left",
headerClassName,
)}
>
{header}
</MessageHeader>
) : null}
{children}
{footer ? (
<MessageFooter
className={cn(
"gap-2 px-0",
align === "end" ? "justify-end text-right" : "justify-start text-left",
footerClassName,
)}
>
{footer}
</MessageFooter>
) : null}
</MessageContent>
</Message>
)
}

View File

@@ -0,0 +1,151 @@
"use client"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
type ReactNode,
type UIEvent,
} from "react"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
} from "@/components/ui/message-scroller"
import { cn } from "@/lib/utils"
export type ConversationMessageScrollerHandle = {
scrollToBottom: () => void
}
type ConversationMessageScrollerProps = {
children: ReactNode
className?: string
viewportClassName?: string
contentClassName?: string
hasMoreOlder?: boolean
loadingOlder?: boolean
onLoadOlder?: () => void | Promise<void>
onNearBottomChange?: (nearBottom: boolean) => void
onNearBottomVisible?: () => void
topSlot?: ReactNode
scrollThreshold?: number
}
const ConversationMessageScrollerInner = forwardRef<
ConversationMessageScrollerHandle,
ConversationMessageScrollerProps
>(function ConversationMessageScrollerInner(
{
children,
className,
viewportClassName,
contentClassName,
hasMoreOlder = false,
loadingOlder = false,
onLoadOlder,
onNearBottomChange,
onNearBottomVisible,
topSlot,
scrollThreshold = 120,
},
ref,
) {
const { scrollToEnd } = useMessageScroller()
const scrollable = useMessageScrollerScrollable()
const loadingRef = useRef(false)
const nearBottomRef = useRef(false)
const onLoadOlderRef = useRef(onLoadOlder)
const onNearBottomChangeRef = useRef(onNearBottomChange)
const onNearBottomVisibleRef = useRef(onNearBottomVisible)
useEffect(() => {
onLoadOlderRef.current = onLoadOlder
}, [onLoadOlder])
useEffect(() => {
onNearBottomChangeRef.current = onNearBottomChange
}, [onNearBottomChange])
useEffect(() => {
onNearBottomVisibleRef.current = onNearBottomVisible
}, [onNearBottomVisible])
useEffect(() => {
loadingRef.current = loadingOlder
}, [loadingOlder])
useEffect(() => {
const nearBottom = !scrollable.end
nearBottomRef.current = nearBottom
onNearBottomChangeRef.current?.(nearBottom)
if (nearBottom) {
onNearBottomVisibleRef.current?.()
}
}, [scrollable.end])
useImperativeHandle(ref, () => ({
scrollToBottom: () => {
scrollToEnd({ behavior: "auto" })
},
}), [scrollToEnd])
const maybeLoadOlder = useCallback((viewport: HTMLElement) => {
if (!hasMoreOlder || loadingRef.current || !onLoadOlderRef.current) {
return
}
if (viewport.scrollTop > scrollThreshold) {
return
}
loadingRef.current = true
void Promise.resolve(onLoadOlderRef.current()).finally(() => {
loadingRef.current = false
})
}, [hasMoreOlder, scrollThreshold])
const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
maybeLoadOlder(event.currentTarget)
},
[maybeLoadOlder],
)
return (
<MessageScroller className={className}>
<MessageScrollerViewport
className={cn("agent-desk-scrollbar bg-muted/10", viewportClassName)}
onScroll={handleScroll}
preserveScrollOnPrepend
>
<MessageScrollerContent
className={cn("gap-4 px-6 py-5", contentClassName)}
>
{topSlot}
{children}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
)
})
export const ConversationMessageScroller = forwardRef<
ConversationMessageScrollerHandle,
ConversationMessageScrollerProps
>(function ConversationMessageScroller(props, ref) {
return (
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<ConversationMessageScrollerInner {...props} ref={ref} />
</MessageScrollerProvider>
)
})
export { MessageScrollerItem as ConversationMessageScrollerItem }

View File

@@ -0,0 +1,729 @@
"use client"
import { useEffect, useRef, useState, type ChangeEvent } from "react"
import Placeholder from "@tiptap/extension-placeholder"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import {
ImageIcon,
Loader2Icon,
MessageSquareTextIcon,
PaperclipIcon,
SendHorizonalIcon,
SendIcon,
SparklesIcon,
XIcon,
} 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 {
buildSendableEditorHTML,
hasUploadingEditorImages,
markEditorImageUploadedByTitle,
MessageImageExtension,
removeEditorImageByTitle,
revokeEditorObjectUrl,
revokeEditorObjectUrls,
setEditorImageUploadingByTitle,
type UploadedEditorImage,
} from "@/lib/im-editor-image"
import { generateUUID } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
export type UploadedMessageEditorImage = UploadedEditorImage & {
url: string
}
export type MessageEditorQuickReply = {
id: number
groupName?: string
title: string
content: string
}
type SharedMessageEditorVariant = "customer" | "agent"
export type SharedMessageEditorAICopilot = {
onSuggest: () => Promise<string | null>
loading?: boolean
}
export type SharedMessageEditorProps = {
variant: SharedMessageEditorVariant
conversationId?: number | null
disabled?: boolean
uploadingAsset?: boolean
manageLocalUploading?: boolean
quickReplies?: {
open: boolean
loading: boolean
items: MessageEditorQuickReply[]
onOpenChange: (open: boolean) => void
}
aiCopilot?: SharedMessageEditorAICopilot
onSend: (html: string) => Promise<void>
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
onSendAttachment: (file: File) => Promise<void>
onTyping?: () => void
}
export function SharedMessageEditor({
variant,
conversationId,
disabled = false,
uploadingAsset = false,
manageLocalUploading = false,
quickReplies,
aiCopilot,
onSend,
onUploadImage,
onSendAttachment,
onTyping,
}: SharedMessageEditorProps) {
const t = useI18n()
const [localUploading, setLocalUploading] = useState(false)
const [quickReplySearch, setQuickReplySearch] = useState("")
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
const quickReplySearchRef = useRef(quickReplySearch)
quickReplySearchRef.current = quickReplySearch
const isSlashTriggeredRef = useRef(false)
const quickRepliesRef = useRef(quickReplies)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
const onSendRef = useRef(onSend)
const onUploadImageRef = useRef(onUploadImage)
const onSendAttachmentRef = useRef(onSendAttachment)
const onTypingRef = useRef(onTyping)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
placeholderRef.current = t("conversation.editorPlaceholder")
useEffect(() => {
quickRepliesRef.current = quickReplies
}, [quickReplies])
useEffect(() => {
const objectUrls = objectUrlsRef.current
return () => {
revokeEditorObjectUrls(objectUrls)
}
}, [])
useEffect(() => {
onSendRef.current = onSend
}, [onSend])
useEffect(() => {
onUploadImageRef.current = onUploadImage
}, [onUploadImage])
useEffect(() => {
onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
onTypingRef.current = onTyping
}, [onTyping])
useEffect(() => {
setSuggestedReply(null)
}, [conversationId])
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
horizontalRule: false,
}),
MessageImageExtension,
Placeholder.configure({
placeholder: () => placeholderRef.current,
}),
],
content: "",
onUpdate: ({ editor }) => {
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
onTypingRef.current?.()
}
if (!quickRepliesRef.current || quickRepliesRef.current.loading) {
return
}
const { $from } = editor.state.selection
const blockText = $from.parent.textContent
const trimmed = blockText.trimStart()
const match = trimmed.match(/^\/([^\s]*)$/)
if (match) {
isSlashTriggeredRef.current = true
const query = match[1]
setQuickReplySearch(query)
if (!quickRepliesRef.current.open) {
quickRepliesRef.current.onOpenChange(true)
}
} else if (isSlashTriggeredRef.current && quickRepliesRef.current.open) {
isSlashTriggeredRef.current = false
setQuickReplySearch("")
quickRepliesRef.current.onOpenChange(false)
}
},
editorProps: {
attributes: {
class: getEditorClassName(variant),
},
handleKeyDown: (_view, event) => {
if (event.key === "Escape" && quickRepliesRef.current?.open) {
event.preventDefault()
quickRepliesRef.current.onOpenChange(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
return true
}
if (
(event.key === "Enter" || event.key === "Tab") &&
!event.shiftKey &&
quickRepliesRef.current?.open
) {
const query = quickReplySearchRef.current.toLowerCase().trim()
const items = quickRepliesRef.current.items
const matching = items.filter((item) => {
if (!query) return true
const target = `${item.groupName ?? ""} ${item.title} ${item.content}`.toLowerCase()
return target.includes(query)
})
if (matching.length > 0) {
event.preventDefault()
handleInsertQuickReply(matching[0])
return true
}
if (event.key === "Enter") {
event.preventDefault()
return true
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
void handleSend()
return true
}
return false
},
handlePaste: (_view, event) => {
if (disabled || isUploading) {
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 && !isUploading)
}, [disabled, editor, isUploading])
useEffect(() => {
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
return
}
requestAnimationFrame(() => {
editor.commands.focus()
})
}, [disabled, editor, isUploading])
async function handleSend() {
if (!editor || disabled || isUploading) {
return
}
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
return
}
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
setSuggestedReply(null)
if (!isCustomer) {
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
}
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || disabled || isUploading) {
restoreFocusIfNeeded()
return
}
await insertUploadedImage(file)
}
async function insertUploadedImage(file: File) {
if (!editor || disabled || isUploading) {
return
}
shouldRestoreFocusRef.current = true
const objectUrl = URL.createObjectURL(file)
objectUrlsRef.current.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
.focus()
.setImage({
src: objectUrl,
alt: file.name || "uploading-image",
title: placeholderId,
})
.run()
setEditorImageUploadingByTitle(editor, placeholderId)
try {
setLocalUploading(true)
const uploaded = await onUploadImageRef.current(file)
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId)
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return
}
markEditorImageUploadedByTitle(
editor,
placeholderId,
uploaded,
uploadedImagesRef.current
)
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
}
}
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || disabled || isUploading) {
restoreFocusIfNeeded()
return
}
shouldRestoreFocusRef.current = editor?.isFocused ?? true
try {
setLocalUploading(true)
await onSendAttachmentRef.current(file)
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (editor && !disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
}
}
function handleInsertQuickReply(item: MessageEditorQuickReply) {
if (!editor || disabled || isUploading) {
return
}
if (!item.content.trim()) {
return
}
const { $from } = editor.state.selection
const blockText = $from.parent.textContent
const trimmed = blockText.trimStart()
const match = trimmed.match(/^\/([^\s]*)$/)
if (match) {
const slashIndex = blockText.indexOf("/")
const blockStart = $from.start() + slashIndex
const matchLen = match[0].length
editor
.chain()
.focus()
.deleteRange({ from: blockStart, to: blockStart + matchLen })
.insertContent(item.content)
.run()
} else {
const fullText = editor.getText().trim()
const fullMatch = fullText.match(/^\/([^\s]*)$/)
if (fullMatch) {
editor.chain().focus().setContent(item.content).run()
} else {
editor.chain().focus().insertContent(item.content).run()
}
}
quickRepliesRef.current?.onOpenChange(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
function restoreFocusIfNeeded() {
if (editor && shouldRestoreFocusRef.current) {
requestAnimationFrame(() => {
editor.commands.focus()
})
}
}
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
async function handleTriggerSuggest() {
if (!aiCopilot || disabled || isUploading || isGenerating) {
return
}
setIsGeneratingSuggestion(true)
try {
const text = await aiCopilot.onSuggest()
if (text && text.trim()) {
setSuggestedReply(text.trim())
}
} finally {
setIsGeneratingSuggestion(false)
}
}
function handleInsertSuggestedReply() {
if (!editor || !suggestedReply) {
return
}
editor.chain().focus().insertContent(suggestedReply).run()
setSuggestedReply(null)
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
function handleDismissSuggestedReply() {
setSuggestedReply(null)
}
const editorContent = (
<>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleSelectImage}
/>
<input
ref={attachmentInputRef}
type="file"
className="hidden"
onChange={handleSelectAttachment}
/>
{!isCustomer && suggestedReply ? (
<div className="m-2 rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="flex items-center justify-between gap-2 pb-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-primary">
<SparklesIcon className="size-3.5" />
<span>{t("aiCopilot.suggestReply")}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-foreground"
onClick={handleDismissSuggestedReply}
aria-label={t("aiCopilot.discardReply")}
title={t("aiCopilot.discardReply")}
>
<XIcon className="size-3.5" />
</Button>
</div>
<div className="line-clamp-3 select-text text-xs leading-5 text-foreground/90 whitespace-pre-wrap">
{suggestedReply}
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleDismissSuggestedReply}
>
{t("aiCopilot.discardReply")}
</Button>
<Button
type="button"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={handleInsertSuggestedReply}
>
{t("aiCopilot.insertReply")}
</Button>
</div>
</div>
) : null}
{isCustomer ? (
<div className="min-h-10">
<EditorContent editor={editor} />
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden px-2 py-1">
<EditorContent editor={editor} className="h-full" />
</div>
)}
<div className={getToolbarClassName(variant)}>
<div className={isCustomer ? "flex items-center gap-1.5" : "flex items-center gap-1"}>
<Button
type="button"
variant="ghost"
size="icon"
className={getIconButtonClassName(variant)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
shouldRestoreFocusRef.current = editor?.isFocused ?? true
imageInputRef.current?.click()
}}
disabled={disabled || isUploading}
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
>
<ImageIcon className={isCustomer ? undefined : "size-4"} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={getIconButtonClassName(variant)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
shouldRestoreFocusRef.current = editor?.isFocused ?? true
attachmentInputRef.current?.click()
}}
disabled={disabled || isUploading}
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
>
<PaperclipIcon className={isCustomer ? undefined : "size-4"} />
</Button>
{quickReplies ? (
<Popover
open={quickReplies.open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setQuickReplySearch("")
isSlashTriggeredRef.current = false
}
quickReplies.onOpenChange(nextOpen)
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={disabled || isUploading || quickReplies.loading}
onMouseDown={(event) => event.preventDefault()}
title={t("conversation.quickReplySlashHint")}
aria-label={t("conversation.quickReplySlashHint")}
/>
}
>
<MessageSquareTextIcon className="size-4" />
</PopoverTrigger>
<PopoverContent
className="w-[30rem] p-0"
align="start"
initialFocus={!isSlashTriggeredRef.current}
>
<Command>
<CommandInput
value={quickReplySearch}
onValueChange={setQuickReplySearch}
placeholder={t("conversation.searchQuickReplies")}
/>
<CommandList>
<CommandEmpty>{t("conversation.emptyQuickReplies")}</CommandEmpty>
<CommandGroup>
{quickReplies.items.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>
) : null}
{!isCustomer && aiCopilot ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-primary hover:bg-primary/10"
disabled={disabled || isUploading || isGenerating || aiCopilot.loading}
onClick={handleTriggerSuggest}
title={isGenerating ? t("aiCopilot.generating") : t("aiCopilot.suggestReply")}
aria-label={t("aiCopilot.suggestReply")}
>
{isGenerating ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
</Button>
) : null}
{!isCustomer && quickReplies ? (
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline-block pl-1">
{t("conversation.quickReplySlashHint")}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<p className={isCustomer ? "hidden text-[10px] text-muted-foreground sm:block" : "text-xs text-muted-foreground"}>
{t("conversation.enterToSend")}
</p>
{isCustomer ? (
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-label={t("conversation.send")}
title={t("conversation.send")}
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
>
<SendHorizonalIcon />
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
>
<SendIcon className="mr-1 size-4" />
{isUploading ? t("conversation.uploading") : t("conversation.send")}
</Button>
)}
</div>
</div>
</>
)
if (isCustomer) {
return (
<div className="px-3 pt-2 pb-3">
<div className="rounded-xl border border-border bg-background p-2 shadow-[0_8px_24px_rgba(15,23,42,0.05)] dark:shadow-none">
{editorContent}
</div>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col p-2">
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border bg-card">
{editorContent}
</div>
</div>
)
}
function getEditorClassName(variant: SharedMessageEditorVariant) {
if (variant === "customer") {
return "agent-desk-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-lg [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55"
}
return "h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.agent-desk-editor-image-wrap]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-md [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
}
function getToolbarClassName(variant: SharedMessageEditorVariant) {
if (variant === "customer") {
return "mt-2 flex items-center justify-between"
}
return "flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2"
}
function getIconButtonClassName(variant: SharedMessageEditorVariant) {
if (variant === "customer") {
return "text-muted-foreground hover:bg-muted hover:text-foreground"
}
return "size-8"
}
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(data: DataTransfer | null) {
if (!data) {
return null
}
for (const item of Array.from(data.items)) {
if (item.kind === "file" && item.type.startsWith("image/")) {
return item.getAsFile()
}
}
return null
}

View File

@@ -0,0 +1,289 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"
import { toast } from "sonner"
import { DashboardCrudFormDialog } from "@/components/dashboard/crud"
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 { useI18n } from "@/i18n/provider"
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 t = useI18n()
const resolvedPlaceholder = placeholder ?? t("companyPicker.placeholder")
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 : t("companyPicker.loadFailed"))
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [open, trimmedKeyword, t])
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 || t("companyPicker.fallback", { id: value }) : resolvedPlaceholder
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(t("companyPicker.created", { name: created.name }))
} catch (error) {
toast.error(error instanceof Error ? error.message : t("companyPicker.createFailed"))
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={t("companyPicker.searchPlaceholder")}
/>
<CommandList>
{loading ? <CommandEmpty>{t("companyPicker.loading")}</CommandEmpty> : null}
{!loading && options.length === 0 ? <CommandEmpty>{t("companyPicker.empty")}</CommandEmpty> : null}
{!loading ? (
<CommandGroup heading={t("companyPicker.results")}>
<CommandItem
value="none"
data-checked={Number(value) <= 0}
onSelect={handleClear}
>
<span>{t("companyPicker.none")}</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={t("companyPicker.actions")}>
<CommandItem
value={`create ${trimmedKeyword}`}
onSelect={() => setCreateOpen(true)}
>
<PlusIcon className="size-4" />
<span className="truncate">{t("companyPicker.create", { name: trimmedKeyword })}</span>
</CommandItem>
</CommandGroup>
</>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<DashboardCrudFormDialog<AdminCompany, CreateAdminCompanyPayload>
open={createOpen}
saving={createSaving}
item={null}
itemId={null}
fields={[
{
name: "name",
label: t("company.columnName"),
placeholder: t("company.namePlaceholder"),
defaultValue: trimmedKeyword,
required: true,
requiredMessage: t("company.nameRequired"),
trim: true,
},
{
name: "code",
label: t("company.columnCode"),
placeholder: t("company.optional"),
trim: true,
},
{
name: "remark",
label: t("company.columnRemark"),
placeholder: t("company.remarkPlaceholder"),
type: "textarea",
rows: 4,
trim: true,
},
]}
transformSubmitValues={(values) => ({
name: String(values.name ?? ""),
code: String(values.code ?? ""),
remark: String(values.remark ?? ""),
})}
labels={{
createTitle: t("company.createTitle"),
editTitle: t("company.editTitle"),
create: t("company.create"),
save: t("company.save"),
saving: t("company.saving"),
cancel: t("company.cancel"),
loadingDetail: t("company.loadingDetail"),
required: t("company.nameRequired"),
invalidNumber: t("company.nameRequired"),
minValue: () => t("company.nameRequired"),
maxValue: () => t("company.nameRequired"),
}}
onOpenChange={setCreateOpen}
onSubmit={handleCreateCompany}
/>
</>
)
}

View File

@@ -0,0 +1,122 @@
"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"
import { useI18n } from "@/i18n/provider"
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,
variant: "default",
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const t = useI18n()
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 ?? t("confirm.title"),
description: options.description ?? t("confirm.description"),
confirmText: options.confirmText ?? t("confirm.confirm"),
cancelText: options.cancelText ?? t("confirm.cancel"),
variant: options.variant ?? defaultState.variant,
})
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve
})
}, [t])
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 }

View File

@@ -0,0 +1,26 @@
import MarkdownIt from "markdown-it"
import TurndownService from "turndown"
const markdownIt = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*",
strongDelimiter: "**",
})
turndownService.keep(["table", "thead", "tbody", "tr", "th", "td"])
export function markdownToHtml(markdown: string) {
return markdownIt.render(markdown ?? "")
}
export function htmlToMarkdown(html: string) {
return turndownService.turndown(html ?? "")
}

View File

@@ -0,0 +1,73 @@
"use client"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useI18n } from "@/i18n/provider"
import {
CONTENT_MODE_OPTIONS,
type ContentMode,
} from "./types"
type EditorModeSwitchProps = {
value: ContentMode
allowedModes?: ReadonlyArray<ContentMode>
disabled?: boolean
onChange: (nextMode: ContentMode) => void
}
export function EditorModeSwitch({
value,
allowedModes = CONTENT_MODE_OPTIONS,
disabled = false,
onChange,
}: EditorModeSwitchProps) {
const t = useI18n()
const MODE_OPTIONS: Array<{ value: ContentMode; label: string }> = [
{ value: "markdown", label: t("editor.modeLabelMarkdown") },
{ value: "html", label: t("editor.modeLabelRichText") },
]
const options = MODE_OPTIONS.filter((option) => allowedModes.includes(option.value))
const activeLabel = options.find((option) => option.value === value)?.label ?? value
if (options.length <= 1) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="sm" className="mx-0.5 h-7 min-w-24 justify-between gap-2 bg-background px-2 text-xs font-medium shadow-none" />}
disabled={disabled}
aria-label={t("editor.modeSwitchLabel")}
>
<span>{activeLabel}</span>
<ChevronDownIcon className="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-32">
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextMode) => {
if (nextMode !== value) {
onChange(nextMode as ContentMode)
}
}}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value} disabled={disabled}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,376 @@
"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"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export type HtmlEditorRef = {
focus: () => void
}
type HtmlEditorProps = {
value: string
onChange: (nextValue: string) => void
mode: ContentMode
allowedModes: ReadonlyArray<ContentMode>
onModeChange: (nextMode: ContentMode) => void
fullscreen: boolean
onToggleFullscreen: () => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height: string
scrollMode: "editor" | "document"
}
export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
function HtmlEditor(
{
value,
onChange,
mode,
allowedModes,
onModeChange,
fullscreen,
onToggleFullscreen,
placeholder = "",
disabled = false,
onUploadImage,
height,
scrollMode,
},
ref
) {
const t = useI18n()
const imageInputRef = useRef<HTMLInputElement>(null)
const [previewOnly, setPreviewOnly] = useState(false)
const proseClassName = cn(
"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",
scrollMode === "editor" && "h-full overflow-y-auto",
scrollMode === "document" && "min-h-64 overflow-visible"
)
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(t("editor.promptLinkUrl"), 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}
allowedModes={allowedModes}
disabled={disabled}
onChange={onModeChange}
/>
),
},
{
key: "bold",
label: t("editor.bold"),
icon: BoldIcon,
disabled,
pressed: !!editor?.isActive("bold"),
onClick: () => editor?.chain().focus().toggleBold().run(),
},
{
key: "underline",
label: t("editor.underline"),
icon: UnderlineIcon,
disabled,
pressed: !!editor?.isActive("underline"),
onClick: () => editor?.chain().focus().toggleUnderline().run(),
},
{
key: "italic",
label: t("editor.italic"),
icon: ItalicIcon,
disabled,
pressed: !!editor?.isActive("italic"),
onClick: () => editor?.chain().focus().toggleItalic().run(),
},
{
key: "strike",
label: t("editor.strike"),
icon: StrikethroughIcon,
disabled,
pressed: !!editor?.isActive("strike"),
onClick: () => editor?.chain().focus().toggleStrike().run(),
},
{ key: "separator-1", type: "separator" },
{
key: "h1",
label: t("editor.heading1"),
icon: Heading1Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 1 }),
onClick: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
key: "h2",
label: t("editor.heading2"),
icon: Heading2Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 2 }),
onClick: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
key: "quote",
label: t("editor.quote"),
icon: QuoteIcon,
disabled,
pressed: !!editor?.isActive("blockquote"),
onClick: () => editor?.chain().focus().toggleBlockquote().run(),
},
{
key: "bullet-list",
label: t("editor.bulletList"),
icon: ListIcon,
disabled,
pressed: !!editor?.isActive("bulletList"),
onClick: () => editor?.chain().focus().toggleBulletList().run(),
},
{
key: "ordered-list",
label: t("editor.orderedList"),
icon: ListOrderedIcon,
disabled,
pressed: !!editor?.isActive("orderedList"),
onClick: () => editor?.chain().focus().toggleOrderedList().run(),
},
{ key: "separator-2", type: "separator" },
{
key: "code",
label: t("editor.inlineCode"),
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("code"),
onClick: () => editor?.chain().focus().toggleCode().run(),
},
{
key: "code-block",
label: t("editor.codeBlock"),
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("codeBlock"),
onClick: () => editor?.chain().focus().toggleCodeBlock().run(),
},
{ key: "separator-3", type: "separator" },
{
key: "link",
label: t("editor.link"),
icon: LinkIcon,
disabled,
pressed: !!editor?.isActive("link"),
onClick: handleInsertLink,
},
{
key: "image",
label: t("editor.image"),
icon: ImageIcon,
disabled: disabled || !onUploadImage,
onClick: () => imageInputRef.current?.click(),
},
{ key: "separator-4", type: "separator" },
{
key: "undo-tail",
label: t("editor.undo"),
icon: RotateCcwIcon,
disabled: disabled || !editor?.can().undo(),
onClick: () => editor?.chain().focus().undo().run(),
},
{
key: "redo-tail",
label: t("editor.redo"),
icon: RedoIcon,
disabled: disabled || !editor?.can().redo(),
onClick: () => editor?.chain().focus().redo().run(),
},
{ key: "separator-fullscreen", type: "separator" },
{
key: "fullscreen",
label: fullscreen ? t("editor.exitFullscreen") : t("editor.fullscreen"),
icon: fullscreen ? Minimize2Icon : Maximize2Icon,
disabled,
pressed: fullscreen,
onClick: onToggleFullscreen,
},
{ key: "separator-preview", type: "separator" },
{
key: "preview-only",
label: t("editor.previewOnly"),
icon: EyeIcon,
disabled,
pressed: previewOnly,
onClick: handleTogglePreviewOnly,
},
]
if (allowedModes.length > 1) {
actions.splice(1, 0, { key: "separator-mode", type: "separator" })
}
if (!editor) {
return null
}
return (
<div
className="flex w-full flex-col rounded-md border bg-background"
style={scrollMode === "document" ? undefined : { height }}
>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleSelectImage(event)
}}
/>
<EditorToolbar actions={actions} />
<div className={cn("p-2", scrollMode === "editor" && "min-h-0 flex-1")}>
{previewOnly ? (
<div
className={proseClassName}
dangerouslySetInnerHTML={{ __html: value }}
/>
) : (
<EditorContent editor={editor} className={cn(scrollMode === "editor" && "h-full")} />
)}
</div>
</div>
)
}
)

View File

@@ -0,0 +1,195 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import { useConfirm } from "@/components/confirm-provider"
import { htmlToMarkdown, markdownToHtml } from "./convert"
import { HtmlEditor } from "./html-editor"
import { MarkdownEditor } from "./markdown-editor"
import {
CONTENT_MODE_OPTIONS,
type ContentMode,
type ContentValue,
type UploadImageHandler,
} from "./types"
import { useI18n } from "@/i18n/provider"
type ContentEditorProps = {
value: ContentValue
onChange: (next: ContentValue) => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height?: number | string
allowedModes?: ReadonlyArray<ContentMode>
scrollMode?: "editor" | "document"
className?: 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, t: (key: string) => string) {
return mode === "markdown" ? t("editor.modeLabelMarkdown") : t("editor.modeLabelRichText")
}
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,
allowedModes = CONTENT_MODE_OPTIONS,
scrollMode = "editor",
className,
}: ContentEditorProps) {
const t = useI18n()
const confirm = useConfirm()
const editorHeight = normalizeHeight(height)
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
const effectiveScrollMode = fullscreen ? "editor" : scrollMode
const normalizedAllowedModes = allowedModes.length > 0 ? allowedModes : CONTENT_MODE_OPTIONS
const activeMode = normalizedAllowedModes.includes(value.mode)
? value.mode
: normalizedAllowedModes[0]
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(
async (nextMode: ContentMode) => {
if (
disabled ||
normalizedAllowedModes.length <= 1 ||
nextMode === activeMode ||
!normalizedAllowedModes.includes(nextMode)
) {
return
}
const currentText = value.raw.trim()
if (!currentText) {
onChange({ mode: nextMode, raw: "" })
return
}
if (activeMode === "markdown" && nextMode === "html") {
onChange({ mode: nextMode, raw: convertContent(activeMode, value.raw) })
return
}
const confirmed = await confirm({
title: t("editor.modeSwitchTitle", { mode: getModeLabel(nextMode, t) }),
description: t("editor.modeSwitchDescription"),
confirmText: t("editor.modeSwitchAction"),
cancelText: t("editor.modeSwitchCancel"),
})
if (!confirmed) {
return
}
onChange({
mode: nextMode,
raw: convertContent(activeMode, value.raw),
})
},
[activeMode, confirm, disabled, normalizedAllowedModes, onChange, t, value.raw]
)
useEffect(() => {
if (value.mode !== activeMode) {
onChange({ mode: activeMode, raw: value.raw })
}
}, [activeMode, onChange, value.mode, value.raw])
const content = (
<div
className={cn(
"w-full",
effectiveScrollMode === "document" && "content-editor-document",
!fullscreen && className,
fullscreen && "fixed inset-0 z-[10000] overflow-hidden bg-background p-4"
)}
>
{activeMode === "markdown" ? (
<MarkdownEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "markdown", raw: nextRaw })}
mode={activeMode}
allowedModes={normalizedAllowedModes}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
scrollMode={effectiveScrollMode}
/>
) : (
<HtmlEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "html", raw: nextRaw })}
mode={activeMode}
allowedModes={normalizedAllowedModes}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
scrollMode={effectiveScrollMode}
/>
)}
</div>
)
if (fullscreen && mounted) {
return createPortal(content, document.body)
}
return content
}
export type { ContentMode, ContentValue, UploadImageHandler, UploadImageResult } from "./types"

View File

@@ -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-app-mono), monospace;
font-size: 0.875rem;
line-height: 1.75;
}

View File

@@ -0,0 +1,179 @@
"use client"
import {
type ComponentProps,
forwardRef,
useId,
useImperativeHandle,
useMemo,
useRef,
} from "react"
import { Maximize2Icon, Minimize2Icon } from "lucide-react"
import { config, 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"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
config({
codeMirrorExtensions(extensions) {
return extensions.filter((extension) => extension.type !== "linkShortener")
},
})
export type MarkdownEditorRef = {
focus: () => void
}
type MarkdownEditorProps = {
value: string
onChange: (nextValue: string) => void
mode: ContentMode
allowedModes: ReadonlyArray<ContentMode>
onModeChange: (nextMode: ContentMode) => void
fullscreen: boolean
onToggleFullscreen: () => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height: string
scrollMode: "editor" | "document"
}
type MdEditorToolbars = NonNullable<ComponentProps<typeof MdEditor>["toolbars"]>
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
function MarkdownEditor(
{
value,
onChange,
mode,
allowedModes,
onModeChange,
fullscreen,
onToggleFullscreen,
placeholder = "",
disabled = false,
onUploadImage,
height,
scrollMode,
},
ref
) {
const t = useI18n()
const editorId = useId()
const editorRef = useRef<ExposeParam>(null)
const { resolvedTheme } = useTheme()
const showModeSwitch = allowedModes.length > 1
const defToolbars = useMemo(
() => {
const fullscreenToolbar = (
<NormalToolbar
key="toggle-fullscreen"
title={fullscreen ? t("editor.exitFullscreen") : t("editor.fullscreen")}
disabled={disabled}
onClick={onToggleFullscreen}
>
{fullscreen ? (
<Minimize2Icon className="h-[16px] w-[16px]" />
) : (
<Maximize2Icon className="h-[16px] w-[16px]" />
)}
</NormalToolbar>
)
if (!showModeSwitch) {
return [fullscreenToolbar]
}
return [
<EditorModeSwitch
key="mode-switch"
value={mode}
allowedModes={allowedModes}
disabled={disabled}
onChange={onModeChange}
/>,
fullscreenToolbar,
]
},
[allowedModes, disabled, fullscreen, mode, onModeChange, onToggleFullscreen, showModeSwitch, t]
)
const toolbars = useMemo(
() => [
...(showModeSwitch ? [0, "-"] : []),
"bold",
"underline",
"italic",
"strikeThrough",
"-",
"title",
"quote",
"unorderedList",
"orderedList",
"-",
"codeRow",
"code",
"link",
"image",
"-",
"revoke",
"next",
showModeSwitch ? 1 : 0,
"preview",
"previewOnly",
] as MdEditorToolbars,
[showModeSwitch]
)
useImperativeHandle(ref, () => ({
focus() {
editorRef.current?.focus()
},
}))
return (
<div
className={cn("w-full rounded-lg border bg-background", scrollMode === "document" && "flex min-h-full flex-col")}
style={scrollMode === "document" ? undefined : { height }}
>
<div className={cn("content-editor-markdown", scrollMode === "editor" && "h-full", scrollMode === "document" && "content-editor-markdown-document flex min-h-full flex-1 flex-col")}>
<MdEditor
ref={editorRef}
id={editorId}
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? "dark" : "light"}
preview={false}
toolbars={toolbars}
defToolbars={defToolbars}
footers={[]}
noMermaid
noKatex
noHighlight
placeholder={placeholder}
disabled={disabled}
style={scrollMode === "document" ? undefined : { 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>
)
}
)

View File

@@ -0,0 +1,20 @@
.content-editor-toolbar {
scrollbar-width: none;
cursor: grab;
}
.content-editor-toolbar::-webkit-scrollbar {
height: 0 !important;
}
.content-editor-toolbar:active {
cursor: grabbing;
}
.content-editor-document .content-editor-toolbar,
.content-editor-document .md-editor-toolbar-wrapper {
position: sticky;
top: var(--content-editor-toolbar-offset, 0px);
z-index: 10;
background: var(--background);
}

View File

@@ -0,0 +1,142 @@
"use client"
import "./toolbar.css"
import { useRef, type PointerEvent as ReactPointerEvent } from "react"
import { cn } from "@/lib/utils"
import type { EditorToolbarAction } from "./types"
type EditorToolbarProps = {
actions: ReadonlyArray<EditorToolbarAction>
}
function isSeparatorAction(
action: EditorToolbarAction
): action is Extract<EditorToolbarAction, { type: "separator" }> {
return "type" in action && action.type === "separator"
}
function isCustomAction(
action: EditorToolbarAction
): action is Extract<EditorToolbarAction, { type: "custom" }> {
return "type" in action && action.type === "custom"
}
export function EditorToolbar({ actions }: EditorToolbarProps) {
const containerRef = useRef<HTMLDivElement>(null)
const dragStateRef = useRef<{
pointerId: number
startX: number
startScrollLeft: number
moved: boolean
} | null>(null)
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return
}
const container = containerRef.current
if (!container || container.scrollWidth <= container.clientWidth) {
return
}
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: container.scrollLeft,
moved: false,
}
container.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
const dragState = dragStateRef.current
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const deltaX = event.clientX - dragState.startX
if (Math.abs(deltaX) > 3) {
dragState.moved = true
}
container.scrollLeft = dragState.startScrollLeft - deltaX
}
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
const dragState = dragStateRef.current
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
return
}
if (container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId)
}
window.setTimeout(() => {
dragStateRef.current = null
}, 0)
}
return (
<div
ref={containerRef}
className="content-editor-toolbar overflow-x-auto overflow-y-hidden border-b border-border px-1 py-1 h-9.25"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
>
<div className="flex min-w-max items-center justify-between">
<div className="flex items-center">
{actions.map((action) => {
if (isSeparatorAction(action)) {
return (
<span
key={action.key}
aria-hidden="true"
className="relative mx-2 inline-block h-[0.9em] w-px self-center bg-border"
/>
)
}
if (isCustomAction(action)) {
return <div key={action.key}>{action.content}</div>
}
const Icon = action.icon
return (
<button
key={action.key}
type="button"
aria-label={action.label}
title={action.label}
disabled={action.disabled}
onClick={action.onClick}
onClickCapture={(event) => {
if (dragStateRef.current?.moved) {
event.preventDefault()
event.stopPropagation()
}
}}
data-pressed={action.pressed ? "true" : "false"}
className={cn(
"mx-[2px] cursor-pointer list-none rounded-[3px] border-none bg-transparent text-[#3f4a54] transition-all duration-300 select-none hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 data-[pressed=true]:bg-[#f2f2f2] dark:text-[#999] dark:hover:bg-[#333] dark:data-[pressed=true]:bg-[#333]",
action.icon && !action.content
? "flex flex-col items-center px-[2px] py-0"
: "flex flex-col items-center px-[6px] py-0"
)}
>
{Icon ? <Icon className="box-content size-4 p-1" /> : null}
{action.content ? (
<span className="text-[12px] leading-none whitespace-nowrap">
{action.content}
</span>
) : null}
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import type { ComponentType, ReactNode } from "react"
export type ContentMode = "markdown" | "html"
export const CONTENT_MODE_OPTIONS = ["markdown", "html"] as const
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

View File

@@ -0,0 +1,205 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Resolver, useForm } from "react-hook-form"
import { z } from "zod/v4"
import { CircleXIcon, Loader2Icon, SparklesIcon } from "lucide-react"
import { toast } from "sonner"
import { closeConversation } from "@/lib/api/admin"
import { summarizeAgentConversation } from "@/lib/api/agent"
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 { useI18n } from "@/i18n/provider"
type ConversationCloseDialogProps = {
open: boolean
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
type CloseForm = {
closeReason: string
}
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 t = useI18n()
const [saving, setSaving] = useState(false)
const [summarizing, setSummarizing] = useState(false)
const closeSchema = useMemo(
() =>
z.object({
closeReason: z.string().trim().min(1, t("conversationAction.closeReasonRequired")),
}),
[t]
)
const closeResolver = useMemo(
() => zodResolver(closeSchema as never) as Resolver<CloseForm>,
[closeSchema]
)
const form = useForm<CloseForm>({
resolver: closeResolver,
defaultValues: emptyForm,
})
const {
handleSubmit,
reset,
register,
setValue,
formState: { errors },
} = form
useEffect(() => {
reset(emptyForm)
}, [conversationId, reset])
async function handleAiSummary() {
if (!conversationId) {
return
}
setSummarizing(true)
try {
const summary = await summarizeAgentConversation(conversationId)
if (summary) {
setValue("closeReason", summary, { shouldValidate: true })
toast.success(t("aiCopilot.summaryGenerated"))
} else {
toast.info(t("aiCopilot.noSummary"))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("aiCopilot.errorGenerating"))
} finally {
setSummarizing(false)
}
}
async function onFormSubmit(values: CloseForm) {
if (!conversationId) {
toast.error(t("conversationAction.conversationMissing"))
return
}
setSaving(true)
try {
await closeConversation(conversationId, values.closeReason.trim())
toast.success(t("conversationAction.closed", { id: conversationId }))
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : t("conversationAction.closeFailed"))
} finally {
setSaving(false)
}
}
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>{t("conversationAction.closeTitle")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.closeReason}>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="conversation-close-reason">
{t("conversationAction.closeReason")}
</FieldLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs text-primary gap-1"
disabled={summarizing || !conversationId}
onClick={() => void handleAiSummary()}
>
{summarizing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
{summarizing ? t("aiCopilot.summarizing") : t("aiCopilot.summarize")}
</Button>
</div>
<FieldContent>
<Textarea
id="conversation-close-reason"
rows={4}
placeholder={t("conversationAction.closeReasonPlaceholder")}
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}
>
{t("conversationAction.cancel")}
</Button>
<Button type="submit" disabled={saving}>
<CircleXIcon />
{saving ? t("conversationAction.closing") : t("conversationAction.confirmClose")}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}

View File

@@ -0,0 +1,254 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowRightLeftIcon } from "lucide-react"
import { useEffect, useMemo, 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"
import { useI18n } from "@/i18n/provider"
type ConversationTransferDialogProps = {
open: boolean
mode: "assign" | "transfer"
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
type TransferForm = {
toUserId: string
reason: string
}
const emptyForm: TransferForm = {
toUserId: "",
reason: "",
}
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 t = useI18n()
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 || t("conversationAction.agentFallback", { id: agent.userId }),
}))
const transferSchema = useMemo(
() =>
z.object({
toUserId: z.string().trim().min(1, t("conversationAction.targetAgentRequired")),
reason: z.string().trim(),
}),
[t]
)
const transferResolver = useMemo(
() => zodResolver(transferSchema as never) as Resolver<TransferForm>,
[transferSchema]
)
const form = useForm<TransferForm>({
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 : t("conversationAction.loadAgentsFailed"))
})
.finally(() => {
setLoadingAgents(false)
})
}, [t])
async function onFormSubmit(values: TransferForm) {
if (!conversationId) {
toast.error(t("conversationAction.conversationMissing"))
return
}
const toUserId = Number(values.toUserId)
const reason = values.reason.trim()
setSaving(true)
try {
if (mode === "assign") {
await assignConversation(conversationId, toUserId, reason)
toast.success(t("conversationAction.assigned", { id: conversationId }))
} else {
await transferConversation(conversationId, toUserId, reason)
toast.success(t("conversationAction.transferred", { id: conversationId }))
}
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: mode === "assign"
? t("conversationAction.assignFailed")
: t("conversationAction.transferFailed")
)
} 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 ? t("conversationAction.assignTitle") : t("conversationAction.transferTitle")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}>
<FieldLabel htmlFor="conversation-transfer-user">
{t("conversationAction.targetAgent")}
</FieldLabel>
<FieldContent>
<Controller
control={control}
name="toUserId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={userOptions}
placeholder={
loadingAgents
? t("conversationAction.loading")
: t("conversationAction.selectTargetAgent")
}
searchPlaceholder={t("conversationAction.searchAgent")}
emptyText={t("conversationAction.emptyAgents")}
disabled={saving || loadingAgents}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.toUserId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel htmlFor="conversation-transfer-reason">
{isAssign ? t("conversationAction.assignNote") : t("conversationAction.transferReason")}
</FieldLabel>
<FieldContent>
<Textarea
id="conversation-transfer-reason"
rows={4}
placeholder={
isAssign
? t("conversationAction.assignPlaceholder")
: t("conversationAction.transferPlaceholder")
}
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}
>
{t("conversationAction.cancel")}
</Button>
<Button type="submit" disabled={saving}>
<ArrowRightLeftIcon />
{saving
? isAssign
? t("conversationAction.assigning")
: t("conversationAction.transferring")
: isAssign
? t("conversationAction.confirmAssign")
: t("conversationAction.confirmTransfer")}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}

View File

@@ -0,0 +1,90 @@
"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"
import { useI18n } from "@/i18n/provider"
export type CustomerFormDialogProps = {
open: boolean
saving: boolean
itemId: number | null
onOpenChange: (open: boolean) => void
onSave: (payload: CustomerFormSavePayload) => Promise<void>
}
/** Customer create/edit dialog shared by customer management and conversation workflows. */
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 t = useI18n()
const formId = "customer-form-dialog"
const [loadingDetail, setLoadingDetail] = useState(() => Boolean(itemId))
return (
<ProjectDialog
open
onOpenChange={(next) => onOpenChange(next)}
title={itemId ? t("customerForm.editTitle") : t("customerForm.createTitle")}
allowFullscreen
size="xl"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("customerForm.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={saving || loadingDetail}
>
{saving ? t("customerForm.saving") : itemId ? t("customerForm.save") : t("customerForm.create")}
</Button>
</>
}
>
<CustomerForm
formId={formId}
itemId={itemId}
onSave={onSave}
fieldIdPrefix="customer"
className="space-y-4"
onLoadingDetailChange={setLoadingDetail}
/>
</ProjectDialog>
)
}

View File

@@ -0,0 +1,490 @@
"use client"
import { useEffect, useMemo, 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 { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
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 { ContactType, Gender } from "@/lib/generated/enums"
import { useI18n } from "@/i18n/provider"
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(),
})
export type CustomerFormValues = {
name: string
gender: (typeof genderValueOptions)[number]
companyId: string
remark: string
contacts: CustomerContactFormRow[]
}
export type CustomerContactFormRow = {
id?: number
contactType: (typeof contactTypeValues)[number]
contactValue: string
remark: string
isPrimary: boolean
}
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,
}))
}
/** Filters empty rows and keeps at most one primary contact. */
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
type CustomerFormFieldsProps = {
form: UseFormReturn<CustomerFormValues>
fieldIdPrefix?: string
remarkRows?: number
}
function CustomerFormFields({
form,
fieldIdPrefix = "customer",
remarkRows = 4,
}: CustomerFormFieldsProps) {
const t = useI18n()
const {
control,
register,
formState: { errors },
watch,
setValue,
getValues,
} = form
const { fields, append, remove } = useFieldArray({ control, name: "contacts" })
const genderOptions = useMemo(
() => [
{ value: String(Gender.Unknown), label: t("customerForm.genderUnknown") },
{ value: String(Gender.Male), label: t("customerForm.genderMale") },
{ value: String(Gender.Female), label: t("customerForm.genderFemale") },
],
[t]
)
const contactTypeOptions = useMemo(
() => [
{ value: ContactType.Mobile, label: t("customerForm.contactMobile") },
{ value: ContactType.Email, label: t("customerForm.contactEmail") },
{ value: ContactType.Other, label: t("customerForm.contactOther") },
],
[t]
)
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">{t("customerForm.sectionCustomer")}</h3>
<div className="space-y-4">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor={id("name")}>{t("customerForm.name")}</FieldLabel>
<FieldContent>
<Input
id={id("name")}
placeholder={t("customerForm.namePlaceholder")}
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")}>{t("customerForm.gender")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="gender"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={genderOptions}
placeholder={t("customerForm.gender")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.gender]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.companyId}>
<FieldLabel htmlFor={id("company")}>{t("customerForm.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")}>{t("customerForm.remark")}</FieldLabel>
<FieldContent>
<Textarea
id={id("remark")}
placeholder={t("customerForm.optional")}
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">{t("customerForm.sectionContacts")}</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>{t("customerForm.type")}</span>
<span>{t("customerForm.contact")}</span>
<span>{t("customerForm.remark")}</span>
<span className="text-center">{t("customerForm.primary")}</span>
<span className="sr-only">{t("customerForm.actions")}</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">{t("customerForm.type")}</span>
<Controller
control={control}
name={`contacts.${index}.contactType`}
render={({ field: f }) => (
<OptionCombobox
value={f.value}
options={contactTypeOptions}
placeholder={t("customerForm.type")}
onChange={f.onChange}
/>
)}
/>
</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">{t("customerForm.contact")}</FieldLabel>
<FieldContent>
<Input
placeholder={
watch(`contacts.${index}.contactType`) === ContactType.Email
? t("customerForm.emailPlaceholder")
: t("customerForm.contactPlaceholder")
}
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">
{t("customerForm.remark")}
</FieldLabel>
<FieldContent>
<Input
id={id(`tag-${index}`)}
placeholder={t("customerForm.optional")}
{...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">{t("customerForm.primaryContact")}</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={t("customerForm.setPrimary")}
/>
<label htmlFor={id(`primary-${index}`)} className="hidden cursor-pointer text-sm sm:inline">
{t("customerForm.primary")}
</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={t("customerForm.deleteContact")}
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
)
})}
</div>
<Button type="button" variant="outline" size="sm" className="gap-1" onClick={addContactRow}>
<PlusIcon className="size-4" />
{t("customerForm.addContact")}
</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 t = useI18n()
const [loadingDetail, setLoadingDetail] = useState(() => Boolean(itemId))
const customerFormSchema = useMemo(
() =>
z.object({
name: z.string().trim().min(1, t("customerForm.nameRequired")),
gender: z.enum(genderValueOptions, { message: t("customerForm.genderRequired") }),
companyId: z.string().trim().regex(/^\d+$/, t("customerForm.companyRequired")),
remark: z.string().trim(),
contacts: z.array(contactRowSchema),
}),
[t]
)
const customerFormResolver = useMemo(
() => zodResolver(customerFormSchema as never) as Resolver<CustomerFormValues>,
[customerFormSchema]
)
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">{t("customerForm.loading")}</div>
</div>
)
}
return (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className={className}>
<CustomerFormFields
form={form}
fieldIdPrefix={fieldIdPrefix}
remarkRows={remarkRows}
/>
</form>
)
}

View File

@@ -0,0 +1,279 @@
"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"
import { useI18n } from "@/i18n/provider"
export type CustomerLinkOrCreateDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** When present, links an existing or newly created customer to this conversation. */
conversationId?: number | null
/** When present, links an existing or newly created customer to this ticket. */
ticketId?: number | null
/** Called after linking succeeds, or after creating without a linked context. */
onSuccess?: () => void | Promise<void>
}
const createFormId = "customer-link-or-create-form"
export function CustomerLinkOrCreateDialog({
open,
onOpenChange,
conversationId,
ticketId,
onSuccess,
}: CustomerLinkOrCreateDialogProps) {
const t = useI18n()
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(t("customerLink.keywordRequired"))
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(t("customerLink.noMatch"))
}
} catch (e) {
toast.error(e instanceof Error ? e.message : t("customerLink.searchFailed"))
} finally {
setSearching(false)
}
}
const handleLinkExisting = async (customer: AdminCustomer) => {
const customerName = customer.name || t("customerLink.fallbackName", { id: customer.id })
if (!conversationId && !ticketId) {
toast.success(t("customerLink.selected", { name: customerName }))
onOpenChange(false)
await onSuccess?.()
return
}
setLinkingId(customer.id)
try {
if (conversationId) {
await linkConversationToCustomer({
conversationId,
customerId: customer.id,
})
}
if (ticketId) {
await linkTicketToCustomer({
ticketId,
customerId: customer.id,
})
}
toast.success(t("customerLink.linked"))
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : t("customerLink.linkFailed"))
} finally {
setLinkingId(null)
}
}
const onCreateSave = async (payload: CustomerFormSavePayload) => {
setSaving(true)
try {
const created = await saveCustomerProfile(payload)
if (conversationId) {
await linkConversationToCustomer({
conversationId,
customerId: created.id,
})
}
if (ticketId) {
await linkTicketToCustomer({
ticketId,
customerId: created.id,
})
}
if (conversationId || ticketId) {
toast.success(
conversationId
? t("customerLink.createdAndLinkedConversation")
: t("customerLink.createdAndLinkedTicket")
)
} else {
toast.success(t("customerLink.created"))
}
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : t("customerLink.saveFailed"))
} finally {
setSaving(false)
}
}
const description = (
<>
{t("customerLink.descriptionPrefix")}
{conversationId || ticketId
? conversationId
? t("customerLink.descriptionLinkConversation")
: t("customerLink.descriptionLinkTicket")
: t("customerLink.descriptionNoContext")}
{t("customerLink.descriptionCreatePrefix")}
{conversationId || ticketId
? conversationId
? t("customerLink.descriptionCreateConversation")
: t("customerLink.descriptionCreateTicket")
: t("customerLink.descriptionCreateNoContext")}
</>
)
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => onOpenChange(nextOpen)}
title={t("customerLink.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)}
>
{t("customerLink.close")}
</Button>
{showCreate ? (
<Button type="submit" form={createFormId} disabled={saving}>
{saving
? t("customerLink.submitting")
: conversationId
? t("customerLink.createAndLinkConversation")
: ticketId
? t("customerLink.createAndLinkTicket")
: t("customerLink.createCustomer")}
</Button>
) : null}
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Input
placeholder={t("customerLink.searchPlaceholder")}
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 ? t("customerLink.searching") : t("customerLink.search")}
</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 || t("customerLink.fallbackName", { id: 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
? t("customerLink.processing")
: conversationId
? t("customerLink.link")
: t("customerLink.select")}
</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 ? t("customerLink.collapseCreate") : t("customerLink.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>
);
}

View File

@@ -0,0 +1,100 @@
"use client"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { TableCell, TableRow } from "@/components/ui/table"
import { useI18n } from "@/i18n/provider"
export function DashboardPage({
className,
children,
}: {
className?: string
children: ReactNode
}) {
return (
<div className={cn("flex flex-1 flex-col gap-4 p-4 lg:p-5", className)}>
{children}
</div>
)
}
export function DashboardToolbar({
className,
actions,
children,
}: {
className?: string
actions?: ReactNode
children: ReactNode
}) {
return (
<div
className={cn(
"flex flex-col gap-3 border-b border-border/70 bg-background pb-3 lg:flex-row lg:items-center lg:justify-between",
className
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{children}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
) : null}
</div>
)
}
export function DashboardTableShell({
className,
pagination,
children,
}: {
className?: string
pagination?: ReactNode
children: ReactNode
}) {
return (
<div className={cn("space-y-3", className)}>
<div className="overflow-hidden rounded-md border border-border/70 bg-card text-card-foreground">
{children}
</div>
{pagination ? <div>{pagination}</div> : null}
</div>
)
}
export function DashboardTableStateRow({
colSpan,
loading,
loadingText,
emptyText,
}: {
colSpan: number
loading?: boolean
loadingText?: string
emptyText?: string
}) {
const t = useI18n()
return (
<TableRow>
<TableCell colSpan={colSpan} className="py-12 text-center text-muted-foreground">
{loading ? (loadingText ?? t("common.loadingData")) : (emptyText ?? t("common.emptyData"))}
</TableCell>
</TableRow>
)
}
export function DashboardTableSummary({
children,
}: {
children: ReactNode
}) {
return (
<div className="flex items-center justify-between text-sm text-muted-foreground">
{children}
</div>
)
}

View File

@@ -0,0 +1,73 @@
"use client"
import { ArrowUpRightIcon, Clock3Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { useI18n } from "@/i18n/provider"
type DashboardPlaceholderProps = {
eyebrow: string
title: string
description: string
nextSteps: string[]
}
export function DashboardPlaceholder({
eyebrow,
title,
description,
nextSteps,
}: DashboardPlaceholderProps) {
const t = useI18n()
return (
<div className="flex flex-1 flex-col gap-4 p-4 lg:p-5">
<Card className="rounded-md border-dashed shadow-none">
<CardHeader className="gap-2 p-4">
<span className="text-xs font-medium uppercase text-muted-foreground">
{eyebrow}
</span>
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-6">
{description}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0 md:grid-cols-[1.2fr_0.8fr]">
<div className="rounded-md border bg-muted/30 p-4">
<p className="text-sm font-medium">{t("placeholder.nextSteps")}</p>
<div className="mt-3 grid gap-2">
{nextSteps.map((item) => (
<div
key={item}
className="flex items-start gap-3 rounded-md 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-md border bg-background p-4">
<div>
<p className="text-sm font-medium">{t("placeholder.status")}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t("placeholder.statusDescription")}
</p>
</div>
<Button variant="outline" className="mt-5 justify-between">
{t("placeholder.nextPhase")}
<ArrowUpRightIcon />
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,321 @@
"use client"
import { useState } from "react"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import type {
FieldError as HookFormFieldError,
UseFormReturn,
} from "react-hook-form"
import { Controller, type Control, type UseFormRegister } from "react-hook-form"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
import type {
DashboardCrudFormField,
DashboardCrudFormInputValue,
DashboardCrudFormOption,
} from "./dashboard-crud-utils"
export function DashboardCrudFieldControl<TItem>({
field,
control,
form,
register,
error,
}: {
field: DashboardCrudFormField<TItem>
control: Control<Record<string, DashboardCrudFormInputValue>>
form: UseFormReturn<
Record<string, DashboardCrudFormInputValue>,
undefined,
Record<string, DashboardCrudFormInputValue>
>
register: UseFormRegister<Record<string, DashboardCrudFormInputValue>>
error?: HookFormFieldError
}) {
const inputId = `dashboard-crud-field-${field.name}`
if (field.type === "section" || field.type === "group") {
return (
<div className="md:col-span-2">
<div className="border-t pt-4">
<div className="text-sm font-medium">{field.label}</div>
{field.description ? (
<div className="mt-1 text-sm text-muted-foreground">
{field.description}
</div>
) : null}
</div>
</div>
)
}
return (
<Field
data-invalid={!!error}
className={cn(
(field.colSpan === 2 ||
["textarea", "json", "code", "custom"].includes(field.type ?? "")) &&
"md:col-span-2"
)}
>
<FieldLabel
htmlFor={
["select", "multiSelect", "switch", "checkbox", "custom"].includes(
field.type ?? ""
)
? undefined
: inputId
}
>
{field.label}
</FieldLabel>
<FieldContent>
{field.type === "select" ? (
<Controller
control={control}
name={field.name}
render={({ field: controllerField }) => (
<OptionCombobox
value={String(controllerField.value ?? "")}
options={[...(field.options ?? [])]}
placeholder={field.placeholder ?? field.label}
onChange={controllerField.onChange}
/>
)}
/>
) : field.type === "multiSelect" ? (
<Controller
control={control}
name={field.name}
render={({ field: controllerField }) => (
<DashboardCrudMultiSelect
value={
Array.isArray(controllerField.value)
? controllerField.value
: []
}
options={[...(field.options ?? [])]}
placeholder={field.placeholder ?? field.label}
onChange={controllerField.onChange}
/>
)}
/>
) : field.type === "switch" ? (
<Controller
control={control}
name={field.name}
render={({ field: controllerField }) => (
<Switch
checked={Boolean(controllerField.value)}
onCheckedChange={controllerField.onChange}
aria-label={field.label}
/>
)}
/>
) : field.type === "checkbox" ? (
<Controller
control={control}
name={field.name}
render={({ field: controllerField }) => (
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={Boolean(controllerField.value)}
onCheckedChange={controllerField.onChange}
aria-label={field.label}
/>
<span>{field.description ?? field.label}</span>
</label>
)}
/>
) : field.type === "custom" && field.render ? (
<Controller
control={control}
name={field.name}
render={({ field: controllerField }) => (
<>
{field.render?.({
name: field.name,
label: field.label,
value: controllerField.value,
values: form.watch(),
setValue: (name, value) =>
form.setValue(name, value, {
shouldDirty: true,
shouldValidate: true,
}),
})}
</>
)}
/>
) : field.type === "textarea" ? (
<Textarea
id={inputId}
rows={field.rows ?? 4}
placeholder={field.placeholder}
aria-invalid={!!error}
{...register(field.name)}
/>
) : field.type === "json" || field.type === "code" ? (
<Textarea
id={inputId}
rows={field.rows ?? 8}
placeholder={field.placeholder}
aria-invalid={!!error}
spellCheck={false}
className="font-mono text-xs leading-5"
{...register(field.name)}
/>
) : field.normalizeInput ? (
<Controller
control={control}
name={field.name}
render={({ field: controllerField }) => (
<Input
id={inputId}
type="text"
placeholder={field.placeholder}
aria-invalid={!!error}
value={String(controllerField.value ?? "")}
onChange={(event) => controllerField.onChange(field.normalizeInput!(event.target.value))}
/>
)}
/>
) : (
<Input
id={inputId}
type={
field.type === "number"
? "number"
: field.type === "password"
? "password"
: "text"
}
min={field.type === "number" ? field.min : undefined}
max={field.type === "number" ? field.max : undefined}
step={field.type === "number" ? field.step : undefined}
placeholder={field.placeholder}
aria-invalid={!!error}
{...register(field.name)}
/>
)}
{field.description && field.type !== "checkbox" ? (
<div className="text-sm text-muted-foreground">{field.description}</div>
) : null}
<FieldError errors={error ? [error] : []} />
</FieldContent>
</Field>
)
}
function DashboardCrudMultiSelect({
value,
options,
placeholder,
onChange,
}: {
value: string[]
options: DashboardCrudFormOption[]
placeholder: string
onChange: (value: string[]) => void
}) {
const t = useI18n()
const [open, setOpen] = useState(false)
const selectedOptions = options.filter((option) => value.includes(option.value))
const selectedText =
selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join(", ")
: placeholder
function toggleValue(nextValue: string) {
if (value.includes(nextValue)) {
onChange(value.filter((item) => item !== nextValue))
return
}
onChange([...value, nextValue])
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
/>
}
>
<span className="truncate">{selectedText}</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={t("common.searchKeyword")} />
<CommandList>
<CommandEmpty>{t("common.emptyOptions")}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const checked = value.includes(option.value)
return (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => toggleValue(option.value)}
>
<CheckIcon
className={cn(
"mr-2 size-4 shrink-0",
checked ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
{selectedOptions.length > 0 ? (
<div className="flex flex-wrap gap-1 border-t p-2">
{selectedOptions.slice(0, 8).map((option) => (
<Badge key={option.value} variant="secondary">
{option.label}
</Badge>
))}
{selectedOptions.length > 8 ? (
<Badge variant="outline">+{selectedOptions.length - 8}</Badge>
) : null}
</div>
) : null}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,295 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form"
import { useForm } from "react-hook-form"
import { z } from "zod/v4"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import {
buildDashboardCrudFormValues,
normalizeDashboardCrudSubmitValues,
type DashboardCrudFormInputValue,
type DashboardCrudFormField,
type DashboardCrudFormOption,
} from "./dashboard-crud-utils"
import { DashboardCrudFieldControl } from "./dashboard-crud-field-control"
type DashboardCrudFormDialogProps<TItem, TPayload> = {
open: boolean
saving: boolean
item: TItem | null
itemId: number | null
fields: DashboardCrudFormField<TItem>[]
fetchDetail?: (id: number) => Promise<TItem>
transformSubmitValues?: (
values: Record<string, string | number | boolean | string[] | number[]>,
context: { mode: "create" | "edit"; item: TItem | null }
) => TPayload
labels: {
createTitle: string
editTitle: string
create: string
save: string
saving: string
cancel: string
loadingDetail: string
required: string
invalidNumber: string
minValue: (min: number) => string
maxValue: (max: number) => string
}
onOpenChange: (open: boolean) => void
onSubmit: (payload: TPayload) => Promise<void>
}
function createFormSchema<TItem>(
fields: ReadonlyArray<DashboardCrudFormField<TItem>>,
labels: DashboardCrudFormDialogProps<TItem, unknown>["labels"]
) {
const shape: Record<string, z.ZodType> = {}
fields.forEach((field) => {
if (field.type === "section" || field.type === "group") {
return
}
if (field.type === "switch" || field.type === "checkbox") {
shape[field.name] = field.required
? z.boolean().refine((value) => value, field.requiredMessage ?? labels.required)
: z.boolean()
return
}
if (field.type === "multiSelect") {
shape[field.name] = field.required
? z.array(z.string()).min(1, field.requiredMessage ?? labels.required)
: z.array(z.string())
return
}
if (field.type === "custom") {
shape[field.name] = z.any()
return
}
let schema = field.trim ? z.string().trim() : z.string()
if (field.required) {
schema = schema.min(1, field.requiredMessage ?? labels.required)
}
if (field.pattern) {
schema = schema.regex(field.pattern, field.patternMessage ?? labels.required)
}
if (field.type === "number") {
schema = schema.refine((value) => {
if (!value.trim()) return !field.required
return Number.isFinite(Number(value))
}, labels.invalidNumber)
if (field.min !== undefined) {
schema = schema.refine((value) => !value.trim() || Number(value) >= field.min!, {
message: labels.minValue(field.min),
})
}
if (field.max !== undefined) {
schema = schema.refine((value) => !value.trim() || Number(value) <= field.max!, {
message: labels.maxValue(field.max),
})
}
}
if (field.type === "json" && field.validateJson !== false) {
schema = schema.refine((value) => {
if (!value.trim()) return !field.required
try {
JSON.parse(value)
return true
} catch {
return false
}
}, field.patternMessage ?? labels.required)
}
shape[field.name] = schema
})
return z.object(shape)
}
function normalizeFormLayoutFields<TItem>(fields: DashboardCrudFormField<TItem>[]) {
return fields.map((field) =>
["textarea", "json", "code", "custom", "section", "group"].includes(
field.type ?? ""
)
? { ...field, colSpan: field.colSpan ?? 2 }
: field
)
}
export function DashboardCrudFormDialog<TItem, TPayload>({
open,
saving,
item,
itemId,
fields,
fetchDetail,
transformSubmitValues,
labels,
onOpenChange,
onSubmit,
}: DashboardCrudFormDialogProps<TItem, TPayload>) {
const layoutFields = useMemo(() => normalizeFormLayoutFields(fields), [fields])
const initialValues = useMemo(
() => buildDashboardCrudFormValues(fields, item),
[fields, item]
)
const schema = useMemo(() => createFormSchema(fields, labels), [fields, labels])
const resolver = useMemo(
() =>
zodResolver(schema as never) as Resolver<
Record<string, DashboardCrudFormInputValue>,
undefined,
Record<string, DashboardCrudFormInputValue>
>,
[schema]
)
const [fetchedDetail, setFetchedDetail] = useState<{
id: number
item: TItem
} | null>(null)
const [fieldOptions, setFieldOptions] = useState<
Record<string, ReadonlyArray<DashboardCrudFormOption>>
>({})
const form = useForm<
Record<string, DashboardCrudFormInputValue>,
undefined,
Record<string, DashboardCrudFormInputValue>
>({
resolver,
defaultValues: initialValues,
})
const {
control,
handleSubmit,
register,
reset,
formState: { errors },
} = form
const formId = "dashboard-crud-edit-form"
const mode = itemId ? "edit" : "create"
const loadingDetail = Boolean(
itemId && fetchDetail && fetchedDetail?.id !== itemId
)
const detailItem =
itemId && fetchDetail && fetchedDetail?.id === itemId
? fetchedDetail.item
: item
useEffect(() => {
let cancelled = false
if (!itemId || !fetchDetail) {
reset(initialValues)
return
}
void fetchDetail(itemId)
.then((detail) => {
if (cancelled) return
setFetchedDetail({ id: itemId, item: detail })
reset(buildDashboardCrudFormValues(fields, detail))
})
return () => {
cancelled = true
}
}, [fetchDetail, fields, initialValues, item, itemId, reset])
useEffect(() => {
if (!open) return
let cancelled = false
const loaders = fields
.filter(
(field) =>
(field.type === "select" || field.type === "multiSelect") &&
field.loadOptions
)
.map(async (field) => {
const options = await field.loadOptions!()
return [field.name, options] as const
})
if (loaders.length === 0) return
void Promise.all(loaders).then((entries) => {
if (cancelled) return
setFieldOptions((current) => ({
...current,
...Object.fromEntries(entries),
}))
})
return () => {
cancelled = true
}
}, [fields, open])
async function submit(values: Record<string, DashboardCrudFormInputValue>) {
const normalizedValues = normalizeDashboardCrudSubmitValues(fields, values)
const payload = transformSubmitValues
? transformSubmitValues(normalizedValues, { mode, item: detailItem })
: (normalizedValues as TPayload)
await onSubmit(payload)
}
if (!open || fields.length === 0) {
return null
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={mode === "edit" ? labels.editTitle : labels.createTitle}
size="md"
allowFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{labels.cancel}
</Button>
<Button type="submit" form={formId} disabled={saving || loadingDetail}>
{saving ? labels.saving : mode === "edit" ? labels.save : labels.create}
</Button>
</>
}
>
{loadingDetail ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">{labels.loadingDetail}</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(submit)}
className="grid gap-4 md:grid-cols-2"
>
{layoutFields.map((field) => (
<DashboardCrudFieldControl
key={field.name}
field={{
...field,
options: fieldOptions[field.name] ?? field.options,
}}
control={control}
form={form}
register={register}
error={errors[field.name]}
/>
))}
</form>
)}
</ProjectDialog>
)
}

View File

@@ -0,0 +1,741 @@
"use client"
import type { ReactNode } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
GripVerticalIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "sonner"
import { useConfirm, type ConfirmOptions } from "@/components/confirm-provider"
import {
DashboardPage,
DashboardTableShell,
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { useDashboardPagedList } from "@/components/dashboard/list"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
isDashboardCrudActionDisabled,
isDashboardCrudActionVisible,
type DashboardCrudActionRule,
type DashboardCrudFormField,
type DashboardCrudPageResult,
type DashboardCrudQueryFilter,
type DashboardCrudQueryValue,
} from "./dashboard-crud-utils"
import { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog"
export type DashboardCrudFilter<TValue extends string | number = string> =
DashboardCrudQueryFilter & {
label: string
placeholder?: string
defaultValue: TValue
type?: "text" | "select"
className?: string
options?: ReadonlyArray<{ value: string; label: string }>
}
export type DashboardCrudColumn<TItem> = {
key: string
label: ReactNode
className?: string
render: (item: TItem, context: DashboardCrudRowActionContext<TItem>) => ReactNode
}
export type DashboardCrudDialogProps<TItem, TPayload> = {
open: boolean
saving: boolean
item: TItem | null
itemId: number | null
onOpenChange: (open: boolean) => void
onSubmit: (payload: TPayload) => Promise<void>
}
export type DashboardCrudRowActionContext<TItem> = {
item: TItem
itemId: number
actionLoading: boolean
actionLoadingId: number | null
reload: () => Promise<void>
setActionLoadingId: (id: number | null) => void
}
export type DashboardCrudRowAction<TItem> = DashboardCrudActionRule<TItem> & {
key: string
label: ReactNode | ((item: TItem) => ReactNode)
icon?: ReactNode | ((item: TItem) => ReactNode)
variant?: "default" | "destructive"
confirm?: ConfirmOptions | ((item: TItem) => ConfirmOptions)
run: (context: DashboardCrudRowActionContext<TItem>) => Promise<void> | void
}
export type DashboardCrudActionState = {
onRefresh: () => void
onCreate: () => void
loading: boolean
}
export type DashboardCrudPageProps<TItem, TPayload> = {
filters: DashboardCrudFilter[]
columns: DashboardCrudColumn<TItem>[]
fetchList: (
query: Record<string, DashboardCrudQueryValue>
) => Promise<DashboardCrudPageResult<TItem>>
renderEditDialog?: (props: DashboardCrudDialogProps<TItem, TPayload>) => ReactNode
form?: {
fields: DashboardCrudFormField<TItem>[]
fetchDetail?: (id: number) => Promise<TItem>
transformSubmitValues?: (
values: Record<string, string | number | boolean | string[] | number[]>,
context: { mode: "create" | "edit"; item: TItem | null }
) => TPayload
labels: {
createTitle: string
editTitle: string
create: string
save: string
saving: string
cancel: string
loadingDetail: string
required: string
invalidNumber: string
minValue: (min: number) => string
maxValue: (max: number) => string
}
}
getItemId: (item: TItem) => number
createItem: (payload: TPayload) => Promise<unknown>
updateItem: (item: TItem, payload: TPayload) => Promise<unknown>
onCreateItem?: () => void
canEdit?: (item: TItem) => boolean
onEditItem?: (item: TItem) => void
deleteItem?: (item: TItem) => Promise<unknown>
canDelete?: (item: TItem) => boolean
deleteConfirm?: false | ConfirmOptions | ((item: TItem) => ConfirmOptions)
rowActions?: DashboardCrudRowAction<TItem>[]
renderRowActions?: (context: DashboardCrudRowActionContext<TItem>) => ReactNode
sort?: {
enabled?: boolean
disabled?: boolean
onReorder: (items: TItem[]) => Promise<unknown>
successMessage?: string
errorMessage: string
handleLabel: string
}
pageSize?: number
reloadKey?: string | number | null
layout?: "page" | "fragment"
showToolbar?: boolean
showToolbarActions?: boolean
tableShellClassName?: string
onActionStateChange?: (state: DashboardCrudActionState) => void
labels: {
refresh: string
create: string
query: string
loading: string
empty: string
actions: string
edit: string
delete: string
processing: string
moreActions: (item: TItem) => string
loadFailed: string
saveFailed: string
deleteFailed: string
created: (item: TPayload) => string
updated: (item: TItem, payload: TPayload) => string
deleted?: (item: TItem) => string
}
}
export function DashboardCrudPage<TItem, TPayload>({
filters,
columns,
fetchList,
renderEditDialog,
form,
getItemId,
createItem,
updateItem,
onCreateItem,
canEdit,
onEditItem,
deleteItem,
canDelete,
deleteConfirm,
rowActions = [],
renderRowActions,
sort,
pageSize = 20,
reloadKey,
layout = "page",
showToolbar = true,
showToolbarActions = true,
tableShellClassName,
onActionStateChange,
labels,
}: DashboardCrudPageProps<TItem, TPayload>) {
const confirm = useConfirm()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const list = useDashboardPagedList<TItem>({
filters,
fetchList,
pageSize,
reloadKey,
loadFailed: labels.loadFailed,
})
const draftFilters = list.draftFilters
const setDraftFilter = list.setDraftFilter
const loading = list.loading
const result = list.result
const loadData = list.loadData
const [saving, setSaving] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<TItem | null>(null)
function handleApplyFilters() {
list.applyFilters()
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return
event.preventDefault()
handleApplyFilters()
}
const openCreateDialog = useCallback(() => {
if (onCreateItem) {
onCreateItem()
return
}
setEditingItem(null)
setDialogOpen(true)
}, [onCreateItem])
const loadDataRef = useRef(loadData)
const openCreateDialogRef = useRef(openCreateDialog)
loadDataRef.current = loadData
openCreateDialogRef.current = openCreateDialog
const actionRefresh = useCallback(() => void loadDataRef.current(), [])
const actionCreate = useCallback(() => openCreateDialogRef.current(), [])
function openEditDialog(item: TItem) {
setEditingItem(item)
setDialogOpen(true)
}
useEffect(() => {
onActionStateChange?.({
onRefresh: actionRefresh,
onCreate: actionCreate,
loading,
})
}, [actionCreate, actionRefresh, loading, onActionStateChange])
function handleDialogOpenChange(open: boolean) {
if (saving) return
if (!open) setEditingItem(null)
setDialogOpen(open)
}
async function handleSubmit(payload: TPayload) {
if (saving) return
setSaving(true)
try {
if (editingItem) {
await updateItem(editingItem, payload)
toast.success(labels.updated(editingItem, payload))
} else {
await createItem(payload)
toast.success(labels.created(payload))
}
setDialogOpen(false)
setEditingItem(null)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.saveFailed)
} finally {
setSaving(false)
}
}
async function handleDelete(item: TItem) {
if (!deleteItem) return
if (deleteConfirm !== false) {
const confirmed = await confirm(
typeof deleteConfirm === "function"
? deleteConfirm(item)
: {
confirmText: labels.delete,
variant: "destructive",
...deleteConfirm,
}
)
if (!confirmed) return
}
const id = getItemId(item)
setActionLoadingId(id)
try {
await deleteItem(item)
toast.success(labels.deleted?.(item) ?? labels.delete)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.deleteFailed)
} finally {
setActionLoadingId(null)
}
}
async function runRowAction(
action: DashboardCrudRowAction<TItem>,
item: TItem,
actionLoading: boolean
) {
if (actionLoading || isDashboardCrudActionDisabled(action, item)) return
if (action.confirm) {
const confirmed = await confirm(
typeof action.confirm === "function" ? action.confirm(item) : action.confirm
)
if (!confirmed) return
}
const id = getItemId(item)
setActionLoadingId(id)
try {
await action.run({
item,
itemId: id,
actionLoading: true,
actionLoadingId: id,
reload: loadData,
setActionLoadingId,
})
} finally {
setActionLoadingId(null)
}
}
async function handleSortEnd(event: DragEndEvent) {
if (!sort?.enabled || sort.disabled) return
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = result.results.findIndex(
(item) => String(getItemId(item)) === String(active.id)
)
const newIndex = result.results.findIndex(
(item) => String(getItemId(item)) === String(over.id)
)
if (oldIndex < 0 || newIndex < 0) return
const previousResults = result.results
const nextResults = arrayMove(result.results, oldIndex, newIndex)
list.setResult((current) => ({ ...current, results: nextResults }))
try {
await sort.onReorder(nextResults)
if (sort.successMessage) {
toast.success(sort.successMessage)
}
} catch (error) {
list.setResult((current) => ({ ...current, results: previousResults }))
toast.error(error instanceof Error ? error.message : sort.errorMessage)
}
}
const sortable = Boolean(sort?.enabled)
const colSpan = columns.length + 1 + (sortable ? 1 : 0)
function renderTableRow(
item: TItem,
options: { sortable?: boolean; sortDisabled?: boolean } = {}
) {
const id = getItemId(item)
const actionLoading = actionLoadingId === id
const rowCells = (
<>
{columns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.render(item, {
item,
itemId: id,
actionLoading,
actionLoadingId,
reload: loadData,
setActionLoadingId,
})}
</TableCell>
))}
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
disabled={canEdit ? !canEdit(item) : false}
onClick={() => {
if (onEditItem) {
onEditItem(item)
return
}
openEditDialog(item)
}}
>
{labels.edit}
</Button>
{renderRowActions || rowActions.length > 0 || deleteItem ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={labels.moreActions(item)}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 min-w-40">
{rowActions
.filter((action) => isDashboardCrudActionVisible(action, item))
.map((action) => {
const icon =
typeof action.icon === "function"
? action.icon(item)
: action.icon
const label =
typeof action.label === "function"
? action.label(item)
: action.label
return (
<DropdownMenuItem
key={action.key}
disabled={
actionLoading ||
isDashboardCrudActionDisabled(action, item)
}
className={
action.variant === "destructive"
? "text-destructive focus:text-destructive"
: undefined
}
onClick={() => void runRowAction(action, item, actionLoading)}
>
{icon}
{actionLoading ? labels.processing : label}
</DropdownMenuItem>
)
})}
{renderRowActions?.({
item,
itemId: id,
actionLoading,
actionLoadingId,
reload: loadData,
setActionLoadingId,
})}
{deleteItem ? (
<DropdownMenuItem
onClick={() => void handleDelete(item)}
disabled={canDelete ? !canDelete(item) : false}
className="text-destructive focus:text-destructive"
>
<Trash2Icon />
{actionLoading ? labels.processing : labels.delete}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</ButtonGroup>
</TableCell>
</>
)
if (options.sortable) {
return (
<DashboardCrudSortableRow
key={id}
id={String(id)}
disabled={loading || options.sortDisabled}
handleLabel={sort?.handleLabel ?? labels.actions}
>
{rowCells}
</DashboardCrudSortableRow>
)
}
return <TableRow key={id}>{rowCells}</TableRow>
}
function renderStateRow() {
if (!loading && result.results.length > 0) return null
return (
<DashboardTableStateRow
colSpan={colSpan}
loading={loading}
loadingText={labels.loading}
emptyText={labels.empty}
/>
)
}
const tableElement = (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
{sortable ? <TableHead className="w-10" /> : null}
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.label}
</TableHead>
))}
<TableHead className="w-[92px] text-right">
{labels.actions}
</TableHead>
</TableRow>
</TableHeader>
{sortable ? (
<SortableContext
items={result.results.map((item) => String(getItemId(item)))}
strategy={verticalListSortingStrategy}
>
<TableBody>
{result.results.map((item) =>
renderTableRow(item, {
sortable: true,
sortDisabled: sort?.disabled,
})
)}
{renderStateRow()}
</TableBody>
</SortableContext>
) : (
<TableBody>
{result.results.map((item) => renderTableRow(item))}
{renderStateRow()}
</TableBody>
)}
</Table>
)
const table = sortable ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleSortEnd(event)}
>
{tableElement}
</DndContext>
) : (
tableElement
)
const content = (
<>
{showToolbar ? (
<DashboardToolbar
actions={
showToolbarActions ? (
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : undefined} />
{labels.refresh}
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon />
{labels.create}
</Button>
</>
) : null
}
>
{filters.map((filter) => {
const value = draftFilters[filter.name]
if (filter.type === "select") {
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-40"}>
<OptionCombobox
value={String(value ?? "")}
onChange={(nextValue) => setDraftFilter(filter.name, nextValue)}
placeholder={filter.placeholder ?? filter.label}
options={[...(filter.options ?? [])]}
/>
</div>
)
}
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-64"}>
<Input
value={String(value ?? "")}
onChange={(event) =>
setDraftFilter(filter.name, event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder={filter.placeholder ?? filter.label}
/>
</div>
)
})}
<Button variant="outline" onClick={handleApplyFilters} disabled={loading}>
<SearchIcon />
{labels.query}
</Button>
</DashboardToolbar>
) : null}
<DashboardTableShell
className={tableShellClassName}
pagination={
<ListPagination
page={result.page.page}
total={result.page.total}
limit={list.limit}
loading={loading}
onPageChange={list.handlePageChange}
onLimitChange={list.handleLimitChange}
/>
}
>
{table}
</DashboardTableShell>
</>
)
const dialog = form ? (
<DashboardCrudFormDialog
open={dialogOpen}
saving={saving}
item={editingItem}
itemId={editingItem ? getItemId(editingItem) : null}
fields={form.fields}
fetchDetail={form.fetchDetail}
transformSubmitValues={form.transformSubmitValues}
labels={form.labels}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
) : (
renderEditDialog?.({
open: dialogOpen,
saving,
item: editingItem,
itemId: editingItem ? getItemId(editingItem) : null,
onOpenChange: handleDialogOpenChange,
onSubmit: handleSubmit,
})
)
if (layout === "fragment") {
return (
<>
{content}
{dialog}
</>
)
}
return (
<>
<DashboardPage>{content}</DashboardPage>
{dialog}
</>
)
}
function DashboardCrudSortableRow({
id,
disabled,
handleLabel,
children,
}: {
id: string
disabled?: boolean
handleLabel: string
children: ReactNode
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled })
return (
<TableRow
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={isDragging ? "relative z-10 bg-muted/60" : undefined}
>
<TableCell className="w-10">
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label={handleLabel}
{...attributes}
{...listeners}
>
<GripVerticalIcon className="text-muted-foreground" />
</Button>
</TableCell>
{children}
</TableRow>
)
}

View File

@@ -0,0 +1,130 @@
"use client"
import type { ComponentProps, ReactNode } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import type {
DashboardCrudColumn,
DashboardCrudRowAction,
} from "./dashboard-crud-page"
type BadgeVariant = ComponentProps<typeof Badge>["variant"]
export type DashboardCrudStatusColumnOptions<TItem, TStatus> = {
key?: string
label: ReactNode
className?: string
getStatus: (item: TItem) => TStatus
getLabel: (status: TStatus, item: TItem) => ReactNode
getBadgeVariant?: (status: TStatus, item: TItem) => BadgeVariant
isEnabled?: (status: TStatus, item: TItem) => boolean
toggle?: {
disabled?: (item: TItem) => boolean
getNextStatus: (item: TItem) => TStatus
updateStatus: (item: TItem, nextStatus: TStatus) => Promise<unknown>
successMessage: (item: TItem, nextStatus: TStatus) => string
errorMessage: string
ariaLabel: (item: TItem) => string
}
}
export type DashboardCrudStatusToggleActionOptions<TItem, TStatus> = {
key?: string
icon?: ReactNode | ((item: TItem) => ReactNode)
label: (item: TItem, nextStatus: TStatus) => ReactNode
visible?: (item: TItem) => boolean
disabled?: (item: TItem) => boolean
getNextStatus: (item: TItem) => TStatus
updateStatus: (item: TItem, nextStatus: TStatus) => Promise<unknown>
successMessage: (item: TItem, nextStatus: TStatus) => string
errorMessage: string
}
export function createDashboardStatusColumn<TItem, TStatus>({
key = "status",
label,
className,
getStatus,
getLabel,
getBadgeVariant,
isEnabled,
toggle,
}: DashboardCrudStatusColumnOptions<TItem, TStatus>): DashboardCrudColumn<TItem> {
return {
key,
label,
className,
render: (item, { itemId, actionLoading, reload, setActionLoadingId }) => {
const status = getStatus(item)
const badge = (
<Badge variant={getBadgeVariant?.(status, item) ?? "outline"}>
{getLabel(status, item)}
</Badge>
)
if (!toggle) {
return badge
}
return (
<div className="flex items-center gap-3">
<Switch
checked={isEnabled ? isEnabled(status, item) : Boolean(status)}
disabled={actionLoading || toggle.disabled?.(item)}
onCheckedChange={() => {
void (async () => {
setActionLoadingId(itemId)
try {
const nextStatus = toggle.getNextStatus(item)
await toggle.updateStatus(item, nextStatus)
toast.success(toggle.successMessage(item, nextStatus))
await reload()
} catch (error) {
toast.error(
error instanceof Error ? error.message : toggle.errorMessage
)
} finally {
setActionLoadingId(null)
}
})()
}}
aria-label={toggle.ariaLabel(item)}
/>
{badge}
</div>
)
},
}
}
export function createDashboardStatusToggleAction<TItem, TStatus>({
key = "toggle-status",
icon,
label,
visible,
disabled,
getNextStatus,
updateStatus,
successMessage,
errorMessage,
}: DashboardCrudStatusToggleActionOptions<TItem, TStatus>): DashboardCrudRowAction<TItem> {
return {
key,
icon,
visible,
disabled,
label: (item) => label(item, getNextStatus(item)),
run: async ({ item, reload }) => {
try {
const nextStatus = getNextStatus(item)
await updateStatus(item, nextStatus)
toast.success(successMessage(item, nextStatus))
await reload()
} catch (error) {
toast.error(error instanceof Error ? error.message : errorMessage)
}
},
}
}

View File

@@ -0,0 +1,250 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
function plain(value) {
return JSON.parse(JSON.stringify(value))
}
async function loadModule() {
const source = await readFile(
new URL("./dashboard-crud-utils.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "dashboard-crud-utils.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
describe("buildDashboardCrudQuery", () => {
it("trims text filters and omits empty values", async () => {
const { buildDashboardCrudQuery } = await loadModule()
const query = buildDashboardCrudQuery({
values: {
title: " hello ",
groupName: " ",
},
filters: [
{ name: "title", trim: true },
{ name: "groupName", trim: true },
],
page: 2,
limit: 50,
})
assert.deepEqual(plain(query), {
title: "hello",
page: 2,
limit: 50,
})
})
it("omits configured all values and parses numbers", async () => {
const { buildDashboardCrudQuery } = await loadModule()
const query = buildDashboardCrudQuery({
values: {
status: "all",
companyId: "42",
},
filters: [
{ name: "status", allValue: "all" },
{ name: "companyId", allValue: "0", valueType: "number" },
],
page: 1,
limit: 20,
})
assert.deepEqual(plain(query), {
companyId: 42,
page: 1,
limit: 20,
})
})
})
describe("buildDashboardCrudInitialFilters", () => {
it("creates stable filter state from defaults", async () => {
const { buildDashboardCrudInitialFilters } = await loadModule()
assert.deepEqual(
plain(
buildDashboardCrudInitialFilters([
{ name: "keyword", defaultValue: "" },
{ name: "status", defaultValue: "all" },
{ name: "companyId", defaultValue: 0 },
])
),
{
keyword: "",
status: "all",
companyId: 0,
}
)
})
})
describe("normalizeDashboardCrudPageResult", () => {
it("returns a stable empty page when the API result is missing", async () => {
const { normalizeDashboardCrudPageResult } = await loadModule()
assert.deepEqual(plain(normalizeDashboardCrudPageResult(null, 3, 10)), {
results: [],
page: {
page: 3,
limit: 10,
total: 0,
},
})
})
})
describe("buildDashboardCrudFormValues", () => {
it("uses defaults for create forms and item values for edit forms", async () => {
const { buildDashboardCrudFormValues } = await loadModule()
const fields = [
{ name: "title", defaultValue: "Untitled" },
{ name: "sortNo", type: "number", defaultValue: "0" },
{
name: "status",
defaultValue: "0",
valueFromItem: (item) => String(item.status),
},
]
assert.deepEqual(plain(buildDashboardCrudFormValues(fields)), {
title: "Untitled",
sortNo: "0",
status: "0",
})
assert.deepEqual(
plain(buildDashboardCrudFormValues(fields, { title: "Hello", sortNo: 7, status: 1 })),
{
title: "Hello",
sortNo: "7",
status: "1",
}
)
})
it("normalizes boolean and multi-select defaults", async () => {
const { buildDashboardCrudFormValues } = await loadModule()
const fields = [
{ name: "enabled", type: "switch", defaultValue: true },
{ name: "required", type: "checkbox" },
{ name: "toolIds", type: "multiSelect", defaultValue: [1, "2"] },
]
assert.deepEqual(plain(buildDashboardCrudFormValues(fields)), {
enabled: true,
required: false,
toolIds: ["1", "2"],
})
assert.deepEqual(
plain(
buildDashboardCrudFormValues(fields, {
enabled: 0,
required: "1",
toolIds: [3, "4"],
})
),
{
enabled: false,
required: true,
toolIds: ["3", "4"],
}
)
})
})
describe("normalizeDashboardCrudSubmitValues", () => {
it("trims strings and converts number fields", async () => {
const { normalizeDashboardCrudSubmitValues } = await loadModule()
const fields = [
{ name: "title", trim: true },
{ name: "sortNo", type: "number" },
{ name: "status", type: "select", valueType: "number" },
]
assert.deepEqual(
plain(
normalizeDashboardCrudSubmitValues(fields, {
title: " Hello ",
sortNo: "12",
status: "1",
})
),
{
title: "Hello",
sortNo: 12,
status: 1,
}
)
})
it("converts boolean and multi-select fields", async () => {
const { normalizeDashboardCrudSubmitValues } = await loadModule()
const fields = [
{ name: "enabled", type: "switch" },
{ name: "toolCodes", type: "multiSelect" },
{ name: "roleIds", type: "multiSelect", valueType: "number" },
{ name: "section", type: "section" },
]
assert.deepEqual(
plain(
normalizeDashboardCrudSubmitValues(fields, {
enabled: true,
toolCodes: ["search", "faq"],
roleIds: ["1", "2", "bad"],
section: "",
})
),
{
enabled: true,
toolCodes: ["search", "faq"],
roleIds: [1, 2],
}
)
})
})
describe("dashboard CRUD action rules", () => {
it("defaults actions to visible and enabled", async () => {
const {
isDashboardCrudActionVisible,
isDashboardCrudActionDisabled,
} = await loadModule()
const action = {}
assert.equal(isDashboardCrudActionVisible(action, { status: 0 }), true)
assert.equal(isDashboardCrudActionDisabled(action, { status: 0 }), false)
})
it("honors visible and disabled predicates", async () => {
const {
isDashboardCrudActionVisible,
isDashboardCrudActionDisabled,
} = await loadModule()
const action = {
visible: (item) => item.status !== 2,
disabled: (item) => item.status === 1,
}
assert.equal(isDashboardCrudActionVisible(action, { status: 2 }), false)
assert.equal(isDashboardCrudActionVisible(action, { status: 0 }), true)
assert.equal(isDashboardCrudActionDisabled(action, { status: 1 }), true)
assert.equal(isDashboardCrudActionDisabled(action, { status: 0 }), false)
})
})

View File

@@ -0,0 +1,262 @@
import type { ReactNode } from "react"
export type DashboardCrudQueryValue = string | number | undefined
export type DashboardCrudQueryFilter = {
name: string
trim?: boolean
allValue?: string | number
valueType?: "string" | "number"
}
export type DashboardCrudFilterStateConfig<
TValue extends string | number = string | number,
> = {
name: string
defaultValue: TValue
}
export type DashboardCrudPageResult<T> = {
results: T[]
page: {
page: number
limit: number
total: number
}
}
export type DashboardCrudFormValue =
| string
| number
| boolean
| ReadonlyArray<string | number>
| undefined
export type DashboardCrudFormOption = {
value: string
label: string
}
export type DashboardCrudFormInputValue = string | boolean | string[]
export type DashboardCrudFormCustomRenderContext = {
name: string
label: string
value: DashboardCrudFormInputValue
values: Record<string, DashboardCrudFormInputValue>
setValue: (name: string, value: DashboardCrudFormInputValue) => void
}
export type DashboardCrudFormField<TItem = unknown> = {
name: string
label: string
type?:
| "text"
| "textarea"
| "number"
| "select"
| "multiSelect"
| "switch"
| "checkbox"
| "password"
| "json"
| "code"
| "custom"
| "section"
| "group"
placeholder?: string
defaultValue?: DashboardCrudFormValue
description?: string
required?: boolean
requiredMessage?: string
trim?: boolean
normalizeInput?: (value: string) => string
valueType?: "string" | "number" | "boolean"
min?: number
max?: number
step?: number
pattern?: RegExp
patternMessage?: string
options?: ReadonlyArray<DashboardCrudFormOption>
loadOptions?: () => Promise<ReadonlyArray<DashboardCrudFormOption>>
colSpan?: 1 | 2
rows?: number
language?: string
validateJson?: boolean
valueFromItem?: (item: TItem) => DashboardCrudFormValue
render?: (context: DashboardCrudFormCustomRenderContext) => ReactNode
}
export type DashboardCrudActionRule<TItem> = {
visible?: (item: TItem) => boolean
disabled?: (item: TItem) => boolean
}
export function buildDashboardCrudQuery({
values,
filters,
page,
limit,
}: {
values: Record<string, string | number | undefined>
filters: DashboardCrudQueryFilter[]
page: number
limit: number
}): Record<string, DashboardCrudQueryValue> {
const query: Record<string, DashboardCrudQueryValue> = {}
filters.forEach((filter) => {
const rawValue = values[filter.name]
const value =
filter.trim && typeof rawValue === "string" ? rawValue.trim() : rawValue
if (
value === undefined ||
value === "" ||
(filter.allValue !== undefined && String(value) === String(filter.allValue))
) {
return
}
if (filter.valueType === "number") {
const numberValue = Number(value)
if (Number.isFinite(numberValue)) {
query[filter.name] = numberValue
}
return
}
query[filter.name] = value
})
query.page = page
query.limit = limit
return query
}
export function buildDashboardCrudInitialFilters(
filters: ReadonlyArray<DashboardCrudFilterStateConfig>
): Record<string, string | number | undefined> {
return Object.fromEntries(
filters.map((filter) => [filter.name, filter.defaultValue])
) as Record<string, string | number | undefined>
}
export function normalizeDashboardCrudPageResult<T>(
result: Partial<DashboardCrudPageResult<T>> | null | undefined,
page: number,
limit: number
): DashboardCrudPageResult<T> {
return {
results: Array.isArray(result?.results) ? result.results : [],
page: {
page: result?.page?.page ?? page,
limit: result?.page?.limit ?? limit,
total: result?.page?.total ?? 0,
},
}
}
export function buildDashboardCrudFormValues<TItem>(
fields: ReadonlyArray<DashboardCrudFormField<TItem>>,
item?: TItem | null
): Record<string, DashboardCrudFormInputValue> {
return Object.fromEntries(
fields.map((field) => {
let value: unknown = field.defaultValue ?? getDashboardCrudFormDefaultValue(field)
if (item) {
if (field.valueFromItem) {
value = field.valueFromItem(item)
} else if (typeof item === "object" && item && field.name in item) {
value = (item as Record<string, unknown>)[field.name]
}
}
return [field.name, normalizeDashboardCrudFormInputValue(field, value)]
})
)
}
export function normalizeDashboardCrudSubmitValues<TItem>(
fields: ReadonlyArray<DashboardCrudFormField<TItem>>,
values: Record<string, DashboardCrudFormInputValue>
): Record<string, string | number | boolean | string[] | number[]> {
const output: Record<string, string | number | boolean | string[] | number[]> = {}
fields.forEach((field) => {
if (field.type === "section" || field.type === "group") {
return
}
const rawValue = values[field.name] ?? getDashboardCrudFormDefaultValue(field)
if (field.type === "switch" || field.type === "checkbox" || field.valueType === "boolean") {
output[field.name] = Boolean(rawValue)
return
}
if (field.type === "multiSelect") {
const list = Array.isArray(rawValue) ? rawValue : []
if (field.valueType === "number") {
output[field.name] = list
.map((value) => Number(value))
.filter((value) => Number.isFinite(value))
return
}
output[field.name] = list.map(String)
return
}
const rawText = typeof rawValue === "string" ? rawValue : String(rawValue ?? "")
const text = field.trim ? rawText.trim() : rawText
if (field.type === "number" || field.valueType === "number") {
const numberValue = Number(text)
output[field.name] = Number.isFinite(numberValue) ? numberValue : 0
return
}
output[field.name] = text
})
return output
}
function getDashboardCrudFormDefaultValue<TItem>(
field: DashboardCrudFormField<TItem>
): DashboardCrudFormInputValue {
if (field.type === "switch" || field.type === "checkbox") {
return false
}
if (field.type === "multiSelect") {
return []
}
return ""
}
function normalizeDashboardCrudFormInputValue<TItem>(
field: DashboardCrudFormField<TItem>,
value: unknown
): DashboardCrudFormInputValue {
if (value === undefined || value === null) {
return getDashboardCrudFormDefaultValue(field)
}
if (field.type === "switch" || field.type === "checkbox" || field.valueType === "boolean") {
return value === true || value === "true" || value === 1 || value === "1"
}
if (field.type === "multiSelect") {
if (!Array.isArray(value)) return []
return value
.filter((item) => item !== undefined && item !== null)
.map((item) => String(item))
}
return String(value)
}
export function isDashboardCrudActionVisible<TItem>(
action: DashboardCrudActionRule<TItem>,
item: TItem
) {
return action.visible ? action.visible(item) : true
}
export function isDashboardCrudActionDisabled<TItem>(
action: DashboardCrudActionRule<TItem>,
item: TItem
) {
return action.disabled ? action.disabled(item) : false
}

View File

@@ -0,0 +1,30 @@
export { DashboardCrudPage } from "./dashboard-crud-page"
export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog"
export {
buildDashboardCrudQuery,
normalizeDashboardCrudPageResult,
} from "./dashboard-crud-utils"
export {
createDashboardStatusColumn,
createDashboardStatusToggleAction,
} from "./dashboard-crud-presets"
export { useDashboardCrudFilters } from "./use-dashboard-crud-filters"
export type {
DashboardCrudColumn,
DashboardCrudActionState,
DashboardCrudDialogProps,
DashboardCrudFilter,
DashboardCrudPageProps,
DashboardCrudRowAction,
DashboardCrudRowActionContext,
} from "./dashboard-crud-page"
export type {
DashboardCrudFormField,
DashboardCrudFormInputValue,
DashboardCrudFormOption,
DashboardCrudFormValue,
DashboardCrudFilterStateConfig,
DashboardCrudPageResult,
DashboardCrudQueryFilter,
DashboardCrudQueryValue,
} from "./dashboard-crud-utils"

View File

@@ -0,0 +1,65 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import {
buildDashboardCrudInitialFilters,
type DashboardCrudFilterStateConfig,
} from "./dashboard-crud-utils"
export function useDashboardCrudFilters(
filters: ReadonlyArray<DashboardCrudFilterStateConfig>
) {
const defaultsKey = filters
.map((filter) => `${filter.name}:${String(filter.defaultValue)}`)
.join("|")
const initialFilters = useMemo(
() => buildDashboardCrudInitialFilters(filters),
// eslint-disable-next-line react-hooks/exhaustive-deps
[defaultsKey]
)
const [draftFilters, setDraftFilters] = useState(initialFilters)
const [appliedFilters, setAppliedFilters] = useState(initialFilters)
useEffect(() => {
setDraftFilters(initialFilters)
setAppliedFilters(initialFilters)
}, [initialFilters])
function setDraftFilter(name: string, value: string | number | undefined) {
setDraftFilters((current) => ({
...current,
[name]: value,
}))
}
function applyFilters() {
setAppliedFilters(draftFilters)
}
function applyFilter(name: string, value: string | number | undefined) {
setDraftFilters((current) => ({
...current,
[name]: value,
}))
setAppliedFilters((current) => ({
...current,
[name]: value,
}))
}
function resetFilters() {
setDraftFilters(initialFilters)
setAppliedFilters(initialFilters)
}
return {
draftFilters,
appliedFilters,
setDraftFilter,
setDraftFilters,
applyFilter,
applyFilters,
resetFilters,
}
}

View File

@@ -0,0 +1,290 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import {
DashboardPage,
DashboardTableShell,
DashboardTableStateRow,
DashboardToolbar,
} from "@/components/dashboard-page"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { TagSelector } from "@/components/tag-selector"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { TagTree } from "@/lib/api/admin"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import type {
DashboardCrudPageResult,
DashboardCrudQueryFilter,
} from "@/components/dashboard/crud"
import {
useDashboardPagedList,
type DashboardPagedListOptions,
} from "./use-dashboard-paged-list"
export type DashboardListFilter = DashboardCrudQueryFilter & {
label: string
placeholder?: string
defaultValue: string | number
type?: "text" | "select" | "segment" | "tag"
className?: string
inputClassName?: string
options?: ReadonlyArray<{ value: string; label: string }>
tags?: TagTree[]
searchPlaceholder?: string
emptyText?: string
icon?: ReactNode
}
export type DashboardListColumn<TItem> = {
key: string
label: ReactNode
className?: string
render: (item: TItem, context: DashboardListRenderContext<TItem>) => ReactNode
}
export type DashboardListRenderContext<TItem> = {
result: DashboardCrudPageResult<TItem>
loading: boolean
reload: () => Promise<void>
resetFilters: () => void
}
export type DashboardListPageProps<TItem> = {
filters?: DashboardListFilter[]
fetchList: DashboardPagedListOptions<TItem>["fetchList"]
columns?: DashboardListColumn<TItem>[]
getItemId?: (item: TItem) => string | number
renderContent?: (context: DashboardListRenderContext<TItem>) => ReactNode
renderToolbarActions?: (context: DashboardListRenderContext<TItem>) => ReactNode
getRowClassName?: (item: TItem) => string | undefined
onRowClick?: (item: TItem) => void
pageSize?: number
enabled?: boolean
reloadKey?: string | number | null
layout?: "page" | "fragment"
tableShellClassName?: string
labels: {
refresh?: string
query?: string
loading: string
empty: string
loadFailed: string
}
}
export function DashboardListPage<TItem>({
filters = [],
fetchList,
columns,
getItemId,
renderContent,
renderToolbarActions,
getRowClassName,
onRowClick,
pageSize,
enabled,
reloadKey,
layout = "page",
tableShellClassName,
labels,
}: DashboardListPageProps<TItem>) {
const list = useDashboardPagedList<TItem>({
filters,
fetchList,
pageSize,
enabled,
reloadKey,
loadFailed: labels.loadFailed,
})
const renderContext: DashboardListRenderContext<TItem> = {
result: list.result,
loading: list.loading,
reload: list.loadData,
resetFilters: list.resetFilters,
}
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return
event.preventDefault()
list.applyFilters()
}
const content = (
<>
<DashboardToolbar
actions={
<>
{labels.refresh ? (
<Button
variant="outline"
onClick={() => void list.loadData()}
disabled={list.loading}
>
<RefreshCwIcon className={list.loading ? "animate-spin" : undefined} />
{labels.refresh}
</Button>
) : null}
{renderToolbarActions?.(renderContext)}
</>
}
>
{filters.map((filter) => {
const value = list.draftFilters[filter.name]
if (filter.type === "segment") {
return (
<div
key={filter.name}
className={filter.className ?? "flex flex-wrap gap-2"}
>
{(filter.options ?? []).map((option) => (
<Button
key={option.value}
variant={String(value) === option.value ? "default" : "outline"}
onClick={() => list.applyFilter(filter.name, option.value)}
>
{option.label}
</Button>
))}
</div>
)
}
if (filter.type === "select") {
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-40"}>
<OptionCombobox
value={String(value ?? "")}
onChange={(nextValue) =>
list.setDraftFilter(filter.name, nextValue || filter.defaultValue)
}
placeholder={filter.placeholder ?? filter.label}
searchPlaceholder={filter.searchPlaceholder}
emptyText={filter.emptyText}
options={[...(filter.options ?? [])]}
/>
</div>
)
}
if (filter.type === "tag") {
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-48"}>
<TagSelector
mode="single"
value={Number(value ?? filter.defaultValue)}
onChange={(nextValue) => list.setDraftFilter(filter.name, nextValue)}
tags={filter.tags ?? []}
placeholder={filter.placeholder ?? filter.label}
searchPlaceholder={filter.searchPlaceholder}
emptyText={filter.emptyText}
rootOption={{
value: Number(filter.allValue ?? filter.defaultValue),
label: filter.placeholder ?? filter.label,
}}
/>
</div>
)
}
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-64"}>
<div className={filter.icon ? "relative" : undefined}>
{filter.icon ? (
<div className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground">
{filter.icon}
</div>
) : null}
<Input
value={String(value ?? "")}
onChange={(event) =>
list.setDraftFilter(filter.name, event.target.value)
}
onKeyDown={handleFilterKeyDown}
placeholder={filter.placeholder ?? filter.label}
className={filter.inputClassName}
/>
</div>
</div>
)
})}
{filters.some((filter) => filter.type !== "segment") ? (
<Button variant="outline" onClick={list.applyFilters} disabled={list.loading}>
<SearchIcon />
{labels.query}
</Button>
) : null}
</DashboardToolbar>
<DashboardTableShell
className={tableShellClassName}
pagination={
<ListPagination
page={list.result.page.page}
total={list.result.page.total}
limit={list.result.page.limit}
loading={list.loading}
onPageChange={list.handlePageChange}
onLimitChange={list.handleLimitChange}
/>
}
>
{renderContent ? (
renderContent(renderContext)
) : columns ? (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
{columns.map((column) => (
<TableHead key={column.key} className={column.className}>
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{list.result.results.map((item, index) => {
const key = getItemId ? getItemId(item) : index
return (
<TableRow
key={key}
className={getRowClassName?.(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{columns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.render(item, renderContext)}
</TableCell>
))}
</TableRow>
)
})}
{list.loading || list.result.results.length === 0 ? (
<DashboardTableStateRow
colSpan={columns.length}
loading={list.loading}
loadingText={labels.loading}
emptyText={labels.empty}
/>
) : null}
</TableBody>
</Table>
) : null}
</DashboardTableShell>
</>
)
if (layout === "fragment") {
return content
}
return <DashboardPage>{content}</DashboardPage>
}

View File

@@ -0,0 +1,12 @@
export { DashboardListPage } from "./dashboard-list-page"
export { useDashboardPagedList } from "./use-dashboard-paged-list"
export type {
DashboardListColumn,
DashboardListFilter,
DashboardListPageProps,
DashboardListRenderContext,
} from "./dashboard-list-page"
export type {
DashboardPagedListFilter,
DashboardPagedListOptions,
} from "./use-dashboard-paged-list"

View File

@@ -0,0 +1,136 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import {
buildDashboardCrudQuery,
normalizeDashboardCrudPageResult,
type DashboardCrudPageResult,
type DashboardCrudFilterStateConfig,
type DashboardCrudQueryFilter,
type DashboardCrudQueryValue,
} from "@/components/dashboard/crud/dashboard-crud-utils"
import { useDashboardCrudFilters } from "@/components/dashboard/crud/use-dashboard-crud-filters"
export type DashboardPagedListFilter = DashboardCrudQueryFilter &
DashboardCrudFilterStateConfig
export type DashboardPagedListOptions<TItem> = {
filters: DashboardPagedListFilter[]
fetchList: (
query: Record<string, DashboardCrudQueryValue>
) => Promise<DashboardCrudPageResult<TItem>>
pageSize?: number
loadFailed: string
enabled?: boolean
reloadKey?: string | number | null
}
export function useDashboardPagedList<TItem>({
filters,
fetchList,
pageSize = 20,
loadFailed,
enabled = true,
reloadKey,
}: DashboardPagedListOptions<TItem>) {
const fetchListRef = useRef(fetchList)
fetchListRef.current = fetchList
const filtersKey = filters
.map(
(filter) =>
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
)
.join("|")
const {
draftFilters,
appliedFilters,
setDraftFilter,
applyFilter,
applyFilters,
resetFilters,
} =
useDashboardCrudFilters(filters)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(pageSize)
const [loading, setLoading] = useState(enabled)
const [result, setResult] = useState<DashboardCrudPageResult<TItem>>({
results: [],
page: { page: 1, limit: pageSize, total: 0 },
})
const loadData = useCallback(async () => {
if (!enabled) {
setLoading(false)
setResult({ results: [], page: { page: 1, limit, total: 0 } })
return
}
setLoading(true)
try {
const data = await fetchListRef.current(
buildDashboardCrudQuery({
values: appliedFilters,
filters,
page,
limit,
})
)
setResult(normalizeDashboardCrudPageResult(data, page, limit))
} catch (error) {
toast.error(error instanceof Error ? error.message : loadFailed)
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedFilters, enabled, filtersKey, limit, loadFailed, page, reloadKey])
useEffect(() => {
void loadData()
}, [loadData])
function applyDraftFilters() {
applyFilters()
setPage(1)
}
function applyDraftFilter(name: string, value: string | number | undefined) {
applyFilter(name, value)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) return
setPage(nextPage)
}
function handleLimitChange(nextLimit: number) {
if (nextLimit <= 0 || nextLimit === limit) return
setLimit(nextLimit)
setPage(1)
}
function resetDraftFilters() {
resetFilters()
setPage(1)
}
return {
draftFilters,
setDraftFilter,
applyFilter: applyDraftFilter,
applyFilters: applyDraftFilters,
page,
setPage,
limit,
setLimit,
loading,
result,
setResult,
loadData,
handlePageChange,
handleLimitChange,
resetFilters: resetDraftFilters,
}
}

View File

@@ -0,0 +1,146 @@
"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"
import { useI18n } from "@/i18n/provider"
type DashboardTask = {
id: number
module: string
owner: string
status: string
progress: string
updatedAt: string
}
export function DataTable({ data }: { data: DashboardTask[] }) {
const t = useI18n()
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">{t("scaffold.moduleBoard")}</CardTitle>
<CardDescription className="mt-1">
{t("scaffold.moduleBoardDescription")}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Input className="w-full md:w-64" placeholder={t("scaffold.searchModule")} />
<Button variant="outline">
<FilterIcon />
{t("scaffold.filter")}
</Button>
</div>
</div>
<TabsList className="w-fit">
<TabsTrigger value="modules">{t("scaffold.moduleList")}</TabsTrigger>
<TabsTrigger value="milestones">{t("scaffold.milestones")}</TabsTrigger>
</TabsList>
<TabsContent value="modules" className="m-0">
<Card>
<CardHeader>
<CardTitle>{t("scaffold.phaseOneModules")}</CardTitle>
<CardDescription>
{t("scaffold.phaseOneDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>{t("scaffold.module")}</TableHead>
<TableHead>{t("scaffold.owner")}</TableHead>
<TableHead>{t("scaffold.status")}</TableHead>
<TableHead>{t("scaffold.progress")}</TableHead>
<TableHead className="text-right">{t("scaffold.updatedAt")}</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 === t("scaffold.done") ? (
<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>{t("scaffold.nextMilestone")}</CardTitle>
<CardDescription>
{t("scaffold.nextMilestoneDescription")}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{[
t("scaffold.milestoneLogin"),
t("scaffold.milestoneForms"),
t("scaffold.milestoneKnowledge"),
].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>
)
}

View File

@@ -0,0 +1,235 @@
# 统一编辑器设计(TipTap 单内核 + 双格式存储)
## 1. 背景与目标
当前项目存在两类编辑需求:
- `markdown`:知识文档等偏结构化内容
- `html` 富文本:所见即所得编辑、IM 消息等
现状是不同场景存在分叉实现,维护成本高,交互也不一致。
本设计目标是在不破坏现有后端接口(`contentType + content`)前提下,统一前端编辑器体系。
### 目标
- 统一编辑内核:前端所有编辑场景尽量使用 TipTap/ProseMirror
- 兼容双格式:继续保留 `contentType = "markdown" | "html"`
- 保持可演进:后续可扩展图片上传、草稿、快捷键、插件化工具栏
- 降低迁移风险:分阶段替换,不一次性重写全部页面
### 非目标
- 不追求 markdown 与 html 的绝对无损双向转换
- 不在第一阶段实现完整协作编辑(OT/CRDT)
---
## 2. 核心结论
可以使用 TipTap 作为单一编辑内核,但不建议把 markdown/html 简化为“完全等价格式”。
- TipTap 的内部模型是 ProseMirror 文档,不是 markdown AST
- markdown 与 html 语义存在差异,复杂结构双向转换会有损
- 正确做法是:**内核统一 + 存储分型 + 转换可控**
---
## 3. 总体架构
建议目录(以 `web/components/editor` 为中心):
```text
web/components/editor/
index.tsx # 统一入口组件 UnifiedEditor
html.tsx # 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 编辑器可在下一阶段迁移到同一核心配置,避免破坏现有发送交互。

View File

@@ -0,0 +1,150 @@
"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"
import { useI18n } from "@/i18n/provider"
export type HtmlEditorProps = BaseEditorProps & {
value: string
onChange: (nextValue: string) => void
}
export function HtmlEditor({
value,
onChange,
placeholder,
disabled = false,
}: HtmlEditorProps) {
const t = useI18n()
const editorPlaceholder = placeholder ?? t("editor.placeholder")
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: editorPlaceholder,
}),
],
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: t("editor.undo"),
icon: UndoIcon,
disabled: !editor.can().undo() || disabled,
onClick: () => editor.chain().focus().undo().run(),
},
{
key: "redo",
label: t("editor.redo"),
icon: RedoIcon,
disabled: !editor.can().redo() || disabled,
onClick: () => editor.chain().focus().redo().run(),
},
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: t("editor.bold"),
icon: BoldIcon,
disabled,
pressed: editor.isActive("bold"),
onClick: () => editor.chain().focus().toggleBold().run(),
},
{
key: "italic",
label: t("editor.italic"),
icon: ItalicIcon,
disabled,
pressed: editor.isActive("italic"),
onClick: () => editor.chain().focus().toggleItalic().run(),
},
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: t("editor.bulletList"),
icon: ListIcon,
disabled,
pressed: editor.isActive("bulletList"),
onClick: () => editor.chain().focus().toggleBulletList().run(),
},
{
key: "orderedList",
label: t("editor.orderedList"),
icon: ListOrderedIcon,
disabled,
pressed: editor.isActive("orderedList"),
onClick: () => editor.chain().focus().toggleOrderedList().run(),
},
{
key: "blockquote",
label: t("editor.quote"),
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>
)
}

View File

@@ -0,0 +1,49 @@
"use client"
import { HtmlEditor } from "./html"
import { MarkdownEditor } from "./markdown"
import type { BaseEditorProps, EditorValue } from "./types"
export type UnifiedEditorProps = BaseEditorProps & {
value: EditorValue
onChange: (next: EditorValue) => void
markdownRows?: number
}
export function UnifiedEditor({
value,
onChange,
placeholder,
disabled,
features,
className,
markdownRows,
}: UnifiedEditorProps) {
if (value.mode === "markdown") {
return (
<MarkdownEditor
value={value.raw}
onChange={(nextRaw) => onChange({ ...value, raw: nextRaw })}
placeholder={placeholder}
disabled={disabled}
features={features}
className={className}
rows={markdownRows}
/>
)
}
return (
<HtmlEditor
value={value.raw}
onChange={(nextRaw) => onChange({ ...value, raw: nextRaw })}
placeholder={placeholder}
disabled={disabled}
features={features}
className={className}
/>
)
}
export * from "./types"

View File

@@ -0,0 +1,172 @@
"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"
import { useI18n } from "@/i18n/provider"
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 t = useI18n()
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) || t("editor.linkText")
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: t("editor.heading1"),
icon: Heading1Icon,
disabled,
onClick: () => handleInsertLinePrefix("# "),
},
{ key: "separator-1", type: "separator" as const },
{
key: "bold",
label: t("editor.bold"),
icon: BoldIcon,
disabled,
onClick: () => handleWrapSelection("**"),
},
{
key: "italic",
label: t("editor.italic"),
icon: ItalicIcon,
disabled,
onClick: () => handleWrapSelection("*"),
},
{
key: "code",
label: t("editor.inlineCode"),
icon: CodeIcon,
disabled,
onClick: () => handleWrapSelection("`"),
},
{ key: "separator-2", type: "separator" as const },
{
key: "bulletList",
label: t("editor.bulletList"),
icon: ListIcon,
disabled,
onClick: () => handleInsertLinePrefix("- "),
},
{
key: "orderedList",
label: t("editor.orderedList"),
icon: ListOrderedIcon,
disabled,
onClick: () => handleInsertLinePrefix("1. "),
},
{
key: "blockquote",
label: t("editor.quote"),
icon: QuoteIcon,
disabled,
onClick: () => handleInsertLinePrefix("> "),
},
{
key: "link",
label: t("editor.link"),
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>
)
}

View File

@@ -0,0 +1,63 @@
"use client"
import type { LucideIcon } from "lucide-react"
import { Separator } from "@/components/ui/separator"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
type EditorToolbarButtonAction = {
key: string
label: string
icon: LucideIcon
onClick: () => void
disabled?: boolean
pressed?: boolean
}
type EditorToolbarSeparatorAction = {
key: string
type: "separator"
}
export type EditorToolbarAction = EditorToolbarButtonAction | EditorToolbarSeparatorAction
type EditorToolbarProps = {
actions: ReadonlyArray<EditorToolbarAction>
}
function isSeparatorAction(
action: EditorToolbarAction
): action is EditorToolbarSeparatorAction {
return "type" in action && action.type === "separator"
}
export function EditorToolbar({ actions }: EditorToolbarProps) {
return (
<div className="flex items-center gap-1 border-b p-2">
<ToggleGroup className="flex-wrap gap-1">
{actions.map((action) => {
if (isSeparatorAction(action)) {
return <Separator key={action.key} orientation="vertical" className="mx-1 h-6" />
}
const Icon = action.icon
return (
<ToggleGroupItem
key={action.key}
value={action.key}
aria-label={action.label}
disabled={action.disabled}
pressed={action.pressed}
onClick={action.onClick}
>
<Icon className="size-4" />
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
)
}

View File

@@ -0,0 +1,21 @@
export type EditorMode = "markdown" | "html"
export type EditorValue = {
mode: EditorMode
raw: string
}
export type EditorFeatures = {
image?: boolean
link?: boolean
table?: boolean
codeBlock?: boolean
}
export type BaseEditorProps = {
placeholder?: string
disabled?: boolean
features?: EditorFeatures
className?: string
}

View File

@@ -0,0 +1,86 @@
"use client"
import { memo, useEffect, useRef } from "react"
type ImMessageHTMLProps = {
html: string
className?: string
onImageSettled?: () => void
onImageClick?: (src: string, alt?: string) => void
}
function ImMessageHTMLComponent({
html,
className = "",
onImageSettled,
onImageClick,
}: ImMessageHTMLProps) {
const containerRef = useRef<HTMLDivElement>(null)
const onImageSettledRef = useRef(onImageSettled)
const onImageClickRef = useRef(onImageClick)
useEffect(() => {
onImageSettledRef.current = onImageSettled
}, [onImageSettled])
useEffect(() => {
onImageClickRef.current = onImageClick
}, [onImageClick])
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
const images = Array.from(container.querySelectorAll("img"))
if (images.length === 0) {
return
}
const cleanups = images.map((image) => {
const handleSettled = () => onImageSettledRef.current?.()
const handleClick = () => {
const src = image.getAttribute("src")
if (src) {
const alt = image.getAttribute("alt") ?? undefined
onImageClickRef.current?.(src, alt)
}
}
image.addEventListener("load", handleSettled)
image.addEventListener("error", handleSettled)
image.addEventListener("click", handleClick)
if (image.complete) {
onImageSettledRef.current?.()
}
image.classList.add("cursor-zoom-in")
return () => {
image.removeEventListener("load", handleSettled)
image.removeEventListener("error", handleSettled)
image.removeEventListener("click", handleClick)
}
})
return () => {
cleanups.forEach((cleanup) => cleanup())
}
}, [html, onImageClick, onImageSettled])
return (
<div
ref={containerRef}
className={`break-words text-sm [&_p]:m-0 [&_p+*]:mt-2 [&_h1]:m-0 [&_h1]:text-base [&_h1]:font-semibold [&_h1+*]:mt-2 [&_h2]:m-0 [&_h2]:text-[15px] [&_h2]:font-semibold [&_h2+*]:mt-2 [&_h3]:m-0 [&_h3]:font-semibold [&_h3+*]:mt-2 [&_h4]:m-0 [&_h4]:font-medium [&_h4+*]:mt-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-current/20 [&_blockquote]:pl-3 [&_blockquote]:opacity-90 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-black/6 [&_pre]:px-3 [&_pre]:py-2 [&_pre]:text-[13px] [&_pre]:leading-6 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 [&_code]:py-0.5 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_hr]:my-3 [&_hr]:border-current/10 [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-current/10 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium [&_td]:border [&_td]:border-current/10 [&_td]:px-2 [&_td]:py-1 [&_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
)

View File

@@ -0,0 +1,153 @@
"use client"
import { useRef, useState } from "react"
import Image from "next/image"
import { UploadIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { uploadAsset } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
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
upload?: (file: File, prefix?: string) => Promise<{ url: string }>
}
export function ImageInput({
value,
onChange,
disabled,
accept = "image/*",
maxSize = 5 * 1024 * 1024,
prefix,
placeholder,
className,
upload = uploadAsset,
}: ImageInputProps) {
const t = useI18n()
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const resolvedPlaceholder = placeholder ?? t("upload.imagePlaceholder")
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(t("upload.chooseImage"))
return
}
if (file.size > maxSize) {
const maxSizeMB = (maxSize / 1024 / 1024).toFixed(0)
toast.error(t("upload.imageTooLarge", { maxSize: maxSizeMB }))
return
}
setUploading(true)
try {
const result = await upload(file, prefix)
onChange?.(result.url)
toast.success(t("upload.imageUploaded"))
} catch (error) {
toast.error(error instanceof Error ? error.message : t("upload.imageUploadFailed"))
} finally {
setUploading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
const isDisabled = disabled || uploading
return (
<div className="relative w-fit">
<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",
className
)}
tabIndex={isDisabled ? -1 : 0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleClick()
}
}}
role="button"
aria-label={value ? t("upload.replaceImage") : resolvedPlaceholder}
>
{value ? (
<>
<Image
src={value}
alt={t("upload.uploadedImage")}
fill
sizes="96px"
unoptimized
className="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-xs text-white">{t("upload.replaceImage")}</span>
</div>
</>
) : (
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<UploadIcon className="size-4" />
<span className="text-xs">{uploading ? t("upload.uploading") : resolvedPlaceholder}</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-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full bg-red-600 text-white shadow-sm transition-colors hover:bg-red-700 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
aria-label={t("upload.deleteImage")}
>
<XIcon className="size-3" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,521 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import {
ChevronLeftIcon,
ChevronRightIcon,
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";
import { translateCurrentMessage } from "@/i18n/messages";
import { useI18n } from "@/i18n/provider";
export type ImageLightboxItem = {
src: string;
alt?: string;
svg?: string;
backgroundColor?: string;
};
type ImageLightboxState = {
items: ImageLightboxItem[];
index: number;
};
export type ImageLightboxContextValue = {
open: (src: string, alt?: string) => void;
openGallery: (items: ImageLightboxItem[], initialIndex?: number) => void;
close: () => void;
};
const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(
null,
);
export function useImageLightbox(): ImageLightboxContextValue {
const ctx = useContext(ImageLightboxContext);
if (!ctx) {
throw new Error(translateCurrentMessage("lightbox.providerError"));
}
return ctx;
}
/** Returns null when no provider is mounted, which keeps adoption incremental. */
export function useImageLightboxOptional(): ImageLightboxContextValue | null {
return useContext(ImageLightboxContext);
}
export type ImageLightboxProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
src: string | null;
alt?: string;
svg?: string;
backgroundColor?: string;
currentIndex?: number;
total?: number;
onPrevious?: () => void;
onNext?: () => void;
};
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,
svg,
backgroundColor,
pinchRef,
rotationDeg,
}: {
src: string;
alt?: string;
svg?: string;
backgroundColor?: string;
pinchRef: React.RefObject<ReactZoomPanPinchContentRef | null>;
rotationDeg: number;
}) {
const t = useI18n();
const [imageState, setImageState] = useState<{
src: string;
status: "loading" | "loaded" | "error";
}>({ src, status: "loading" });
const status = imageState.src === src ? imageState.status : "loading";
const loading = status === "loading";
const error = status === "error";
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">
{!svg && 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}
{!svg && 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>{t("lightbox.loadFailed")}</p>
{showOpenTab ? (
<a
href={src}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
>
{t("lightbox.openInNewTab")}
</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"
>
{svg ? (
<div
role="img"
aria-label={alt || t("lightbox.previewImage")}
style={{
backgroundColor,
transform: `rotate(${rotationDeg}deg)`,
}}
className="h-[min(78vh,900px)] w-[min(88vw,1600px)] max-h-[calc(100dvh-5rem)] max-w-[calc(100vw-2rem)] origin-center rounded-xl p-4 transition-transform duration-200 ease-out select-none sm:p-6 [&_svg]:!block [&_svg]:!h-full [&_svg]:!max-h-none [&_svg]:!max-w-none [&_svg]:!w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
) : (
/* eslint-disable-next-line @next/next/no-img-element -- External URLs and arbitrary image sizes need native preview. */
<img
src={src}
alt={alt || t("lightbox.previewImage")}
draggable={false}
style={{ transform: `rotate(${rotationDeg}deg)` }}
className="max-h-[min(85vh,calc(100dvh-3rem))] max-w-full origin-center object-contain transition-transform duration-200 ease-out select-none"
onLoad={() => {
setImageState({ src, status: "loaded" });
requestAnimationFrame(() => {
pinchRef.current?.centerView(1, 0);
});
}}
onError={() => {
setImageState({ src, status: "error" });
}}
/>
)}
</TransformComponent>
</TransformWrapper>
)}
</div>
);
}
/** Mounted by src key so rotation resets when switching images. */
function ImageLightboxDialogContent({
src,
alt,
svg,
backgroundColor,
currentIndex = 0,
total = 1,
onPrevious,
onNext,
}: {
src: string;
alt?: string;
svg?: string;
backgroundColor?: string;
currentIndex?: number;
total?: number;
onPrevious?: () => void;
onNext?: () => void;
}) {
const t = useI18n();
const pinchRef = useRef<ReactZoomPanPinchContentRef | null>(null);
const [rotation, setRotation] = useState({ src, degrees: 0 });
const rotationDeg = rotation.src === src ? rotation.degrees : 0;
const showOpenTab = canOpenInNewTab(src);
const titleText = alt?.trim() || t("lightbox.imagePreview");
const hasGallery = total > 1;
useEffect(() => {
requestAnimationFrame(() => {
pinchRef.current?.resetTransform(0);
});
}, [src]);
useEffect(() => {
if (!hasGallery) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft" && onPrevious) {
event.preventDefault();
onPrevious();
} else if (event.key === "ArrowRight" && onNext) {
event.preventDefault();
onNext();
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [hasGallery, onNext, onPrevious]);
const rotateLeft = useCallback(() => {
setRotation({ src, degrees: (rotationDeg - 90 + 360) % 360 });
}, [rotationDeg, src]);
const rotateRight = useCallback(() => {
setRotation({ src, degrees: (rotationDeg + 90) % 360 });
}, [rotationDeg, src]);
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}
{hasGallery ? (
<span className="ml-2 text-white/60">
{t("lightbox.position", { current: currentIndex + 1, total })}
</span>
) : null}
</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={t("lightbox.zoomIn")}
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={t("lightbox.zoomOut")}
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={t("lightbox.rotateLeft")}
onClick={rotateLeft}
>
<RotateCcwIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label={t("lightbox.rotateRight")}
onClick={rotateRight}
>
<RotateCwIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-white hover:bg-white/10"
aria-label={t("lightbox.reset")}
onClick={() => {
setRotation({ src, degrees: 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={t("lightbox.openInNewTab")}
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={t("lightbox.close")}
/>
}
>
<XIcon className="size-4" />
<span className="sr-only">{t("lightbox.close")}</span>
</DialogClose>
</div>
</div>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{hasGallery ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!onPrevious}
className="absolute left-2 top-1/2 z-20 size-11 -translate-y-1/2 rounded-full bg-black/45 text-white shadow-lg hover:border-white/25 hover:bg-white/20 hover:text-white active:-translate-y-1/2 dark:hover:bg-white/20 disabled:opacity-25 sm:left-4"
aria-label={t("lightbox.previous")}
onClick={onPrevious}
>
<ChevronLeftIcon className="size-6" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!onNext}
className="absolute right-2 top-1/2 z-20 size-11 -translate-y-1/2 rounded-full bg-black/45 text-white shadow-lg hover:border-white/25 hover:bg-white/20 hover:text-white active:-translate-y-1/2 dark:hover:bg-white/20 disabled:opacity-25 sm:right-4"
aria-label={t("lightbox.next")}
onClick={onNext}
>
<ChevronRightIcon className="size-6" />
</Button>
</>
) : null}
<LightboxImageBody
pinchRef={pinchRef}
rotationDeg={rotationDeg}
src={src}
alt={alt}
svg={svg}
backgroundColor={backgroundColor}
/>
</div>
<p className="sr-only">
{hasGallery ? t("lightbox.galleryHelp") : t("lightbox.help")}
</p>
</DialogPrimitive.Popup>
</DialogPortal>
);
}
export function ImageLightboxView({
open,
onOpenChange,
src,
alt,
svg,
backgroundColor,
currentIndex = 0,
total = 1,
onPrevious,
onNext,
}: ImageLightboxProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{src ? (
<ImageLightboxDialogContent
src={src}
alt={alt}
svg={svg}
backgroundColor={backgroundColor}
currentIndex={currentIndex}
total={total}
onPrevious={onPrevious}
onNext={onNext}
/>
) : null}
</Dialog>
);
}
export function ImageLightboxProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ImageLightboxState | null>(null);
const open = useCallback((src: string, alt?: string) => {
const trimmed = src?.trim();
if (!trimmed) {
return;
}
setState({ items: [{ src: trimmed, alt }], index: 0 });
}, []);
const openGallery = useCallback(
(items: ImageLightboxItem[], initialIndex = 0) => {
const normalizedItems = items
.map((item) => ({ ...item, src: item.src?.trim() }))
.filter((item): item is ImageLightboxItem => Boolean(item.src));
if (!normalizedItems.length) {
return;
}
const index = Math.min(Math.max(initialIndex, 0), normalizedItems.length - 1);
setState({ items: normalizedItems, index });
},
[],
);
const close = useCallback(() => {
setState(null);
}, []);
const contextValue = useMemo(
() => ({
open,
openGallery,
close,
}),
[open, openGallery, close],
);
const currentItem = state?.items[state.index] ?? null;
return (
<ImageLightboxContext.Provider value={contextValue}>
{children}
<ImageLightboxView
open={state !== null}
onOpenChange={(next) => {
if (!next) {
setState(null);
}
}}
src={currentItem?.src ?? null}
alt={currentItem?.alt}
svg={currentItem?.svg}
backgroundColor={currentItem?.backgroundColor}
currentIndex={state?.index ?? 0}
total={state?.items.length ?? 0}
onPrevious={state && state.index > 0 ? () => {
setState((current) => current ? { ...current, index: Math.max(0, current.index - 1) } : current);
} : undefined}
onNext={state && state.index < state.items.length - 1 ? () => {
setState((current) => current ? { ...current, index: Math.min(current.items.length - 1, current.index + 1) } : current);
} : undefined}
/>
</ImageLightboxContext.Provider>
);
}

View File

@@ -0,0 +1,77 @@
"use client"
import { useEffect, useMemo } from "react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type JsonCodeEditorProps = {
value: string
onChange: (value: string) => void
onValidationChange?: (error: string | null) => void
disabled?: boolean
className?: string
}
function validateJson(value: string, fallbackMessage: string) {
const text = value.trim()
if (!text) {
return null
}
try {
JSON.parse(text)
return null
} catch (error) {
return error instanceof Error ? error.message : fallbackMessage
}
}
export function JsonCodeEditor({
value,
onChange,
onValidationChange,
disabled = false,
className,
}: JsonCodeEditorProps) {
const t = useI18n()
const error = useMemo(() => validateJson(value, t("json.invalid")), [t, 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 ? t("json.invalidLabel") : t("json.valid")}
</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 || t("json.readyHint")}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import type { CSSProperties } from "react"
import JsonView from "@uiw/react-json-view"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type JsonTreeViewerProps = {
value: unknown
emptyText?: string
className?: string
collapsed?: boolean | number
}
const viewerTheme = {
"--w-rjv-font-family":
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
"--w-rjv-background-color": "transparent",
"--w-rjv-color": "hsl(var(--foreground))",
"--w-rjv-line-color": "hsl(var(--border))",
"--w-rjv-arrow-color": "hsl(var(--muted-foreground))",
"--w-rjv-info-color": "hsl(var(--muted-foreground))",
"--w-rjv-curlybraces-color": "hsl(var(--foreground))",
"--w-rjv-brackets-color": "hsl(var(--foreground))",
"--w-rjv-colon-color": "hsl(var(--muted-foreground))",
"--w-rjv-ellipsis-color": "hsl(var(--muted-foreground))",
"--w-rjv-key-string": "hsl(199 89% 58%)",
"--w-rjv-type-string-color": "hsl(142 76% 45%)",
"--w-rjv-type-int-color": "hsl(262 83% 70%)",
"--w-rjv-type-float-color": "hsl(262 83% 70%)",
"--w-rjv-type-boolean-color": "hsl(38 92% 50%)",
"--w-rjv-type-null-color": "hsl(347 77% 50%)",
} as CSSProperties
export function JsonTreeViewer({
value,
emptyText,
className,
collapsed = 2,
}: JsonTreeViewerProps) {
const t = useI18n()
if (value == null || value === "") {
return (
<div
className={cn(
"rounded-md border bg-muted/20 px-4 py-3 font-mono text-xs text-muted-foreground",
className
)}
>
{emptyText ?? t("json.empty")}
</div>
)
}
return (
<div className={cn("rounded-md border bg-muted/20 p-3", className)}>
<JsonView
value={value as Record<string, unknown>}
collapsed={collapsed}
displayDataTypes={false}
displayObjectSize
enableClipboard
shortenTextAfterLength={120}
style={viewerTheme}
/>
</div>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type JsonViewerProps = {
value: unknown
emptyText?: string
className?: string
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}
function formatJson(value: unknown) {
if (value === undefined) {
return ""
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function highlightJson(value: string) {
const escaped = escapeHtml(value)
return escaped.replace(
/("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let className = "text-slate-200"
if (match.startsWith('"')) {
className = match.endsWith(":") ? "text-sky-300" : "text-emerald-300"
} else if (match === "true" || match === "false") {
className = "text-amber-300"
} else if (match === "null") {
className = "text-rose-300"
} else {
className = "text-violet-300"
}
return `<span class="${className}">${match}</span>`
}
)
}
export function JsonViewer({
value,
emptyText,
className,
}: JsonViewerProps) {
const t = useI18n()
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 ?? t("json.empty")}
</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) }}
/>
)
}

View File

@@ -0,0 +1,110 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
import enUSMessages from "@/messages/en-US.json"
import ruRUMessages from "@/messages/ru-RU.json"
import zhCNMessages from "@/messages/zh-CN.json"
type LegalPageType = "terms" | "privacy"
type LegalSection = {
title: string
body: string
}
type LegalDocument = {
title: string
description: string
updatedAt: string
relatedLabel: string
relatedLink: string
sections: LegalSection[]
}
const messages = {
"zh-CN": zhCNMessages,
"en-US": enUSMessages,
"ru-RU": ruRUMessages,
}
export function LegalDocumentPage({ type }: { type: LegalPageType }) {
const t = useI18n()
const { locale } = useAppLocale()
const [config, setConfig] = useState<PublicConfig | null>(null)
const document = messages[locale].legal[type] as LegalDocument
const relatedHref = type === "terms" ? "/legal/privacy" : "/legal/terms"
useEffect(() => {
fetchPublicConfig()
.then(setConfig)
.catch(() => setConfig(null))
}, [])
const brandName = config?.companyName || t("app.brand")
const brandLogo = config?.companyLogoUrl || "/images/logo.svg"
return (
<main className="min-h-svh bg-muted px-6 py-8 md:px-10">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex items-center gap-4">
<div className="flex items-center gap-2 font-medium">
<Image
src={brandLogo}
alt={brandName}
width={32}
height={32}
className="size-8 object-contain"
priority
unoptimized={brandLogo.startsWith("http")}
/>
<span>{brandName}</span>
</div>
</header>
<Card className="bg-card/95">
<CardHeader className="gap-3 border-b px-6 py-6 md:px-8">
<div className="flex flex-col gap-2">
<CardTitle className="text-3xl font-semibold tracking-tight">
{document.title}
</CardTitle>
<p className="text-sm leading-6 text-muted-foreground">
{document.description}
</p>
</div>
<p className="text-xs text-muted-foreground">{document.updatedAt}</p>
</CardHeader>
<CardContent className="space-y-8 px-6 py-7 md:px-8">
{document.sections.map((section) => (
<section key={section.title} className="space-y-2">
<h2 className="text-lg font-semibold tracking-tight">
{section.title}
</h2>
<p className="text-sm leading-7 text-muted-foreground">
{section.body}
</p>
</section>
))}
</CardContent>
</Card>
<footer className="flex justify-end text-sm text-muted-foreground">
<p>
{document.relatedLabel}{" "}
<Link
href={relatedHref}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
{document.relatedLink}
</Link>
</p>
</footer>
</div>
</main>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useI18n } from "@/i18n/provider"
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 t = useI18n()
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>
{t("pagination.pageSummary", { page, totalPages })}
</span>
<span>{t("pagination.total", { 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)}>
{t("pagination.pageSize", { pageSize })}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => onPageChange(page - 1)}
disabled={loading || !canGoPreviousPage}
>
<ChevronLeftIcon />
{t("pagination.previous")}
</Button>
<Button
variant="outline"
onClick={() => onPageChange(page + 1)}
disabled={loading || !canGoNextPage}
>
{t("pagination.next")}
<ChevronRightIcon />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,234 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import type { PageData } from "@/lib/api/types"
type LoadMoreLabels = {
loadMore: string
noMore: string
loading?: string
error?: string
}
type LoadMoreRequest = {
cursor: string
force: boolean
}
export function LoadMore<T>({
initialItems = [],
initialCursor,
initialHasMore,
initialLoad = false,
resetKey,
labels,
loadPage,
renderItems,
renderEmpty,
alwaysShowButton = false,
autoLoadOnScroll = false,
}: {
initialItems?: T[] | null
initialCursor?: string
initialHasMore: boolean
initialLoad?: boolean
resetKey?: string
labels: LoadMoreLabels
loadPage: (request: LoadMoreRequest) => Promise<PageData<T>>
renderItems: (items: T[]) => React.ReactNode
renderEmpty?: () => React.ReactNode
alwaysShowButton?: boolean
autoLoadOnScroll?: boolean
}) {
const safeInitialItems = Array.isArray(initialItems) ? initialItems : []
const contentKey = React.useMemo(
() =>
resetKey ||
JSON.stringify({
initialCursor: initialCursor || "",
initialHasMore,
initialLoad,
initialItemsLength: safeInitialItems.length,
}),
[initialCursor, initialHasMore, initialLoad, resetKey, safeInitialItems.length]
)
return (
<LoadMoreContent
key={contentKey}
initialItems={safeInitialItems}
initialCursor={initialCursor}
initialHasMore={initialHasMore}
initialLoad={initialLoad}
labels={labels}
loadPage={loadPage}
renderItems={renderItems}
renderEmpty={renderEmpty}
alwaysShowButton={alwaysShowButton}
autoLoadOnScroll={autoLoadOnScroll}
/>
)
}
function LoadMoreContent<T>({
initialItems,
initialCursor,
initialHasMore,
initialLoad,
labels,
loadPage,
renderItems,
renderEmpty,
alwaysShowButton,
autoLoadOnScroll,
}: {
initialItems: T[]
initialCursor?: string
initialHasMore: boolean
initialLoad: boolean
labels: LoadMoreLabels
loadPage: (request: LoadMoreRequest) => Promise<PageData<T>>
renderItems: (items: T[]) => React.ReactNode
renderEmpty?: () => React.ReactNode
alwaysShowButton: boolean
autoLoadOnScroll: boolean
}) {
const [cursor, setCursor] = React.useState(initialCursor || "")
const [hasMore, setHasMore] = React.useState(initialHasMore || (initialLoad && initialItems.length === 0))
const [items, setItems] = React.useState<T[]>(initialItems)
const [loaded, setLoaded] = React.useState(!initialLoad || initialItems.length > 0)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const inFlightRef = React.useRef(false)
const mountedRef = React.useRef(true)
const loadPageRef = React.useRef(loadPage)
const sentinelRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
loadPageRef.current = loadPage
}, [loadPage])
React.useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const loadMore = React.useCallback(
async (force = false) => {
if (inFlightRef.current || (!force && !hasMore)) {
return
}
inFlightRef.current = true
setLoading(true)
setError(null)
try {
const data = await loadPageRef.current({
cursor: force ? "" : cursor,
force,
})
if (!mountedRef.current) {
return
}
setItems((current) => (force ? data.results || [] : [...current, ...(data.results || [])]))
setCursor(data.cursor || "")
setHasMore(Boolean(data.hasMore))
setLoaded(true)
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : labels.error || "Could not load more items.")
}
} finally {
inFlightRef.current = false
if (mountedRef.current) {
setLoading(false)
}
}
},
[cursor, hasMore, labels.error]
)
React.useEffect(() => {
if (!initialLoad || initialItems.length > 0 || loaded || loading || inFlightRef.current) {
return
}
const timer = window.setTimeout(() => {
void loadMore(true)
}, 0)
return () => window.clearTimeout(timer)
}, [initialItems.length, initialLoad, loadMore, loaded, loading])
React.useEffect(() => {
if (!autoLoadOnScroll || !hasMore || loading) {
return
}
const sentinel = sentinelRef.current
if (!sentinel || typeof IntersectionObserver === "undefined") {
return
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
void loadMore()
}
},
{ rootMargin: "160px 0px" }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [autoLoadOnScroll, hasMore, loadMore, loading])
async function onLoadMore() {
if (inFlightRef.current || !hasMore) {
return
}
await loadMore()
}
const showButton = alwaysShowButton || items.length > 0 || hasMore || loading
return (
<>
{items.length ? renderItems(items) : loaded && !loading && !error ? renderEmpty?.() : null}
{showButton ? (
<LoadMoreButton
loading={loading}
hasMore={hasMore}
loadingLabel={labels.loading}
labels={labels}
onClick={onLoadMore}
/>
) : null}
{autoLoadOnScroll ? <div ref={sentinelRef} aria-hidden="true" className="h-px" /> : null}
{error ? <p className="-mt-4 pb-5 text-center text-xs text-destructive">{error}</p> : null}
</>
)
}
export function LoadMoreButton({
loading,
hasMore,
loadingLabel,
labels,
onClick,
}: {
loading: boolean
hasMore: boolean
loadingLabel?: string
labels: LoadMoreLabels
onClick: () => void
}) {
return (
<div className="p-5 text-center">
<Button type="button" variant="link" disabled={loading || !hasMore} onClick={onClick} className="w-[150px]">
{loading ? loadingLabel || labels.loading || labels.loadMore : hasMore ? labels.loadMore : labels.noMore}
</Button>
</div>
)
}

View File

@@ -0,0 +1,279 @@
"use client"
import Image from "next/image"
import Link from "next/link"
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 { fetchPublicConfig, type PublicConfig } from "@/lib/api/config"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { KeyRoundIcon, Loader2Icon, TriangleAlertIcon } from "lucide-react"
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const t = useI18n()
const router = useRouter()
const searchParams = useSearchParams()
const { session, ready } = useAuth()
const [isPending, setIsPending] = useState(false)
const [publicConfig, setPublicConfig] = useState<PublicConfig | null>(null)
const [publicConfigError, setPublicConfigError] = useState<string | null>(null)
const nextPath = searchParams.get("next")
const oidcError = searchParams.get("oidcError")
const redirectPath =
nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard"
const enabledProviderCount =
Number(publicConfig?.oidcEnabled)
const isPasswordLoginEnabled = publicConfig?.passwordLoginEnabled !== false
// Redirect-only mode: when OIDC is the only login transport, skip the
// chooser and go straight to the provider. Suppressed while the session
// probe is in flight (an already-signed-in visitor goes to their
// destination instead of the IdP), and when
// the provider bounced back with ?oidcError= - otherwise this would loop.
const shouldRedirectToOIDC = Boolean(
ready &&
!session &&
publicConfig &&
publicConfig.oidcEnabled &&
!publicConfig.passwordLoginEnabled &&
!oidcError,
)
useEffect(() => {
if (!shouldRedirectToOIDC) {
return
}
window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(redirectPath)}`
}, [shouldRedirectToOIDC, redirectPath])
useEffect(() => {
if (session) {
router.replace(redirectPath)
}
}, [redirectPath, router, session])
useEffect(() => {
if (oidcError) {
toast.error(oidcError)
}
}, [oidcError])
useEffect(() => {
let cancelled = false
void fetchPublicConfig()
.then((options) => {
if (!cancelled) {
setPublicConfig(options)
setPublicConfigError(null)
}
})
.catch((error) => {
if (!cancelled) {
setPublicConfig(null)
setPublicConfigError(error instanceof Error ? error.message : "")
}
})
return () => {
cancelled = true
}
}, [])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!isPasswordLoginEnabled) {
return
}
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(t("auth.loginSuccess"))
startTransition(() => {
router.push(redirectPath)
})
} catch (error) {
toast.error(error instanceof Error ? error.message : t("auth.loginFailed"))
} finally {
setIsPending(false)
}
}
if (publicConfigError) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 p-8 text-center">
<TriangleAlertIcon className="size-8 text-destructive" />
<div className="space-y-1">
<h1 className="text-lg font-semibold">{t("auth.optionsLoadFailed")}</h1>
<p className="text-sm text-muted-foreground">
{publicConfigError || t("api.requestFailed")}
</p>
</div>
</CardContent>
</Card>
</div>
)
}
if (!publicConfig) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 p-8 text-center">
<Loader2Icon className="size-7 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("auth.loadingOptions")}</p>
</CardContent>
</Card>
</div>
)
}
if (shouldRedirectToOIDC) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 p-8 text-center">
<Loader2Icon className="size-7 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("auth.redirectingToOidc")}</p>
</CardContent>
</Card>
</div>
)
}
return (
<div
className={cn("flex flex-col gap-6", className)}
{...props}
>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" onSubmit={handleSubmit}>
<FieldGroup>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">{t("auth.welcome")}</h1>
<p className="text-balance text-muted-foreground">
{t("auth.loginDescription", { brand: publicConfig.companyName || t("app.brand") })}
</p>
</div>
{isPasswordLoginEnabled ? (
<>
<Field>
<FieldLabel htmlFor="username">{t("auth.username")}</FieldLabel>
<Input
id="username"
name="username"
placeholder={t("auth.usernamePlaceholder")}
autoComplete="username"
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">{t("auth.password")}</FieldLabel>
<a
href="#"
className="ml-auto text-sm underline-offset-2 hover:underline"
>
{t("auth.forgotPassword")}
</a>
</div>
<Input
id="password"
name="password"
type="password"
placeholder={t("auth.passwordPlaceholder")}
autoComplete="current-password"
required
/>
</Field>
<Field>
<Button type="submit" disabled={isPending}>
{isPending ? t("auth.signingIn") : t("auth.signIn")}
</Button>
</Field>
</>
) : null}
{enabledProviderCount > 0 ? (
<>
{isPasswordLoginEnabled ? (
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
{t("auth.continueWith")}
</FieldSeparator>
) : null}
<Field
className={cn(
"grid gap-4",
enabledProviderCount === 1 || !isPasswordLoginEnabled ? "grid-cols-1" : "grid-cols-2"
)}
>
{publicConfig.oidcEnabled ? (
<Button
type="button"
variant="outline"
aria-label={t("auth.oidcSignIn")}
onClick={() => {
window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(redirectPath)}`
}}
>
<KeyRoundIcon className="size-4 shrink-0" />
<span>{t("auth.oidcSignIn")}</span>
</Button>
) : null}
</Field>
</>
) : null}
{/* <FieldDescription className="text-center">
{t("auth.noAccount")} <a href="#">{t("auth.signUp")}</a>
</FieldDescription> */}
</FieldGroup>
</form>
<div className="relative hidden bg-muted md:block">
{/* shadcn login-04 uses a plain img for the decorative side panel. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/login-illustration.svg"
alt={t("auth.imageAlt")}
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
{t("auth.termsPrefix")}{" "}
<Link href="/legal/terms" target="_blank" rel="noreferrer">
{t("auth.termsOfService")}
</Link>{" "}
{t("auth.termsJoiner")}{" "}
<Link href="/legal/privacy" target="_blank" rel="noreferrer">
{t("auth.privacyPolicy")}
</Link>
.
</FieldDescription>
</div>
)
}

View File

@@ -0,0 +1,97 @@
"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"
import { useI18n } from "@/i18n/provider"
export function NavDocuments({
items,
}: {
items: ReadonlyArray<{
name: string
url: string
icon: React.ReactNode
}>
}) {
const pathname = usePathname()
const { isMobile } = useSidebar()
const t = useI18n()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>{t("scaffold.businessModules")}</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>{t("scaffold.open")}</span>
</DropdownMenuItem>
<DropdownMenuItem>
<ShareIcon
/>
<span>{t("scaffold.share")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon
/>
<span>{t("scaffold.delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontalIcon className="text-sidebar-foreground/70" />
<span>{t("scaffold.more")}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
const source = await readFile(new URL("./nav-main.tsx", import.meta.url), "utf8")
test("dashboard nav items expose full labels when visual text is truncated", () => {
assert.match(source, /<span\s+title=\{title\}>\{title\}<\/span>/)
assert.match(source, /tooltip=\{item\.title\}/)
assert.match(source, /<span\s+title=\{item\.title\}>\{item\.title\}<\/span>/)
})
test("collapsed dashboard nav sections expose submenu flyouts on hover", () => {
assert.match(source, /useSidebar/)
assert.match(source, /state === "collapsed" && !isMobile/)
assert.match(source, /<DropdownMenuTrigger\s+openOnHover/)
assert.match(source, /render=\{<SidebarMenuButton \/>\}/)
assert.match(source, /<DropdownMenuContent[\s\S]*side="right"/)
assert.match(source, /<DropdownMenuGroup>[\s\S]*<DropdownMenuLabel/)
})

160
web/components/nav-main.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client"
import { ChevronRightIcon } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useState } from "react"
import {
getDashboardNavSectionStorageKey,
isDashboardNavItemActive,
parseDashboardNavSectionOpenState,
} from "@/lib/navigation-active"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavMain({
icon,
sectionKey,
title,
items,
}: {
icon?: React.ReactNode
sectionKey: string
title: string
items: ReadonlyArray<{
title: string
url: string
icon?: React.ReactNode
}>
}) {
const pathname = usePathname()
const { isMobile, state } = useSidebar()
const storageKey = getDashboardNavSectionStorageKey(sectionKey)
const [open, setOpen] = useState(() => {
if (typeof window === "undefined") {
return true
}
return parseDashboardNavSectionOpenState(window.localStorage.getItem(storageKey)) ?? true
})
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
window.localStorage.setItem(storageKey, String(nextOpen))
}
if (state === "collapsed" && !isMobile) {
return (
<SidebarGroup className="px-2 py-1 first:pt-2 last:pb-2">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
openOnHover
render={<SidebarMenuButton />}
>
{icon}
<span className="min-w-0 flex-1 truncate" title={title}>
{title}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
sideOffset={8}
className="w-56"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="truncate" title={title}>
{title}
</DropdownMenuLabel>
{items.map((item) => (
<DropdownMenuItem
key={item.title}
render={<Link href={item.url} />}
className="cursor-pointer"
>
<span className="truncate" title={item.title}>
{item.title}
</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
return (
<SidebarGroup className="px-2 py-1 first:pt-2 last:pb-2">
<SidebarMenu className="gap-0.5">
<Collapsible
open={open}
onOpenChange={handleOpenChange}
className="group/collapsible"
render={<SidebarMenuItem />}
>
{/* A section title is the one place the label is not the button's last
child, so the shared truncate never reaches it. Left unconstrained
it wrapped inside the fixed h-8 and spilled over its neighbours,
so let it wrap deliberately and give the row the height it needs. */}
<CollapsibleTrigger
render={
<SidebarMenuButton
tooltip={title}
className="h-auto min-h-8 items-start py-1.5"
/>
}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0 flex-1 leading-5" title={title}>
{title}
</span>
<ChevronRightIcon className="mt-0.5 ml-auto shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="gap-0.5 py-1">
{items.map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton
render={<Link href={item.url} />}
isActive={isDashboardNavItemActive(pathname, item.url)}
tooltip={item.title}
>
<span className="min-w-0 flex-1 truncate" title={item.title}>
{item.title}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,53 @@
"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 className="gap-0.5">
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
render={<Link href={item.url} />}
isActive={isActive(item.url)}
tooltip={item.title}
>
{item.icon}
<span className="min-w-0 flex-1 truncate" title={item.title}>
{item.title}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

155
web/components/nav-user.tsx Normal file
View File

@@ -0,0 +1,155 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useAuth } from "@/components/auth-provider"
import { useI18n } from "@/i18n/provider"
import { ChangePasswordDialog } from "@/components/change-password-dialog"
import { useNotifications } from "@/components/notification-provider"
import { PaletteToggle } from "@/components/palette-toggle"
import { ThemeToggle } from "@/components/theme-toggle"
import { Badge } from "@/components/ui/badge"
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 t = useI18n()
const { signOut } = useAuth()
const { unreadCount } = useNotifications()
const { isMobile } = useSidebar()
const router = useRouter()
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>
<DropdownMenuLabel className="px-2 py-1.5 text-xs font-normal text-muted-foreground">
{t("nav.preferences")}
</DropdownMenuLabel>
<div className="flex items-center gap-2 px-2 pb-2">
<PaletteToggle />
<ThemeToggle />
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
router.push("/dashboard/notifications")
}}
className="gap-2"
>
<BellIcon />
<span className="flex-1">{t("nav.notifications")}</span>
{unreadCount > 0 ? (
<Badge className="h-5 min-w-5 px-1.5">
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setChangePasswordOpen(true)
}}
>
<KeyRoundIcon />
{t("nav.changePassword")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
void signOut()
}}
>
<LogOutIcon />
{t("nav.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<ChangePasswordDialog
open={changePasswordOpen}
onOpenChange={setChangePasswordOpen}
onSuccess={signOut}
/>
</>
)
}

View File

@@ -0,0 +1,164 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
createNotificationWebSocketUrl,
fetchNotificationUnreadCount,
markNotificationRead,
type NotificationItem,
} from "@/lib/api/notification"
import { readSession } from "@/lib/auth"
import {
createRealtimeConnectionManager,
type RealtimeConnectionStatus,
} from "@/lib/realtime-connection"
import { useAppLocale, useI18n } from "@/i18n/provider"
import { localizeNotificationItem } from "@/lib/notification-i18n"
type NotificationRealtimeEnvelope = {
eventId?: string
type?: string
data?: {
notification?: NotificationItem
}
}
type NotificationContextValue = {
unreadCount: number
realtimeStatus: RealtimeConnectionStatus
refreshUnreadCount: () => Promise<void>
markReadAndNavigate: (notification: NotificationItem) => Promise<void>
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
export function NotificationProvider({ children }: { children: ReactNode }) {
const t = useI18n()
const { locale } = useAppLocale()
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [realtimeStatus, setRealtimeStatus] =
useState<RealtimeConnectionStatus>("disconnected")
const currentUserIdRef = useRef(readSession()?.user.id ?? 0)
const refreshUnreadCount = useCallback(async () => {
const result = await fetchNotificationUnreadCount()
setUnreadCount(result.unreadCount)
}, [])
const markReadAndNavigate = useCallback(
async (notification: NotificationItem) => {
if (!notification.readAt) {
await markNotificationRead(notification.id)
setUnreadCount((current) => Math.max(0, current - 1))
}
if (notification.actionUrl) {
router.push(notification.actionUrl)
}
},
[router]
)
useEffect(() => {
currentUserIdRef.current = readSession()?.user.id ?? 0
void refreshUnreadCount().catch(() => {
setUnreadCount(0)
})
}, [refreshUnreadCount])
useEffect(() => {
const realtime = createRealtimeConnectionManager({
createSocket: () => new WebSocket(createNotificationWebSocketUrl()),
canReconnect: () => Boolean(readSession()?.accessToken),
onStatusChange: setRealtimeStatus,
onOpen: () => {
void refreshUnreadCount().catch(() => undefined)
},
onMessage: (event, socket) => {
try {
const envelope = JSON.parse(event.data) as NotificationRealtimeEnvelope
const eventType = envelope.type ?? ""
const eventId = envelope.eventId?.trim() ?? ""
if (
eventType === "" ||
eventType === "connected" ||
eventType === "pong" ||
eventType === "subscribed" ||
eventType === "unsubscribed"
) {
return
}
if (eventId && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ack", eventId }))
}
if (eventType !== "notification.created") {
return
}
const notification = envelope.data?.notification
if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
return
}
const localizedNotification = localizeNotificationItem(notification, locale)
setUnreadCount((current) => current + 1)
toast(localizedNotification.title || t("notification.new"), {
description: localizedNotification.content,
action: {
label: t("notification.view"),
onClick: () => {
void markReadAndNavigate(localizedNotification).catch((error) => {
toast.error(error instanceof Error ? error.message : t("notification.openFailed"))
})
},
},
})
} catch {
// ignore invalid realtime payload
}
},
onConnectError: (error) => {
toast.error(error instanceof Error ? error.message : t("notification.connectFailed"))
},
})
realtime.connect()
return () => {
realtime.disconnect()
}
}, [locale, markReadAndNavigate, refreshUnreadCount, t])
const value = useMemo<NotificationContextValue>(
() => ({
unreadCount,
realtimeStatus,
refreshUnreadCount,
markReadAndNavigate,
}),
[markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount]
)
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications() {
const context = useContext(NotificationContext)
if (!context) {
throw new Error("useNotifications must be used within NotificationProvider")
}
return context
}

View File

@@ -0,0 +1,283 @@
"use client"
import {
AlertTriangleIcon,
CheckCircle2Icon,
Loader2Icon,
RefreshCwIcon,
ShieldAlertIcon,
SparklesIcon,
WrenchIcon,
} from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider"
import {
applySetupQuickFix,
fetchSetupReadiness,
type SetupQuickFixAction,
type SetupReadinessResponse,
} from "@/lib/api/setup-readiness"
import { cn } from "@/lib/utils"
export const SETUP_READINESS_UPDATED_EVENT = "setup-readiness-updated"
export function notifySetupReadinessUpdated() {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(SETUP_READINESS_UPDATED_EVENT))
}
}
export function OperatorReadinessIndicator({
compact = false,
}: {
compact?: boolean
}) {
const t = useI18n()
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [loading, setLoading] = useState(true)
const [fixing, setFixing] = useState(false)
const [open, setOpen] = useState(false)
const loadReadiness = useCallback(async () => {
try {
const data = await fetchSetupReadiness()
setReadiness(data)
} catch {
// Silent background check in header
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadReadiness()
const handleUpdated = () => {
void loadReadiness()
}
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
const interval = window.setInterval(() => {
void loadReadiness()
}, 45000)
return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
window.clearInterval(interval)
}
}, [loadReadiness])
async function handleQuickFix(action: SetupQuickFixAction) {
setFixing(true)
try {
const res = await applySetupQuickFix({ action })
setReadiness(res.readiness)
notifySetupReadinessUpdated()
toast.success(t("setupWizard.quickFixSuccess"))
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("common.operationFailed"),
)
} finally {
setFixing(false)
}
}
if (loading && !readiness) {
return null
}
if (!readiness) {
return null
}
const operatorOk = readiness.current_operator.can_receive_now
const chainOk = readiness.can_receive_requests_now
const isAllReady = operatorOk && chainOk
const blockerCount =
readiness.current_operator.blocker_codes.length +
(chainOk ? 0 : Math.max(1, readiness.total_steps - readiness.ready_steps))
const canQuickSetupAll = readiness.steps.some((s) =>
s.quick_fix_actions.includes("quick_setup_all"),
)
const canEnableMyProfile = readiness.steps.some((s) =>
s.quick_fix_actions.includes("enable_my_operator_profile"),
)
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
if (nextOpen) {
void loadReadiness()
}
}}
>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
className={cn(
"h-8 gap-1.5 rounded-md px-2.5 text-xs font-medium",
isAllReady
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300"
: "border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/15 dark:text-amber-300",
)}
/>
}
>
{isAllReady ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
) : (
<AlertTriangleIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
)}
<span className={cn(compact && "hidden md:inline")}>
{isAllReady
? t("setupWizard.headerIndicator.readyLabel")
: t("setupWizard.headerIndicator.blockedLabel", {
count: blockerCount,
})}
</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 rounded-md p-3">
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold text-foreground">
{t("setupWizard.currentOperatorTitle")}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("setupWizard.stepsProgress", {
ready: readiness.ready_steps,
total: readiness.total_steps,
})}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-md"
onClick={() => void loadReadiness()}
aria-label={t("setupWizard.refresh")}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
<div className="rounded-md border border-border/70 bg-muted/40 p-2.5 text-xs">
<div className="flex flex-wrap items-center justify-between gap-1.5">
<span className="font-medium text-foreground">
{t("setupWizard.currentOperatorTeam", {
team:
readiness.current_operator.team_name ||
t("setupWizard.currentOperatorNoTeam"),
})}
</span>
<Badge variant="outline" className="rounded-md text-[11px]">
{t("setupWizard.currentOperatorLoad", {
active: readiness.current_operator.active_conversation_count,
max: readiness.current_operator.max_concurrent_count,
})}
</Badge>
</div>
{isAllReady ? (
<p className="mt-2 flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300">
<CheckCircle2Icon className="size-3.5 shrink-0" />
<span>{t("setupWizard.currentOperatorReady")}</span>
</p>
) : (
<div className="mt-2 space-y-1.5">
<p className="font-medium text-amber-700 dark:text-amber-300">
{t("setupWizard.currentOperatorBlocked")}
</p>
<ul className="space-y-1 text-muted-foreground">
{readiness.current_operator.blocker_codes.map((code) => (
<li key={code} className="flex items-start gap-1.5">
<ShieldAlertIcon className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<span>{t(`setupWizard.operatorBlockers.${code}`)}</span>
</li>
))}
{!chainOk
? readiness.steps
.filter((step) => step.status !== "ok")
.map((step) => (
<li key={step.key} className="flex items-start gap-1.5">
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<span>
{t(`setupWizard.steps.${step.key}.title`)}
</span>
</li>
))
: null}
</ul>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{!operatorOk && canEnableMyProfile ? (
<Button
size="sm"
className="w-full gap-1.5 rounded-md text-xs"
disabled={fixing}
onClick={() =>
void handleQuickFix(
chainOk || !canQuickSetupAll
? "enable_my_operator_profile"
: "quick_setup_all",
)
}
>
{fixing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
<span>
{chainOk || !canQuickSetupAll
? t("setupWizard.enableMyReceptionBtn")
: t("setupWizard.quickSetupAll")}
</span>
</Button>
) : !chainOk && canQuickSetupAll ? (
<Button
size="sm"
className="w-full gap-1.5 rounded-md text-xs"
disabled={fixing}
onClick={() => void handleQuickFix("quick_setup_all")}
>
{fixing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
<span>{t("setupWizard.quickSetupAll")}</span>
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="w-full gap-1.5 rounded-md text-xs"
onClick={() => setOpen(false)}
render={<Link href="/dashboard/setup" />}
>
<WrenchIcon className="size-3.5" />
<span>{t("setupWizard.openSetupWizard")}</span>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,192 @@
"use client"
import { useState, type ReactNode } from "react"
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
export type ComboboxOption = {
value: string
label: string
disabled?: boolean
group?: string
subtitle?: string
description?: string
}
type CommonOptionComboboxProps = {
options: ComboboxOption[]
placeholder: string
searchPlaceholder?: string
emptyText?: string
disabled?: boolean
triggerClassName?: string
preserveExternalSelection?: boolean
renderOptionAction?: (option: ComboboxOption) => ReactNode
}
type OptionComboboxProps = CommonOptionComboboxProps &
(
| {
multiple?: false
value: string
onChange: (value: string) => void
values?: never
onValuesChange?: never
}
| {
multiple: true
values: string[]
onValuesChange: (values: string[]) => void
value?: never
onChange?: never
}
)
export function OptionCombobox(props: OptionComboboxProps) {
const {
options,
placeholder,
searchPlaceholder,
emptyText,
disabled = false,
triggerClassName,
preserveExternalSelection = false,
renderOptionAction,
} = props
const t = useI18n()
const [open, setOpen] = useState(false)
const selectedValues = props.multiple ? props.values : [props.value]
const selectedOptions = options.filter((option) =>
selectedValues.includes(option.value)
)
const selectedLabel =
selectedOptions.length === 0
? placeholder
: selectedOptions.length === 1
? selectedOptions[0].label
: t("common.selectedCount", { count: selectedOptions.length })
const optionGroups = Array.from(
options.reduce((groups, option) => {
const group = option.group ?? ""
groups.set(group, [...(groups.get(group) ?? []), option])
return groups
}, new Map<string, ComboboxOption[]>()),
)
function selectOption(optionValue: string) {
const option = options.find((item) => item.value === optionValue)
if (option?.disabled) {
return
}
if (props.multiple) {
props.onValuesChange(
props.values.includes(optionValue)
? props.values.filter((value) => value !== optionValue)
: [...props.values, optionValue]
)
return
}
props.onChange(optionValue)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
role="combobox"
className={cn("m-0 w-full justify-between font-normal", triggerClassName)}
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"
data-workflow-preserve-selection={preserveExternalSelection ? true : undefined}
>
<Command>
<CommandInput placeholder={searchPlaceholder ?? t("common.searchKeyword")} />
<CommandList>
<CommandEmpty>{emptyText ?? t("common.emptyOptions")}</CommandEmpty>
{optionGroups.map(([group, groupOptions]) => (
<CommandGroup key={group || "__default"} heading={group || undefined}>
{groupOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.group ?? ""} ${option.label} ${option.value} ${option.subtitle ?? ""} ${option.description ?? ""}`}
disabled={option.disabled}
onSelect={() => selectOption(option.value)}
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<div className="flex min-w-0 items-start">
{props.multiple ? (
<Checkbox
checked={selectedValues.includes(option.value)}
className="pointer-events-none mr-2 mt-0.5"
tabIndex={-1}
/>
) : (
<CheckIcon
className={cn(
"mr-2 mt-0.5 size-4 shrink-0",
option.value === props.value ? "opacity-100" : "opacity-0"
)}
/>
)}
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.subtitle ? (
<span className="mt-0.5 block truncate font-mono text-[11px] leading-4 text-slate-500">
{option.subtitle}
</span>
) : null}
{option.description ? (
<span className="mt-0.5 line-clamp-2 text-xs leading-4 text-muted-foreground">
{option.description}
</span>
) : null}
</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>
)
}

View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
const paletteSource = await readFile(
new URL("./palette-toggle.tsx", import.meta.url),
"utf8",
)
const layoutSource = await readFile(new URL("../app/(dashboard)/layout.tsx", import.meta.url), "utf8")
const zhMessages = JSON.parse(
await readFile(new URL("../messages/zh-CN.json", import.meta.url), "utf8"),
)
const enMessages = JSON.parse(
await readFile(new URL("../messages/en-US.json", import.meta.url), "utf8"),
)
test("plain palette is the default dashboard palette", () => {
assert.match(paletteSource, /type PaletteMode = "plain" \| "blue" \| "green" \| "gray"/)
assert.match(paletteSource, /const DEFAULT_PALETTE: PaletteMode = "plain"/)
assert.match(layoutSource, /palette === "plain"/)
assert.match(layoutSource, /: "plain"/)
})
test("plain palette is available in the palette menu and messages", () => {
assert.match(paletteSource, /value: "plain"[\s\S]*labelKey: "palette\.plain"/)
assert.equal(zhMessages.palette.plain, "默认")
assert.equal(enMessages.palette.plain, "Default")
})

View File

@@ -0,0 +1,113 @@
"use client"
import { useEffect, useState } from "react"
import { CircleIcon, DropletsIcon, PaletteIcon } from "lucide-react"
import { useI18n } from "@/i18n/provider"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
type PaletteMode = "plain" | "blue" | "green" | "gray"
const PALETTE_STORAGE_KEY = "dashboard_palette"
const DEFAULT_PALETTE: PaletteMode = "plain"
const paletteOptions: Array<{
value: PaletteMode
labelKey: string
swatch: string
}> = [
{
value: "plain",
labelKey: "palette.plain",
swatch: "bg-[#2475fc]",
},
{
value: "green",
labelKey: "palette.green",
swatch: "bg-teal-700",
},
{
value: "gray",
labelKey: "palette.gray",
swatch: "bg-slate-500",
},
{
value: "blue",
labelKey: "palette.blue",
swatch: "bg-blue-600",
},
]
function readPalette(): PaletteMode {
if (typeof window === "undefined") {
return DEFAULT_PALETTE
}
const stored = window.localStorage.getItem(PALETTE_STORAGE_KEY)
return stored === "plain" ||
stored === "blue" ||
stored === "green" ||
stored === "gray"
? stored
: DEFAULT_PALETTE
}
function applyPalette(value: PaletteMode) {
document.documentElement.dataset.palette = value
window.localStorage.setItem(PALETTE_STORAGE_KEY, value)
}
export function PaletteToggle() {
const t = useI18n()
const [palette, setPalette] = useState<PaletteMode>(DEFAULT_PALETTE)
useEffect(() => {
const storedPalette = readPalette()
setPalette(storedPalette)
applyPalette(storedPalette)
}, [])
function handleChange(value: string) {
const nextPalette: PaletteMode =
value === "blue" || value === "green" || value === "gray"
? value
: "plain"
setPalette(nextPalette)
applyPalette(nextPalette)
}
const ActiveIcon =
palette === "plain"
? CircleIcon
: palette === "green"
? DropletsIcon
: PaletteIcon
return (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="sm" />}
aria-label={t("palette.toggle")}
>
<ActiveIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 min-w-52">
<DropdownMenuRadioGroup value={palette} onValueChange={handleChange}>
{paletteOptions.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span className={`size-2.5 rounded-full ${option.swatch}`} />
<span className="flex-1">{t(option.labelKey)}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,178 @@
"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";
import { useI18n } from "@/i18n/provider";
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",
xxl: "max-w-5xl sm:max-w-5xl",
} 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 = true,
allowFullscreen = false,
defaultFullscreen = false,
bodyScrollable = true,
}: ProjectDialogProps) {
const t = useI18n();
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 && dialogSizeClassName[size],
contentClassName,
fullscreen &&
"top-2 left-2 h-[calc(100vh-16px)] max-h-[calc(100vh-16px)] w-[calc(100vw-16px)] max-w-[calc(100vw-16px)] translate-x-0 translate-y-0 rounded-xl sm:max-w-[calc(100vw-16px)]",
)}
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 ? t("common.exitFullscreen") : t("common.fullscreen")}
</span>
</Button>
) : null}
{showCloseButton ? (
<DialogClose
render={<Button type="button" variant="ghost" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">{t("common.close")}</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 w-full">{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 };

View File

@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
export type RealtimeConnectionStatusValue =
| "connecting"
| "connected"
| "disconnected";
type RealtimeConnectionStatusProps = {
status: RealtimeConnectionStatusValue;
compact?: boolean;
};
const statusTextKey: Record<RealtimeConnectionStatusValue, string> = {
connecting: "realtime.connecting",
connected: "realtime.connected",
disconnected: "realtime.disconnected",
};
const compactStatusTextKey: Record<RealtimeConnectionStatusValue, string> = {
connecting: "realtime.compactConnecting",
connected: "realtime.compactConnected",
disconnected: "realtime.compactDisconnected",
};
export function RealtimeConnectionStatus({
status,
compact = false,
}: RealtimeConnectionStatusProps) {
const t = useI18n();
const toneClass =
status === "connected"
? "border-emerald-200/80 bg-emerald-50 text-emerald-700"
: status === "connecting"
? "border-amber-200/80 bg-amber-50 text-amber-700"
: "border-slate-200/80 bg-slate-100 text-slate-600";
return (
<div
className={cn(
compact
? "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-[0.01em]"
: "inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.02em]",
toneClass,
)}
>
<span
className={cn(
"inline-block size-2 rounded-full",
status === "connected"
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
: status === "connecting"
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
: "bg-slate-400 shadow-[0_0_0_4px_rgba(148,163,184,0.14)]",
)}
/>
<span>
{t(compact ? compactStatusTextKey[status] : statusTextKey[status])}
</span>
</div>
);
}

View File

@@ -0,0 +1,183 @@
"use client"
import { useMemo } from "react"
import { articleHeadingId } from "@/lib/support-article"
import { cn } from "@/lib/utils"
type SafeRichHTMLProps = {
html?: string | null
fallback?: string
className?: string
id?: string
articleHeadingIds?: boolean
unstyled?: boolean
}
const allowedTags = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"u",
"ul",
])
const allowedAttrs = new Set(["alt", "class", "height", "href", "rel", "src", "target", "title", "width"])
function escapeHTML(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function looksLikeHTML(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}
function plainTextToHTML(value: string) {
return escapeHTML(value)
.split(/\n{2,}/)
.map((part) => `<p>${part.replaceAll("\n", "<br>")}</p>`)
.join("")
}
function isSafeURL(value: string) {
if (!value) {
return false
}
if (value.startsWith("/")) {
return true
}
try {
const url = new URL(value, window.location.origin)
return ["http:", "https:"].includes(url.protocol)
} catch {
return false
}
}
function appendClassName(element: Element, className: string) {
const current = element.getAttribute("class")?.trim()
if (!current) {
element.setAttribute("class", className)
return
}
if (current.split(/\s+/).includes(className)) {
return
}
element.setAttribute("class", `${current} ${className}`)
}
function applyArticleHeadingIds(container: ParentNode) {
container.querySelectorAll<HTMLElement>("h2, h3").forEach((heading, index) => {
heading.id = articleHeadingId(heading.textContent || "", index)
appendClassName(heading, "scroll-mt-20")
})
}
function sanitizeRichHTML(value: string, options: { articleHeadingIds?: boolean } = {}) {
const source = looksLikeHTML(value) ? value : plainTextToHTML(value)
if (typeof window === "undefined") {
return source
}
const doc = new DOMParser().parseFromString(source, "text/html")
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT)
const elements: Element[] = []
while (walker.nextNode()) {
elements.push(walker.currentNode as Element)
}
for (const element of elements) {
const tag = element.tagName.toLowerCase()
if (!allowedTags.has(tag)) {
element.replaceWith(...Array.from(element.childNodes))
continue
}
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
const attrValue = attr.value.trim()
if (name.startsWith("on") || !allowedAttrs.has(name)) {
element.removeAttribute(attr.name)
continue
}
if ((name === "href" || name === "src") && !isSafeURL(attrValue)) {
element.removeAttribute(attr.name)
}
}
if (tag === "a" && element.getAttribute("href")) {
element.setAttribute("target", "_blank")
element.setAttribute("rel", "noreferrer noopener")
}
}
if (options.articleHeadingIds) {
applyArticleHeadingIds(doc.body)
}
return doc.body.innerHTML
}
export function isRichTextEmpty(value?: string | null) {
const normalized = String(value ?? "").trim()
if (!normalized) {
return true
}
if (typeof window === "undefined") {
return normalized.replace(/<[^>]*>/g, "").trim() === ""
}
const doc = new DOMParser().parseFromString(normalized, "text/html")
return (doc.body.textContent ?? "").trim() === "" && doc.body.querySelector("img") === null
}
export function SafeRichHTML({ html, fallback = "-", className, id, articleHeadingIds = false, unstyled = false }: SafeRichHTMLProps) {
const normalized = String(html ?? "").trim()
const safeHTML = useMemo(() => {
if (!normalized) {
return plainTextToHTML(fallback)
}
return sanitizeRichHTML(normalized, { articleHeadingIds })
}, [articleHeadingIds, fallback, normalized])
return (
<div
id={id}
className={cn(
!unstyled && "break-words text-sm leading-6 [&_a]:text-primary [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:font-semibold [&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-md [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:m-0 [&_p+*]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_strong]:font-semibold [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:bg-muted [&_th]:px-2 [&_th]:py-1 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
className,
)}
dangerouslySetInnerHTML={{ __html: safeHTML }}
/>
)
}

View File

@@ -0,0 +1,111 @@
"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"
import { useI18n } from "@/i18n/provider"
export function SectionCards() {
const t = useI18n()
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>{t("scaffold.adminUsers")}</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">
{t("scaffold.newAdminsThisWeek")}{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
{t("scaffold.accountSystemReady")}
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>{t("scaffold.permissions")}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
26
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon />
{t("scaffold.initialized")}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{t("scaffold.permissionsPending")}{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
{t("scaffold.permissionModelReady")}
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>{t("scaffold.knowledgeTasks")}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
8
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon
/>
{t("scaffold.running")}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{t("scaffold.ragReserved")}{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">{t("scaffold.docIndexSupport")}</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>{t("scaffold.channelAccess")}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
3
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingDownIcon />
{t("scaffold.pendingConfig")}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{t("scaffold.channelEntrances")}{" "}
<TrendingDownIcon className="size-4" />
</div>
<div className="text-muted-foreground">{t("scaffold.waitingRealConfig")}</div>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -0,0 +1,110 @@
"use client"
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
import { fetchProfile, logout } from "@/lib/api/auth"
import {
AUTH_SESSION_CHANGED_EVENT,
AUTH_SESSION_EXPIRED_EVENT,
clearSession,
readSession,
writeSession,
type AuthSession,
} from "@/lib/auth"
type SessionContextValue = {
session: AuthSession | null
ready: boolean
signingOut: boolean
refreshSession: () => Promise<void>
signOut: () => Promise<void>
}
const SessionContext = createContext<SessionContextValue | null>(null)
export function SessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(null)
const [ready, setReady] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const refreshSession = 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,
accessToken: profile.accessToken || stored.accessToken,
expiresAt: profile.expiresAt || stored.expiresAt,
}
writeSession(nextSession)
setSession(nextSession)
} catch (error) {
const errorCode = (error as Error & { errorCode?: number }).errorCode
if (errorCode === 3000 || errorCode === 3002) {
clearSession()
setSession(null)
} else {
setSession(stored)
}
} finally {
setReady(true)
}
}, [])
const signOut = useCallback(async () => {
if (signingOut) return
setSigningOut(true)
try {
await logout()
} catch {
clearSession()
} finally {
setSession(null)
setSigningOut(false)
}
}, [signingOut])
useEffect(() => {
void refreshSession()
}, [refreshSession])
useEffect(() => {
const syncSession = () => {
setSession(readSession())
setReady(true)
}
const syncOtherTab = (event: StorageEvent) => {
if (event.key === "agent-desk-session") syncSession()
}
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
window.addEventListener("storage", syncOtherTab)
return () => {
window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
window.removeEventListener("storage", syncOtherTab)
}
}, [])
return (
<SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>
{children}
</SessionContext.Provider>
)
}
export function useSession() {
const context = useContext(SessionContext)
if (!context) throw new Error("useSession must be used within SessionProvider")
return context
}

View File

@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { describe, it } from "node:test";
const source = await readFile(new URL("./site-header.tsx", import.meta.url), "utf8");
describe("site header preferences", () => {
it("renders palette and theme controls without a locale switcher", () => {
assert.doesNotMatch(source, /LocaleSwitcher/);
assert.match(source, /import \{ PaletteToggle \} from "@\/components\/palette-toggle"/);
assert.match(source, /import \{ ThemeToggle \} from "@\/components\/theme-toggle"/);
assert.match(source, /<PaletteToggle \/>[\s\S]*<ThemeToggle \/>/);
});
});

View File

@@ -0,0 +1,88 @@
"use client"
import { useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import { OperatorReadinessIndicator } from "@/components/operator-readiness-indicator"
import { PaletteToggle } from "@/components/palette-toggle"
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
import { ThemeToggle } from "@/components/theme-toggle"
import { useI18n } from "@/i18n/provider"
import { getPageTitleKey } from "@/lib/navigation"
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
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 t = useI18n()
const pathname = usePathname()
const { open, setOpen, isMobile } = useSidebar()
const pageTitle = t(getPageTitleKey(pathname))
const realtimeStatus = useAgentConversationsStore((state) => state.realtimeStatus)
const hasRestoredRef = useRef(false)
const showConversationRealtime =
pathname === "/conversations" || pathname.startsWith("/conversations/")
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 border-border/70 bg-background/82 backdrop-blur supports-[backdrop-filter]:bg-background/72 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>
<div className="flex items-center gap-2">
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
{showConversationRealtime ? (
<RealtimeConnectionStatus status={realtimeStatus} compact />
) : null}
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<OperatorReadinessIndicator compact />
<PaletteToggle />
<ThemeToggle />
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { RotateCwIcon } from "lucide-react"
import { Component, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { useI18n } from "@/i18n/provider"
function ChatCrashFallback({ onReset }: { onReset: () => void }) {
const t = useI18n()
return (
<div
role="alert"
className="flex h-full min-h-0 w-full flex-col items-center justify-center gap-3 bg-card px-6 text-center"
>
<div className="text-sm font-semibold text-foreground">{t("supportChat.crashTitle")}</div>
<p className="max-w-[260px] text-xs leading-5 text-muted-foreground">
{t("supportChat.crashDescription")}
</p>
<Button type="button" size="sm" variant="outline" onClick={onReset}>
<RotateCwIcon className="size-4" />
{t("supportChat.crashReload")}
</Button>
</div>
)
}
type State = { failed: boolean }
/**
* Keeps a rendering failure inside the chat from blanking the whole embedded
* frame; the visitor gets a recoverable state instead.
*/
export class SupportChatErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { failed: false }
static getDerivedStateFromError(): State {
return { failed: true }
}
componentDidCatch(error: unknown) {
console.error("Support chat crashed", error)
}
render() {
if (this.state.failed) {
return <ChatCrashFallback onReset={() => this.setState({ failed: false })} />
}
return this.props.children
}
}

View File

@@ -0,0 +1,439 @@
"use client"
import {
CheckCircle2Icon,
MessageSquarePlusIcon,
RotateCwIcon,
XIcon,
} from "lucide-react"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ComponentProps,
type CSSProperties,
} from "react"
import { useShallow } from "zustand/react/shallow"
import { SupportChatErrorBoundary } from "@/components/support-chat/chat-error-boundary"
import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
import { SupportChatConversationSwitcher } from "@/components/support-chat/conversation-switcher"
import { SupportChatNewRequestDialog } from "@/components/support-chat/new-request-dialog"
import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
import { CSATCard } from "@/components/support-chat/csat-card"
import { CustomerMessageEditor } from "@/components/support-chat/customer-message-editor"
import { getCustomerSessionToken } from "@/lib/api/im"
import {
SupportChatMessageList,
type SupportChatMessageListHandle,
} from "@/components/support-chat/message-list"
import {
bindSupportHostBridge,
requestSupportHostMinimize,
} from "@/lib/support-host-bridge"
import { useSupportChatStore } from "@/lib/stores/support-chat"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
const windowActionButtonClass =
"size-8 rounded-full border-0 bg-transparent text-muted-foreground shadow-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-primary/20 dark:hover:bg-white/10"
function WindowActionButton({
className,
...props
}: ComponentProps<typeof Button>) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(windowActionButtonClass, className)}
{...props}
/>
)
}
function useSupportChatSystemTheme() {
useLayoutEffect(() => {
if (typeof window === "undefined") {
return
}
const root = document.documentElement
const query = window.matchMedia("(prefers-color-scheme: dark)")
const previousDarkClass = root.classList.contains("dark")
const previousColorScheme = root.style.colorScheme
const syncTheme = () => {
const isDark = query.matches
root.classList.toggle("dark", isDark)
root.style.colorScheme = isDark ? "dark" : "light"
}
syncTheme()
query.addEventListener("change", syncTheme)
return () => {
query.removeEventListener("change", syncTheme)
root.classList.toggle("dark", previousDarkClass)
root.style.colorScheme = previousColorScheme
}
}, [])
}
export function isEmbeddedInHost() {
if (typeof window === "undefined") {
return false
}
try {
return window.parent !== window
} catch {
return false
}
}
/**
* The chat panel without any outer sizing. Both the embedded iframe frame and
* the standalone support page render this, so the surrounding chrome decides
* how much room it gets.
*/
function SupportChatPanelContent() {
const t = useI18n()
useSupportChatSystemTheme()
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
const [isEmbedded, setIsEmbedded] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
const [isClosingConversation, setIsClosingConversation] = useState(false)
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
const {
title,
subtitle,
themeColor,
conversation,
messages,
messagesHasMore,
messagesLoadingMore,
loadOlderMessages,
status,
error,
isOpen,
isVisible,
setIsOpen,
setIsVisible,
bootstrap,
handleSendMessage,
uploadMessageImage,
sendAttachment,
retry,
disconnectSocket,
markConversationRead,
closeConversation,
setConversationSatisfaction,
} = useSupportChatStore(
useShallow((state) => ({
title: state.title,
subtitle: state.subtitle,
themeColor: state.themeColor,
conversation: state.conversation,
messages: state.messages,
messagesHasMore: state.messagesHasMore,
messagesLoadingMore: state.messagesLoadingMore,
loadOlderMessages: state.loadOlderMessages,
status: state.status,
error: state.error,
isOpen: state.isOpen,
isVisible: state.isVisible,
setIsOpen: state.setIsOpen,
setIsVisible: state.setIsVisible,
bootstrap: state.bootstrap,
handleSendMessage: state.handleSendMessage,
uploadMessageImage: state.uploadMessageImage,
sendAttachment: state.sendAttachment,
retry: state.retry,
disconnectSocket: state.disconnectSocket,
markConversationRead: state.markConversationRead,
closeConversation: state.closeConversation,
setConversationSatisfaction: state.setConversationSatisfaction,
}))
)
const safeMessages = Array.isArray(messages) ? messages : []
useEffect(() => {
setIsEmbedded(isEmbeddedInHost())
}, [])
const maybeMarkConversationRead = useCallback(() => {
if (!isVisible || !conversation || typeof document === "undefined") {
return
}
if (document.visibilityState !== "visible") {
return
}
void markConversationRead().catch((readError) => {
console.error("Failed to mark support chat conversation read", readError)
})
}, [conversation?.id, isVisible, markConversationRead])
useEffect(() => {
return bindSupportHostBridge({
onInit: () => {
if (useSupportChatStore.getState().isOpen) {
bootstrap()
}
},
onOpen: () => {
setIsOpen(true)
setIsVisible(true)
},
onMinimize: () => {
setIsVisible(false)
},
})
}, [bootstrap, setIsOpen, setIsVisible])
useEffect(() => {
bootstrap()
return () => {
if (!isOpen) {
disconnectSocket()
}
}
}, [isOpen, bootstrap, disconnectSocket])
useEffect(() => {
maybeMarkConversationRead()
}, [maybeMarkConversationRead, safeMessages.length])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
maybeMarkConversationRead()
}
}
const handleFocus = () => {
maybeMarkConversationRead()
}
document.addEventListener("visibilitychange", handleVisibilityChange)
window.addEventListener("focus", handleFocus)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("focus", handleFocus)
}
}, [maybeMarkConversationRead])
async function handleSend(content: string) {
await handleSendMessage(content)
messageListRef.current?.scrollToBottom()
}
function handleCloseWindow() {
setIsVisible(false)
if (isEmbedded) {
requestSupportHostMinimize()
} else {
window.location.replace(getStandaloneClosedUrl())
}
}
async function confirmCloseConversation() {
if (isClosingConversation) {
return
}
setIsClosingConversation(true)
try {
if (conversation?.id) {
await closeConversation()
}
setIsCloseDialogOpen(false)
} catch (closeError) {
window.alert(closeError instanceof Error ? closeError.message : t("supportChat.closeConversationFailed"))
} finally {
setIsClosingConversation(false)
}
}
useEffect(() => {
if (!isCloseDialogOpen) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !isClosingConversation) {
setIsCloseDialogOpen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isCloseDialogOpen, isClosingConversation])
return (
<>
<section
className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-card text-card-foreground"
style={{ "--primary": themeColor } as CSSProperties}
>
<header className="shrink-0 border-b border-border/70 bg-card/95 px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,0.02)] backdrop-blur dark:border-border/60 dark:bg-card/90 sm:px-4">
<div className="flex min-w-0 items-center justify-between gap-2.5">
<SupportChatConversationSwitcher
title={title}
subtitle={subtitle}
status={status}
onNewRequest={() => setIsNewRequestDialogOpen(true)}
onCloseActiveRequest={() => setIsCloseDialogOpen(true)}
/>
<div className="flex shrink-0 items-center gap-0.5">
<WindowActionButton
onClick={() => setIsNewRequestDialogOpen(true)}
aria-label={t("supportChat.newRequest")}
title={t("supportChat.newRequest")}
>
<MessageSquarePlusIcon className="size-4" />
</WindowActionButton>
{status !== "connected" ? (
<WindowActionButton
onClick={retry}
aria-label={t("supportChat.retry")}
title={t("supportChat.retry")}
>
<RotateCwIcon className="size-4" />
</WindowActionButton>
) : null}
{conversation && conversation.status !== 4 ? (
<WindowActionButton
onClick={() => setIsCloseDialogOpen(true)}
aria-label={t("supportChat.confirmClose")}
title={t("supportChat.confirmClose")}
className="hover:bg-foreground/[0.06] hover:text-foreground"
>
<CheckCircle2Icon className="size-4" />
</WindowActionButton>
) : null}
<WindowActionButton
onClick={handleCloseWindow}
aria-label={t("supportChat.closeWindow")}
title={t("supportChat.closeWindow")}
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
>
<XIcon className="size-4" />
</WindowActionButton>
</div>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
<SupportChatMessageList
ref={messageListRef}
messages={safeMessages}
onNearBottomVisible={maybeMarkConversationRead}
hasMoreOlder={messagesHasMore}
loadingOlder={messagesLoadingMore}
onLoadOlder={loadOlderMessages}
/>
<div className="shrink-0 border-t border-border/80 bg-card/95 pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_24px_rgba(15,23,42,0.05)] dark:bg-card/90 dark:shadow-none">
{conversation && (conversation.status === 4 || conversation.satisfaction) ? (
<div className="border-b border-border/60 p-3">
<CSATCard
key={conversation.id}
conversationId={conversation.id}
customerToken={getCustomerSessionToken()}
initialSatisfaction={conversation.satisfaction}
onSubmitted={setConversationSatisfaction}
/>
</div>
) : null}
<CustomerMessageEditor
disabled={!conversation || conversation.status === 4}
onSend={handleSend}
onUploadImage={uploadMessageImage}
onSendAttachment={sendAttachment}
/>
</div>
</div>
{error ? (
<div className="border-t border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
) : null}
</section>
<Dialog
open={isCloseDialogOpen}
onOpenChange={(open) => {
if (!isClosingConversation) {
setIsCloseDialogOpen(open)
}
}}
>
<DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}>
<DialogHeader>
<DialogTitle>{t("supportChat.closeDialogTitle")}</DialogTitle>
<DialogDescription className="text-xs leading-5">
{t("supportChat.closeDialogDescription")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isClosingConversation}
onClick={() => setIsCloseDialogOpen(false)}
>
{t("supportChat.continueConversation")}
</Button>
<Button
type="button"
variant="destructive"
disabled={isClosingConversation}
onClick={() => void confirmCloseConversation()}
>
{isClosingConversation ? t("supportChat.closing") : t("supportChat.confirmClose")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SupportChatNewRequestDialog
open={isNewRequestDialogOpen}
onOpenChange={setIsNewRequestDialogOpen}
onCreated={() => {
messageListRef.current?.scrollToBottom()
}}
/>
</>
)
}
export function SupportChatPanel() {
return (
<SupportChatErrorBoundary>
<SupportChatPanelContent />
</SupportChatErrorBoundary>
)
}
/**
* The embedded widget frame. The host sizes the iframe, so the panel always
* fills the whole document box.
*/
export function SupportChatShell() {
return (
<main className="relative flex h-[100dvh] min-h-[100dvh] overflow-hidden bg-muted text-foreground supports-not-[height:100dvh]:h-screen supports-not-[height:100dvh]:min-h-screen">
<SupportChatPanel />
</main>
)
}

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadCloseNavigation() {
const source = await readFile(new URL("./close-navigation.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "close-navigation.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("standalone close navigates to the non-bootstrapping closed page", async () => {
const { getStandaloneClosedUrl } = await loadCloseNavigation()
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed")
})

View File

@@ -0,0 +1,5 @@
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() {
return SUPPORT_CHAT_CLOSED_PATH
}

View File

@@ -0,0 +1,38 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type SupportChatConnectionStatusProps = {
status: "connecting" | "connected" | "disconnected"
}
export function SupportChatConnectionStatus({ status }: SupportChatConnectionStatusProps) {
const t = useI18n()
const toneClass =
status === "connected"
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300"
: status === "connecting"
? "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300"
: "border-border bg-muted text-muted-foreground"
return (
<Badge
variant="outline"
className={cn("h-6 gap-2 px-2.5 text-[11px] font-medium shadow-sm", toneClass)}
>
<span
className={cn(
"inline-block size-2 rounded-full",
status === "connected"
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
: status === "connecting"
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
)}
/>
<span>{t(`supportChat.${status}`)}</span>
</Badge>
)
}

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { describe, it } from "node:test"
const source = await readFile(
new URL("./conversation-switcher.tsx", import.meta.url),
"utf8"
)
describe("support chat conversation switcher", () => {
it("imports DropdownMenuGroup from dropdown-menu primitives", () => {
assert.match(source, /DropdownMenuGroup/)
})
it("wraps DropdownMenuLabel inside DropdownMenuGroup to satisfy Base UI context (Error #31 prevention)", () => {
assert.match(
source,
/<DropdownMenuGroup>[\s\S]*<DropdownMenuLabel[\s\S]*<\/DropdownMenuLabel>[\s\S]*<\/DropdownMenuGroup>/
)
})
})

View File

@@ -0,0 +1,262 @@
"use client"
import {
CheckCircle2Icon,
CheckIcon,
ChevronDownIcon,
HeadphonesIcon,
MessageSquarePlusIcon,
} from "lucide-react"
import { useMemo } from "react"
import { useShallow } from "zustand/react/shallow"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { ImConversation } from "@/lib/api/im"
import { getConversationStatusMessageKey } from "@/lib/support-conversation-i18n"
import {
isOpenImConversation,
resolveConversationTitle,
totalUnreadConversations,
} from "@/lib/support-conversation-list"
import { useSupportChatStore } from "@/lib/stores/support-chat"
import { cn, formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type SupportChatConversationSwitcherProps = {
title: string
subtitle: string
status: string
onNewRequest: () => void
onCloseActiveRequest?: () => void
}
function getStatusBadgeClass(status: number) {
if (status === 4) {
return "bg-muted text-muted-foreground"
}
if (status === 2) {
return "bg-amber-500/12 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300"
}
return "bg-emerald-500/12 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300"
}
function getStatusDotClass(status: string) {
if (status === "connected") {
return "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]"
}
if (status === "connecting") {
return "bg-amber-500 shadow-[0_0_0_3px_rgba(245,158,11,0.16)]"
}
return "bg-muted-foreground shadow-[0_0_0_3px_rgba(148,163,184,0.14)]"
}
export function SupportChatConversationSwitcher({
title,
subtitle,
status,
onNewRequest,
onCloseActiveRequest,
}: SupportChatConversationSwitcherProps) {
const t = useI18n()
const {
conversations,
activeConversationId,
creatingConversation,
selectConversation,
} = useSupportChatStore(
useShallow((state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
creatingConversation: state.creatingConversation,
selectConversation: state.selectConversation,
}))
)
const openCount = useMemo(
() => conversations.filter((item) => isOpenImConversation(item)).length,
[conversations]
)
const totalUnread = useMemo(
() => totalUnreadConversations(conversations),
[conversations]
)
const active = useMemo(
() => conversations.find((item) => item.id === activeConversationId) ?? null,
[conversations, activeConversationId]
)
const activeSubject = active?.subject?.trim()
const hasMultiple = conversations.length > 1
const displayTitle = activeSubject
? activeSubject
: hasMultiple && active
? resolveConversationTitle(active, t("supportChat.requestFallbackTitle"))
: title
const displaySubtitle = activeSubject || hasMultiple
? title
: subtitle
const avatarElement = (
<div className="relative flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/15 dark:bg-primary/15 dark:ring-primary/25">
<HeadphonesIcon className="size-4" />
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-card",
getStatusDotClass(status)
)}
aria-hidden="true"
/>
</div>
)
if (!hasMultiple) {
return (
<div className="flex min-w-0 flex-1 items-center gap-2.5 p-1 -m-1">
{avatarElement}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="truncate text-sm font-semibold leading-5 text-foreground">
{displayTitle}
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{displaySubtitle}
</div>
</div>
</div>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-md p-1 -m-1 text-left transition-colors hover:bg-foreground/[0.04] group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
aria-label={t("supportChat.myRequests")}
>
{avatarElement}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold leading-5 text-foreground">
{displayTitle}
</span>
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100 transition-opacity" />
{totalUnread > 0 ? (
<span className="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground">
{totalUnread > 99 ? "99+" : totalUnread}
</span>
) : null}
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{displaySubtitle}
</div>
</div>
</button>
}
>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] p-0">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>{t("supportChat.myRequests")}</span>
<span className="shrink-0 font-normal">
{t("supportChat.openRequestCount", {
open: openCount,
total: conversations.length,
})}
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator className="my-0" />
<div className="max-h-64 overflow-y-auto py-1">
{conversations.map((conversation: ImConversation) => (
<DropdownMenuItem
key={conversation.id}
onClick={() => void selectConversation(conversation.id)}
className="flex-col items-start gap-1 px-3 py-2"
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<CheckIcon
className={cn(
"size-3.5 shrink-0 text-primary",
conversation.id === activeConversationId
? "opacity-100"
: "opacity-0"
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{resolveConversationTitle(
conversation,
t("supportChat.requestFallbackTitle")
)}
</span>
{Number(conversation.customerUnreadCount) > 0 ? (
<span className="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground">
{conversation.customerUnreadCount}
</span>
) : null}
</span>
<span className="flex w-full items-center gap-1.5 pl-5 text-[11px] text-muted-foreground">
<span
className={cn(
"inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
getStatusBadgeClass(conversation.status)
)}
>
{t(getConversationStatusMessageKey(conversation.status))}
</span>
<span className="truncate">
{formatDateTime(conversation.lastActiveAt)}
</span>
</span>
</DropdownMenuItem>
))}
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0" />
<div className="flex items-center gap-1.5 p-1.5">
<Button
type="button"
variant="outline"
size="sm"
disabled={creatingConversation}
onClick={onNewRequest}
className="h-8 flex-1 justify-start gap-1.5 text-xs"
>
<MessageSquarePlusIcon className="size-3.5" />
<span className="truncate">
{creatingConversation
? t("supportChat.creatingRequest")
: t("supportChat.newRequest")}
</span>
</Button>
{active && active.status !== 4 && onCloseActiveRequest ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onCloseActiveRequest}
className="h-8 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
title={t("supportChat.confirmClose")}
>
<CheckCircle2Icon className="size-3.5" />
<span>{t("supportChat.confirmClose")}</span>
</Button>
) : null}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,228 @@
"use client"
import { Loader2Icon, StarIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { useI18n } from "@/i18n/provider"
import {
submitConversationSatisfaction,
type ConversationSatisfactionResponse,
} from "@/lib/api/im"
import { cn } from "@/lib/utils"
export type CSATCardProps = {
conversationId: number
customerToken?: string
initialSatisfaction?: ConversationSatisfactionResponse | null
onSubmitted?: (satisfaction: ConversationSatisfactionResponse) => void
className?: string
}
function getScoreLabel(score: number, t: (key: string) => string): string {
switch (score) {
case 1:
return t("csat.score1")
case 2:
return t("csat.score2")
case 3:
return t("csat.score3")
case 4:
return t("csat.score4")
case 5:
return t("csat.score5")
default:
return ""
}
}
export function CSATCard({
conversationId,
customerToken,
initialSatisfaction,
onSubmitted,
className,
}: CSATCardProps) {
const t = useI18n()
const [satisfaction, setSatisfaction] = useState<ConversationSatisfactionResponse | null>(
initialSatisfaction ?? null
)
const [selectedScore, setSelectedScore] = useState<number | null>(
initialSatisfaction?.score ?? null
)
const [hoveredScore, setHoveredScore] = useState<number | null>(null)
const [feedback, setFeedback] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setSatisfaction(initialSatisfaction ?? null)
setSelectedScore(initialSatisfaction?.score ?? null)
setHoveredScore(null)
setFeedback("")
setError(null)
}, [conversationId, initialSatisfaction])
if (satisfaction) {
return (
<div
className={cn(
"flex flex-col items-center gap-2.5 rounded-md border border-border/80 bg-card/90 p-4 text-center text-card-foreground dark:bg-card/70",
className
)}
>
<div
className="flex items-center gap-1.5"
role="img"
aria-label={t("csat.alreadyRated", { score: String(satisfaction.score) })}
>
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
className={cn(
"size-5",
star <= satisfaction.score
? "fill-amber-400 text-amber-400"
: "text-muted-foreground/25"
)}
/>
))}
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">{t("csat.thankYou")}</p>
<p className="text-xs text-muted-foreground">
{t("csat.alreadyRated", { score: String(satisfaction.score) })}
</p>
</div>
{satisfaction.feedback ? (
<p className="mt-1 max-w-xs text-xs text-muted-foreground/90 italic break-words">
&ldquo;{satisfaction.feedback}&rdquo;
</p>
) : null}
</div>
)
}
const activeScore = hoveredScore ?? selectedScore
const activeScoreLabel = activeScore ? getScoreLabel(activeScore, t) : ""
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedScore || isSubmitting) {
return
}
setIsSubmitting(true)
setError(null)
try {
const result = await submitConversationSatisfaction(
{
conversationId,
score: selectedScore,
feedback: feedback.trim() || undefined,
},
customerToken
)
setSatisfaction(result)
onSubmitted?.(result)
} catch (err) {
setError(err instanceof Error ? err.message : t("api.requestFailed"))
} finally {
setIsSubmitting(false)
}
}
return (
<form
onSubmit={handleSubmit}
className={cn(
"flex flex-col gap-3 rounded-md border border-border/80 bg-card/90 p-4 text-card-foreground dark:bg-card/70",
className
)}
>
<div className="text-center">
<h4 className="text-sm font-medium text-foreground">{t("csat.title")}</h4>
</div>
<div className="flex flex-col items-center gap-1.5">
<div
role="radiogroup"
aria-label={t("csat.title")}
className="flex items-center gap-1.5"
onMouseLeave={() => setHoveredScore(null)}
>
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = activeScore !== null && star <= activeScore
const isSelected = selectedScore === star
const starLabel = getScoreLabel(star, t)
return (
<button
key={star}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={`${star} - ${starLabel}`}
disabled={isSubmitting}
onMouseEnter={() => setHoveredScore(star)}
onFocus={() => setHoveredScore(star)}
onBlur={() => setHoveredScore(null)}
onClick={() => setSelectedScore(star)}
className="group rounded-md p-1 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
>
<StarIcon
className={cn(
"size-6 transition-colors",
isFilled
? "fill-amber-400 text-amber-400"
: "text-muted-foreground/35 group-hover:text-amber-400/70"
)}
/>
</button>
)
})}
</div>
<div
role="status"
aria-live="polite"
className="h-4 text-xs font-medium text-muted-foreground transition-all"
>
{activeScoreLabel}
</div>
</div>
<Textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder={t("csat.commentPlaceholder")}
disabled={isSubmitting}
rows={2}
className="resize-none text-xs"
/>
{error ? (
<div role="alert" aria-live="polite" className="text-xs text-destructive">
{error}
</div>
) : null}
<Button
type="submit"
disabled={!selectedScore || isSubmitting}
size="sm"
className="w-full"
>
{isSubmitting ? (
<>
<Loader2Icon className="mr-2 size-3.5 animate-spin" />
{t("csat.submitting")}
</>
) : (
t("csat.submit")
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,34 @@
"use client"
import {
SharedMessageEditor,
type UploadedMessageEditorImage,
} from "@/components/chat/shared-message-editor"
type CustomerMessageEditorProps = {
disabled?: boolean
uploadingAsset?: boolean
onSend: (html: string) => Promise<void>
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
onSendAttachment: (file: File) => Promise<void>
}
export function CustomerMessageEditor({
disabled = false,
uploadingAsset = false,
onSend,
onUploadImage,
onSendAttachment,
}: CustomerMessageEditorProps) {
return (
<SharedMessageEditor
variant="customer"
disabled={disabled}
uploadingAsset={uploadingAsset}
manageLocalUploading
onSend={onSend}
onUploadImage={onUploadImage}
onSendAttachment={onSendAttachment}
/>
)
}

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadDemoNavigation() {
const source = await readFile(new URL("./demo-navigation.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "demo-navigation.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("widget demo lives below support so /support remains available", async () => {
const { getWidgetDemoPath } = await loadDemoNavigation()
assert.equal(getWidgetDemoPath(), "/support/demo")
})

View File

@@ -0,0 +1,5 @@
export const SUPPORT_DEMO_PATH = "/support/demo"
export function getWidgetDemoPath() {
return SUPPORT_DEMO_PATH
}

View File

@@ -0,0 +1,335 @@
"use client"
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from "react"
import { AgentActivityBubble } from "@/components/chat/agent-activity-bubble"
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
import {
ConversationMessageScroller,
ConversationMessageScrollerItem,
type ConversationMessageScrollerHandle,
} from "@/components/chat/conversation-message-scroller"
import { ImMessageHTML } from "@/components/im-message-html"
import { useImageLightbox } from "@/components/image-lightbox"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { PromptStarters } from "@/components/support-chat/prompt-starters"
import type { AgentActivityInfo } from "@/lib/agent-activity-state"
import type { ImMessage } from "@/lib/api/im"
import { renderIMMessageHTML } from "@/lib/im-message"
import { useSupportChatStore } from "@/lib/stores/support-chat"
import { cn, formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
type SupportChatMessageListProps = {
messages?: ImMessage[] | null
agentActivity?: AgentActivityInfo | null
promptStarters?: string[]
onSelectPromptStarter?: (starter: string) => void
onNearBottomVisible?: () => void
hasMoreOlder?: boolean
loadingOlder?: boolean
onLoadOlder?: () => Promise<void>
}
export type SupportChatMessageListHandle = {
scrollToBottom: () => void
}
function getDayKey(value?: string) {
if (!value) {
return "unknown"
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value.slice(0, 10)
}
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate()
).padStart(2, "0")}`
}
function getTimelineLabel(
value: string | undefined,
t: (key: string, values?: Record<string, string | number>) => string
) {
if (!value) {
return t("supportChat.justNow")
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
const currentDayKey = getDayKey(value)
const todayDayKey = getDayKey(new Date().toISOString())
const timeText = `${String(date.getHours()).padStart(2, "0")}:${String(
date.getMinutes()
).padStart(2, "0")}`
if (currentDayKey === todayDayKey) {
return t("supportChat.todayAt", { time: timeText })
}
return `${currentDayKey} ${timeText}`
}
export const SupportChatMessageList = forwardRef<SupportChatMessageListHandle, SupportChatMessageListProps>(
function SupportChatMessageList(
{
messages,
agentActivity,
promptStarters,
onSelectPromptStarter,
onNearBottomVisible,
hasMoreOlder = false,
loadingOlder = false,
onLoadOlder,
},
ref
) {
const t = useI18n()
const scrollerRef = useRef<ConversationMessageScrollerHandle | null>(null)
const shouldStickToBottomRef = useRef(true)
const onNearBottomVisibleRef = useRef(onNearBottomVisible)
const safeMessages = Array.isArray(messages) ? messages : []
const storeAgentActivity = useSupportChatStore((state) => state.activeAgentActivity)
const storePromptStarters = useSupportChatStore((state) => state.promptStarters)
const storeHandleSendMessage = useSupportChatStore((state) => state.handleSendMessage)
const storeSending = useSupportChatStore((state) => state.sending)
const resolvedAgentActivity = agentActivity !== undefined ? agentActivity : storeAgentActivity
const resolvedPromptStarters = promptStarters !== undefined ? promptStarters : storePromptStarters
const handleSelectPromptStarter = onSelectPromptStarter ?? storeHandleSendMessage
useEffect(() => {
onNearBottomVisibleRef.current = onNearBottomVisible
}, [onNearBottomVisible])
const scrollToBottom = useCallback(() => {
scrollerRef.current?.scrollToBottom()
}, [])
const prevWasActiveRef = useRef(false)
useEffect(() => {
const wasActive = prevWasActiveRef.current
const isNowActive = Boolean(resolvedAgentActivity)
prevWasActiveRef.current = isNowActive
if (!wasActive && isNowActive && shouldStickToBottomRef.current) {
scrollToBottom()
}
}, [resolvedAgentActivity, scrollToBottom])
const handleImageSettled = useCallback(() => {
if (shouldStickToBottomRef.current) {
scrollToBottom()
onNearBottomVisibleRef.current?.()
}
}, [scrollToBottom])
useImperativeHandle(ref, () => ({
scrollToBottom,
}))
const handleLoadOlder = useCallback(async () => {
if (!onLoadOlder || loadingOlder || !hasMoreOlder) {
return
}
await onLoadOlder()
}, [hasMoreOlder, loadingOlder, onLoadOlder])
return (
<ConversationMessageScroller
ref={scrollerRef}
className="flex min-h-0 flex-1"
viewportClassName="bg-transparent"
contentClassName="gap-4 px-4 py-4"
hasMoreOlder={hasMoreOlder}
loadingOlder={loadingOlder}
onLoadOlder={onLoadOlder}
onNearBottomChange={(nearBottom) => {
shouldStickToBottomRef.current = nearBottom
}}
onNearBottomVisible={onNearBottomVisible}
topSlot={
hasMoreOlder && onLoadOlder ? (
<div className="flex justify-center py-1">
<Button
type="button"
variant="outline"
size="sm"
disabled={loadingOlder}
onClick={() => void handleLoadOlder()}
className="h-7 rounded-full bg-background/90 text-xs text-muted-foreground shadow-sm hover:bg-background hover:text-sky-700 dark:hover:text-sky-400"
>
{loadingOlder ? t("supportChat.loadingOlder") : t("supportChat.loadOlder")}
</Button>
</div>
) : null
}
>
{safeMessages.length === 0 && !resolvedAgentActivity ? (
<div className="flex min-h-32 flex-col items-center justify-center gap-4 px-3 py-6 text-center text-sm leading-6 text-muted-foreground">
<p className="max-w-xs">{t("supportChat.emptyPrompt")}</p>
{resolvedPromptStarters && resolvedPromptStarters.length > 0 ? (
<PromptStarters
items={resolvedPromptStarters}
disabled={storeSending}
onSelect={(starter) => {
void handleSelectPromptStarter?.(starter)
}}
/>
) : null}
</div>
) : null}
{safeMessages.map((message, index) => {
const previousMessage = index > 0 ? safeMessages[index - 1] : null
const showTimeline =
index === 0 ||
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt)
return (
<ConversationMessageScrollerItem
key={message.id}
messageId={`${message.id}`}
>
<MessageItem
message={message}
showTimeline={showTimeline}
onImageSettled={handleImageSettled}
timelineLabel={getTimelineLabel(message.sentAt, t)}
/>
</ConversationMessageScrollerItem>
)
})}
{resolvedAgentActivity ? (
<AgentActivityBubble
stepCode={resolvedAgentActivity.stepCode}
toolName={resolvedAgentActivity.toolName}
isCustomer
/>
) : null}
</ConversationMessageScroller>
)
}
)
type MessageItemProps = {
message: ImMessage
showTimeline: boolean
onImageSettled: () => void
timelineLabel: string
}
const MessageItem = memo(
function MessageItem({ message, showTimeline, onImageSettled, timelineLabel }: MessageItemProps) {
const t = useI18n()
const { open } = useImageLightbox()
const isCustomer = message.senderType === "customer"
// The server substitutes recalled messages with its own placeholder text, so
// rendering content as-is would show a visitor that placeholder in the
// server's language. The dashboard already replaces it; the portal did not.
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6
const senderName = isCustomer ? t("supportChat.customerSelf") : message.senderName?.trim() || t("supportChat.agentLabel")
const avatarSrc =
!isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined
const htmlContent = isRecalled
? `<p>${t("conversation.messageRecalledHtml")}</p>`
: renderIMMessageHTML(message)
const fallbackName = senderName.slice(0, 1).toUpperCase()
return (
<div>
{showTimeline ? (
<div className="mb-3 flex items-center justify-center">
<Badge
variant="outline"
className="border-border bg-background/85 text-[11px] font-medium text-muted-foreground shadow-sm"
>
{timelineLabel}
</Badge>
</div>
) : null}
<ConversationMessageRow
align={isCustomer ? "end" : "start"}
avatar={
!isCustomer ? (
<Avatar>
{avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null}
<AvatarFallback className="bg-muted text-muted-foreground">
{fallbackName || t("supportChat.customerFallback")}
</AvatarFallback>
</Avatar>
) : null
}
contentClassName="max-w-[86%] gap-1.5"
headerClassName="flex-wrap gap-x-2 gap-y-1 px-1 text-[11px]"
header={
<>
<span className="font-medium">{senderName}</span>
<span>{formatDateTime(message.sentAt)}</span>
{isCustomer ? (
<span>{message.agentRead ? t("supportChat.agentRead") : t("supportChat.agentUnread")}</span>
) : null}
</>
}
>
<ConversationMessageBubble
variant={isRecalled ? "recalled" : isCustomer ? "customer" : "system"}
className={cn(
"rounded-lg border-0 px-3 py-2 text-sm leading-normal shadow-[0_10px_22px_rgba(15,23,42,0.06)]",
isRecalled
? "!border-border !bg-muted !text-muted-foreground"
: isCustomer
? "!bg-[#a9ea7a] !text-[#161616] dark:!bg-emerald-500 dark:!text-emerald-950"
: "!border-border !bg-card !text-card-foreground dark:!bg-background"
)}
>
<ImMessageHTML
html={htmlContent}
className={cn(
isRecalled
? "[&_p]:text-muted-foreground"
: isCustomer
? "[&_p]:text-[#161616] dark:[&_p]:text-emerald-950 [&_a]:text-[#161616] dark:[&_a]:text-emerald-950 [&_a]:underline [&_img]:cursor-zoom-in"
: "[&_a]:text-card-foreground [&_a]:underline [&_img]:cursor-zoom-in"
)}
onImageSettled={onImageSettled}
onImageClick={isRecalled ? undefined : open}
/>
</ConversationMessageBubble>
</ConversationMessageRow>
</div>
)
},
(prevProps, nextProps) =>
isSameMessageItemRender(prevProps.message, nextProps.message) &&
prevProps.showTimeline === nextProps.showTimeline &&
prevProps.timelineLabel === nextProps.timelineLabel &&
prevProps.onImageSettled === nextProps.onImageSettled
)
function isSameMessageItemRender(prev: ImMessage, next: ImMessage) {
return (
prev.id === next.id &&
prev.senderType === next.senderType &&
prev.senderName === next.senderName &&
prev.senderAvatar === next.senderAvatar &&
prev.messageType === next.messageType &&
prev.content === next.content &&
prev.payload === next.payload &&
prev.sentAt === next.sentAt &&
prev.agentRead === next.agentRead
)
}

View File

@@ -0,0 +1,171 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useSupportChatStore } from "@/lib/stores/support-chat"
import { useI18n } from "@/i18n/provider"
const SUBJECT_MAX_LENGTH = 255
type SupportChatNewRequestDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onCreated?: () => void
}
/**
* Asks for an optional subject before starting a request. The subject is what
* tells several requests apart in the switcher, so it is worth prompting for -
* but a visitor in a hurry must not be blocked, hence "optional" and an
* immediate path through.
*/
export function SupportChatNewRequestDialog({
open,
onOpenChange,
onCreated,
}: SupportChatNewRequestDialogProps) {
const t = useI18n()
const creatingConversation = useSupportChatStore(
(state) => state.creatingConversation
)
const startNewConversation = useSupportChatStore(
(state) => state.startNewConversation
)
const [subject, setSubject] = useState("")
const [validationError, setValidationError] = useState("")
const [submitFailed, setSubmitFailed] = useState(false)
function resetForm() {
setSubject("")
setValidationError("")
setSubmitFailed(false)
}
// The dialog body unmounts whenever it is closed, so resetting on close rather
// than in an effect keyed on `open` also avoids a cascading render.
function handleOpenChange(nextOpen: boolean) {
if (creatingConversation) {
return
}
if (!nextOpen) {
resetForm()
}
onOpenChange(nextOpen)
}
async function handleCreate() {
const trimmed = subject.trim()
if ([...trimmed].length > SUBJECT_MAX_LENGTH) {
setValidationError(t("supportChat.newRequestSubjectTooLong"))
return
}
setValidationError("")
setSubmitFailed(false)
try {
await startNewConversation(trimmed)
onCreated?.()
resetForm()
onOpenChange(false)
} catch {
// The store already surfaced the localized reason on its error strip;
// keeping the dialog open preserves what the visitor typed.
setSubmitFailed(true)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-[380px]"
showCloseButton={!creatingConversation}
>
<DialogHeader>
<DialogTitle>{t("supportChat.newRequestDialogTitle")}</DialogTitle>
<DialogDescription>
{t("supportChat.newRequestDialogDescription")}
</DialogDescription>
</DialogHeader>
<Field className="gap-2">
<FieldLabel htmlFor="support-chat-new-request-subject">
{t("supportChat.newRequestSubjectLabel")}
</FieldLabel>
<Input
id="support-chat-new-request-subject"
value={subject}
maxLength={SUBJECT_MAX_LENGTH}
placeholder={t("supportChat.newRequestSubjectPlaceholder")}
disabled={creatingConversation}
aria-invalid={validationError ? true : undefined}
aria-describedby={
validationError
? "support-chat-new-request-subject-error"
: "support-chat-new-request-subject-hint"
}
onChange={(event) => {
setSubject(event.target.value)
if (validationError) {
setValidationError("")
}
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !creatingConversation) {
event.preventDefault()
void handleCreate()
}
}}
/>
{validationError ? (
<p
id="support-chat-new-request-subject-error"
className="text-xs text-destructive"
>
{validationError}
</p>
) : (
<FieldDescription id="support-chat-new-request-subject-hint">
{t("supportChat.newRequestSubjectHint")}
</FieldDescription>
)}
{submitFailed ? (
<p className="text-xs text-destructive">
{t("supportChat.createConversationFailed")}
</p>
) : null}
</Field>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={creatingConversation}
onClick={() => handleOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={creatingConversation}
onClick={() => void handleCreate()}
>
{creatingConversation
? t("supportChat.creatingRequest")
: t("supportChat.createRequest")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { ArrowRightIcon, CircleHelpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
export type PromptStartersProps = {
items?: string[] | null
onSelect?: (text: string) => void
disabled?: boolean
className?: string
}
export function PromptStarters({
items,
onSelect,
disabled = false,
className,
}: PromptStartersProps) {
const t = useI18n()
const safeItems = Array.isArray(items)
? items.map((item) => item.trim()).filter(Boolean)
: []
if (safeItems.length === 0) {
return null
}
return (
<div
role="group"
aria-label={t("promptStarters.title")}
className={cn("flex w-full max-w-md flex-col gap-2", className)}
>
{safeItems.map((item, index) => (
<button
key={`${index}-${item}`}
type="button"
disabled={disabled}
onClick={() => onSelect?.(item)}
className="group flex w-full items-center gap-2.5 rounded-lg border border-border/80 bg-card/80 px-3.5 py-2.5 text-left text-sm text-card-foreground shadow-xs transition-all hover:border-primary/40 hover:bg-accent/60 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99] disabled:pointer-events-none disabled:opacity-50 dark:bg-card/50"
>
<CircleHelpIcon className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
<span className="flex-1 font-normal leading-snug break-words">{item}</span>
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/60 opacity-0 transition-all group-hover:opacity-100 group-hover:translate-x-0.5" />
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,466 @@
"use client"
import { SignJWT } from "jose"
import { CheckIcon, CopyIcon } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import type { AgentDeskConfig } from "@/lib/sdk/config-types"
import { useI18n } from "@/i18n/provider"
const STORAGE_KEY = "agent-desk-web-widget-test-config"
const DEFAULT_JWT_TTL_MINUTES = "30"
const INITIAL_CONFIG: AgentDeskConfig = {
channelId: "",
baseUrl: "",
apiBaseUrl: "",
}
type AuthMode = "guest" | "jwt"
type WidgetDemoConfig = AgentDeskConfig & {
authMode?: AuthMode
jwtSecret?: string
jwtUserId?: string
jwtName?: string
jwtTtlMinutes?: string
}
function getDefaultConfig(defaultName: string): WidgetDemoConfig {
if (typeof window === "undefined") {
return INITIAL_CONFIG
}
const savedText = window.localStorage.getItem(STORAGE_KEY)
const savedConfig = savedText
? (JSON.parse(savedText) as Partial<WidgetDemoConfig>)
: {}
const query = new URLSearchParams(window.location.search)
return {
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
baseUrl: "",
apiBaseUrl: "",
authMode: (query.get("authMode") as AuthMode | null) ?? savedConfig.authMode ?? "guest",
jwtSecret: savedConfig.jwtSecret ?? "",
jwtUserId: query.get("userId") ?? savedConfig.jwtUserId ?? "demo-user-001",
jwtName: query.get("name") ?? savedConfig.jwtName ?? defaultName,
jwtTtlMinutes: savedConfig.jwtTtlMinutes ?? DEFAULT_JWT_TTL_MINUTES,
}
}
function removeMountedWidget() {
if (typeof window === "undefined") {
return
}
window.AgentDeskWidget?.destroy()
document
.querySelectorAll(
'[data-agent-desk-widget="launcher"], [data-agent-desk-widget="frame"], [data-agent-desk-widget="script"]'
)
.forEach((node) => node.remove())
delete window.AgentDeskConfig
delete window.__CS_AI_AGENT_WIDGET_CONFIG__
delete window.__CS_AI_AGENT_WIDGET_STATE__
delete window.AgentDeskWidget
}
function injectWidget(config: AgentDeskConfig) {
removeMountedWidget()
window.AgentDeskConfig = config
const script = document.createElement("script")
script.async = true
script.src = `${window.location.origin}/sdk/agent-desk-sdk.min.js`
script.dataset.agentDeskWidget = "script"
document.body.appendChild(script)
}
function buildWidgetConfig(config: WidgetDemoConfig): AgentDeskConfig {
const nextConfig: AgentDeskConfig = {
channelId: config.channelId.trim(),
baseUrl: "",
apiBaseUrl: "",
}
if (config.authMode === "jwt") {
nextConfig.getUserToken = undefined
}
return nextConfig
}
async function signUserToken(config: WidgetDemoConfig, t: (key: string) => string) {
const userId = (config.jwtUserId || "").trim()
const name = (config.jwtName || "").trim()
const secret = (config.jwtSecret || "").trim()
const ttl = Number(config.jwtTtlMinutes || DEFAULT_JWT_TTL_MINUTES)
if (!userId) {
throw new Error(t("widgetDemo.missingUserId"))
}
if (!name) {
throw new Error(t("widgetDemo.missingName"))
}
if (!secret) {
throw new Error(t("widgetDemo.missingSecret"))
}
if (!Number.isFinite(ttl) || ttl <= 0) {
throw new Error(t("widgetDemo.invalidTtl"))
}
return new SignJWT({ userId, name })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(`${ttl}m`)
.sign(new TextEncoder().encode(secret))
}
export function SupportWidgetDemo() {
const t = useI18n()
const [config, setConfig] = useState<WidgetDemoConfig>({
...INITIAL_CONFIG,
authMode: "guest",
jwtSecret: "",
jwtUserId: "demo-user-001",
jwtName: t("widgetDemo.defaultName"),
jwtTtlMinutes: DEFAULT_JWT_TTL_MINUTES,
})
const [status, setStatus] = useState(t("widgetDemo.missingChannel"))
const [origin, setOrigin] = useState("")
const [generatedToken, setGeneratedToken] = useState("")
const [latestDirectChatUrl, setLatestDirectChatUrl] = useState("")
const [copied, setCopied] = useState(false)
const [snippetCopied, setSnippetCopied] = useState(false)
async function mountWidget(configToMount: WidgetDemoConfig) {
const cleanConfig = {
...configToMount,
channelId: configToMount.channelId.trim(),
baseUrl: "",
apiBaseUrl: "",
getUserToken: undefined,
}
const nextConfig = buildWidgetConfig(cleanConfig)
if (cleanConfig.authMode === "jwt") {
nextConfig.getUserToken = () => signUserToken(cleanConfig, t)
}
setConfig(cleanConfig)
setGeneratedToken("")
setLatestDirectChatUrl("")
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanConfig))
if (!nextConfig.channelId) {
removeMountedWidget()
setStatus(t("widgetDemo.missingChannel"))
return
}
injectWidget(nextConfig)
setStatus(
cleanConfig.authMode === "jwt"
? t("widgetDemo.mountedJwt")
: t("widgetDemo.mountedGuest")
)
}
useEffect(() => {
const timer = window.setTimeout(() => {
const initialConfig = getDefaultConfig(t("widgetDemo.defaultName"))
setOrigin(window.location.origin)
setConfig(initialConfig)
setStatus(initialConfig.channelId ? t("widgetDemo.mounted") : t("widgetDemo.missingChannel"))
if (initialConfig.channelId) {
void mountWidget(initialConfig).catch((error) => {
removeMountedWidget()
setGeneratedToken("")
setStatus(error instanceof Error ? error.message : t("widgetDemo.mountFailed"))
})
}
}, 0)
return () => {
window.clearTimeout(timer)
removeMountedWidget()
}
}, [t])
const snippet = useMemo(() => {
const scriptSrc = origin
? `${origin}/sdk/agent-desk-sdk.min.js`
: "/sdk/agent-desk-sdk.min.js"
const configLines = [` channelId: "${config.channelId || ""}"`]
if (config.authMode === "jwt") {
configLines.push(` async getUserToken() {
const res = await fetch("/api/support/user-token", { credentials: "include" });
const data = await res.json();
return data.userToken;
}`)
}
return `<script>
window.AgentDeskConfig = {
${configLines.join(",\n")}
};
</script>
<script async src="${scriptSrc}"></script>`
}, [config.authMode, config.channelId, origin])
function updateField<K extends keyof WidgetDemoConfig>(
key: K,
value: WidgetDemoConfig[K]
) {
setConfig((current) => ({ ...current, [key]: value }))
}
async function handleMount() {
try {
await mountWidget(config)
} catch (error) {
removeMountedWidget()
setGeneratedToken("")
setStatus(error instanceof Error ? error.message : t("widgetDemo.mountFailed"))
}
}
async function handleCopyDirectUrl() {
if (!window.AgentDeskWidget || typeof navigator === "undefined") {
return
}
try {
const url = await window.AgentDeskWidget.getChatUrl()
setLatestDirectChatUrl(url)
if (config.authMode === "jwt") {
setGeneratedToken(new URL(url).searchParams.get("userToken") || "")
}
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch (error) {
setStatus(error instanceof Error ? error.message : t("widgetDemo.linkFailed"))
}
}
async function handleOpenDirectChat() {
if (!window.AgentDeskWidget) {
return
}
try {
const url = await window.AgentDeskWidget.getChatUrl()
setLatestDirectChatUrl(url)
if (config.authMode === "jwt") {
setGeneratedToken(new URL(url).searchParams.get("userToken") || "")
}
window.open(url, "_blank", "noopener,noreferrer")
} catch (error) {
setStatus(error instanceof Error ? error.message : t("widgetDemo.linkFailed"))
}
}
async function handleCopySnippet() {
if (typeof navigator === "undefined") {
return
}
await navigator.clipboard.writeText(snippet)
setSnippetCopied(true)
window.setTimeout(() => setSnippetCopied(false), 1600)
}
return (
<main className="min-h-svh bg-slate-50 px-6 py-8 text-slate-950">
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div className="text-base font-semibold">{t("widgetDemo.title")}</div>
<div className="mt-1 text-sm text-slate-500">{status}</div>
<div className="mt-5 grid gap-3">
<TextField
label="channelId"
value={config.channelId}
onChange={(value) => updateField("channelId", value)}
/>
<SegmentedControl
label={t("widgetDemo.authMode")}
value={config.authMode || "guest"}
onChange={(value) => updateField("authMode", value)}
options={[
{ label: t("widgetDemo.guest"), value: "guest" },
{ label: t("widgetDemo.jwtUser"), value: "jwt" },
]}
/>
{config.authMode === "jwt" ? (
<div className="grid gap-3 rounded-md border border-slate-200 p-3">
<TextField
label="userId"
value={config.jwtUserId}
onChange={(value) => updateField("jwtUserId", value)}
/>
<TextField
label="name"
value={config.jwtName}
onChange={(value) => updateField("jwtName", value)}
/>
<TextField
label="JWT Secret"
value={config.jwtSecret}
onChange={(value) => updateField("jwtSecret", value)}
type="password"
/>
<TextField
label={t("widgetDemo.ttlMinutes")}
value={config.jwtTtlMinutes}
onChange={(value) => updateField("jwtTtlMinutes", value)}
type="number"
/>
</div>
) : null}
</div>
<div className="mt-5 flex gap-2">
<button
type="button"
onClick={() => void handleMount()}
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
>
{t("widgetDemo.mount")}
</button>
<button
type="button"
onClick={() => {
removeMountedWidget()
setStatus(t("widgetDemo.unmounted"))
}}
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium"
>
{t("widgetDemo.unmount")}
</button>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div className="text-base font-semibold">{t("widgetDemo.snippetTitle")}</div>
{config.authMode === "jwt" ? (
<div className="mt-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
{t("widgetDemo.jwtNotice")}
</div>
) : null}
<div className="relative mt-4">
<button
type="button"
onClick={() => void handleCopySnippet()}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-md border border-white/10 bg-white/10 text-slate-200 transition hover:bg-white/20 hover:text-white"
aria-label={snippetCopied ? t("widgetDemo.copiedSnippet") : t("widgetDemo.copySnippet")}
title={snippetCopied ? t("widgetDemo.copied") : t("widgetDemo.copyCode")}
>
{snippetCopied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</button>
<pre className="overflow-x-auto rounded-md bg-slate-950 p-4 pr-12 text-xs leading-5 text-slate-100">
<code>{snippet}</code>
</pre>
</div>
<div className="mt-5">
<div className="text-sm font-medium text-slate-700">{t("widgetDemo.directChat")}</div>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
readOnly
value={latestDirectChatUrl || t("widgetDemo.directChatPlaceholder")}
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 px-3 font-mono text-xs outline-none"
/>
<div className="flex gap-2">
<button
type="button"
disabled={!config.channelId}
onClick={() => void handleCopyDirectUrl()}
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
{copied ? t("widgetDemo.copied") : t("widgetDemo.copy")}
</button>
<button
type="button"
disabled={!config.channelId}
onClick={() => void handleOpenDirectChat()}
className="rounded-md bg-slate-950 px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{t("widgetDemo.openNewWindow")}
</button>
</div>
</div>
</div>
{generatedToken ? (
<div className="mt-4">
<div className="text-sm font-medium text-slate-700">{t("widgetDemo.currentToken")}</div>
<textarea
readOnly
value={generatedToken}
className="mt-2 h-28 w-full resize-none rounded-md border border-slate-200 p-3 font-mono text-xs outline-none"
/>
</div>
) : null}
</section>
</div>
</main>
)
}
function TextField({
label,
value,
onChange,
type = "text",
}: {
label: string
value?: string
onChange: (value: string) => void
type?: string
}) {
return (
<label className="grid gap-1.5 text-sm">
<span className="font-medium text-slate-700">{label}</span>
<input
type={type}
value={value || ""}
onChange={(event) => onChange(event.target.value)}
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
/>
</label>
)
}
function SegmentedControl<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: Array<{ label: string; value: T }>
onChange: (value: T) => void
}) {
return (
<div className="grid gap-1.5 text-sm">
<div className="font-medium text-slate-700">{label}</div>
<div className="grid grid-cols-2 rounded-md border border-slate-200 bg-slate-100 p-1">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={
option.value === value
? "rounded bg-white px-3 py-1.5 text-sm font-medium shadow-sm"
: "rounded px-3 py-1.5 text-sm text-slate-600"
}
>
{option.label}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,355 @@
"use client"
import {
ChevronRightIcon,
ChevronsUpDownIcon,
Loader2Icon,
TagIcon,
} from "lucide-react"
import { useMemo, useState, type ComponentProps } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider"
import type { TagTree } from "@/lib/api/admin"
import {
buildTagPathMap,
flattenTagTree,
flattenVisibleTagTree,
type FlatTagNode,
} from "@/lib/tag-tree"
import { cn } from "@/lib/utils"
type CommonTagSelectorProps = {
tags: TagTree[]
placeholder: string
searchPlaceholder?: string
emptyText?: string
loadingText?: string
disabled?: boolean
loading?: boolean
excludeIds?: number[]
align?: "start" | "center" | "end"
className?: string
triggerClassName?: string
triggerVariant?: ComponentProps<typeof Button>["variant"]
triggerSize?: ComponentProps<typeof Button>["size"]
contentClassName?: string
showSelectedBadges?: boolean
selectedCountText?: (count: number) => string
triggerText?: string
pendingTagId?: number | null
}
type MultipleTagSelectorProps = CommonTagSelectorProps & {
mode: "multiple"
value?: number[]
onChange: (value: number[]) => void
}
type SingleTagSelectorProps = CommonTagSelectorProps & {
mode: "single"
value?: number | null
onChange: (value: number) => void
rootOption?: {
value: number
label: string
}
}
export type TagSelectorProps = MultipleTagSelectorProps | SingleTagSelectorProps
function isSelected(props: TagSelectorProps, tagId: number) {
if (props.mode === "single") {
return props.value === tagId
}
return new Set(props.value ?? []).has(tagId)
}
function getSelectedTags(flatTags: FlatTagNode[], value?: number[]) {
const selectedIds = new Set(value ?? [])
return flatTags.filter((tag) => selectedIds.has(tag.id))
}
export function TagSelector(props: TagSelectorProps) {
const t = useI18n()
const [open, setOpen] = useState(false)
const {
tags,
placeholder,
searchPlaceholder = t("common.searchKeyword"),
emptyText = t("common.emptyOptions"),
loadingText = t("common.loading"),
disabled = false,
loading = false,
excludeIds,
align = "start",
className,
triggerClassName,
triggerVariant = "outline",
triggerSize,
contentClassName,
showSelectedBadges = props.mode === "multiple",
selectedCountText,
triggerText,
pendingTagId = null,
} = props
const [query, setQuery] = useState("")
const [collapsedIds, setCollapsedIds] = useState<Set<number>>(new Set())
const flatTags = useMemo(
() => flattenTagTree(tags, { excludeIds }),
[excludeIds, tags]
)
const visibleFlatTags = useMemo(
() => flattenVisibleTagTree(tags, { excludeIds, collapsedIds: [...collapsedIds] }),
[collapsedIds, excludeIds, tags]
)
const selectedTags = useMemo(
() => (props.mode === "multiple" ? getSelectedTags(flatTags, props.value) : []),
[flatTags, props]
)
const rootTag = useMemo<FlatTagNode | null>(() => {
if (props.mode !== "single" || !props.rootOption) {
return null
}
return {
id: props.rootOption.value,
parentId: 0,
name: props.rootOption.label,
remark: "",
sortNo: 0,
status: 0,
createdAt: "",
updatedAt: "",
children: [],
depth: 0,
path: props.rootOption.label,
searchableText: `${props.rootOption.label} ${props.rootOption.value}`,
}
}, [props])
const allSelectableTags = useMemo(
() => (props.mode === "single" && rootTag ? [rootTag, ...flatTags] : flatTags),
[flatTags, props.mode, rootTag]
)
const normalizedQuery = query.trim().toLowerCase()
const visibleSelectableTags = useMemo(() => {
const visibleTags = props.mode === "single" && rootTag
? [rootTag, ...visibleFlatTags]
: visibleFlatTags
if (!normalizedQuery) {
return visibleTags
}
return allSelectableTags.filter((tag) =>
tag.searchableText.toLowerCase().includes(normalizedQuery)
)
}, [allSelectableTags, normalizedQuery, props.mode, rootTag, visibleFlatTags])
const singleSelected = props.mode === "single"
? allSelectableTags.find((tag) => tag.id === props.value)
: null
const triggerLabel =
triggerText ??
(props.mode === "multiple"
? selectedTags.length > 0
? selectedCountText?.(selectedTags.length) ?? `${placeholder} (${selectedTags.length})`
: placeholder
: singleSelected?.path ?? placeholder)
function handleSelect(tagId: number) {
if (props.mode === "single") {
props.onChange(tagId)
setOpen(false)
return
}
const selectedIds = new Set(props.value ?? [])
if (selectedIds.has(tagId)) {
props.onChange((props.value ?? []).filter((item) => item !== tagId))
return
}
props.onChange([...(props.value ?? []), tagId])
}
function toggleCollapsed(tagId: number) {
setCollapsedIds((current) => {
const next = new Set(current)
if (next.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
return next
})
}
return (
<div className={className}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant={triggerVariant}
size={triggerSize}
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-normal", triggerClassName)}
/>
}
>
<span className="flex min-w-0 items-center gap-2">
<TagIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{triggerLabel}</span>
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent
align={align}
className={cn("w-(--radix-popover-trigger-width) min-w-72 p-0", contentClassName)}
>
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={searchPlaceholder}
/>
<CommandList>
{loading ? <CommandEmpty>{loadingText}</CommandEmpty> : null}
{!loading && visibleSelectableTags.length === 0 ? (
<CommandEmpty>{emptyText}</CommandEmpty>
) : null}
{!loading ? (
<CommandGroup>
{visibleSelectableTags.map((tag) => {
const checked = isSelected(props, tag.id)
const pending = pendingTagId === tag.id
const hasChildren = tag.children.length > 0
const collapsed = collapsedIds.has(tag.id)
return (
<CommandItem
key={tag.id}
value={tag.searchableText}
disabled={disabled || pendingTagId !== null}
onSelect={() => handleSelect(tag.id)}
className={cn(
props.mode === "single" &&
checked &&
"bg-muted text-foreground"
)}
>
<div
className="flex min-w-0 flex-1 items-center gap-1.5"
style={{ paddingLeft: `${tag.depth * 14}px` }}
title={tag.path}
>
{hasChildren && !normalizedQuery ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-5 shrink-0"
aria-label={collapsed ? t("common.expand") : t("common.collapse")}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleCollapsed(tag.id)
}}
>
<ChevronRightIcon
className={cn(
"size-3.5 transition-transform",
!collapsed && "rotate-90"
)}
/>
</Button>
) : (
<span className="size-5 shrink-0" />
)}
{pending ? (
<Loader2Icon className="size-4 shrink-0 animate-spin" />
) : props.mode === "multiple" ? (
<Checkbox
checked={checked}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
) : (
<span className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{tag.name}</span>
</div>
</CommandItem>
)
})}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showSelectedBadges && props.mode === "multiple" && selectedTags.length > 0 ? (
<TagBadges ids={props.value ?? []} tags={tags} className="mt-2" />
) : null}
</div>
)
}
type TagBadgesProps = {
ids?: number[]
tags: TagTree[]
fallbackTags?: Array<{ id: number; name: string }>
className?: string
}
export function TagBadges({
ids,
tags,
fallbackTags = [],
className,
}: TagBadgesProps) {
const tagPathMap = buildTagPathMap(tags)
const fallbackMap = new Map(fallbackTags.map((tag) => [tag.id, tag.name]))
const safeIds = ids ?? []
if (safeIds.length === 0) {
return null
}
return (
<div className={cn("flex flex-wrap items-center gap-1.5", className)}>
{safeIds.map((id) => (
<Badge
key={id}
variant="outline"
className="max-w-full px-2 text-[12px] font-normal"
>
<span className="break-all">{tagPathMap.get(id) ?? fallbackMap.get(id) ?? `#${id}`}</span>
</Badge>
))}
</div>
)
}

View File

@@ -0,0 +1,17 @@
"use client"
import type { ReactNode } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}

View File

@@ -0,0 +1,95 @@
"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"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
type ThemeMode = "light" | "dark" | "system"
type ThemeToggleButtonVariant = "outline" | "ghost"
type ThemeToggleButtonSize = "sm" | "icon-sm"
const themeOptions: Array<{
value: ThemeMode
labelKey: string
icon: typeof SunIcon
}> = [
{ value: "system", labelKey: "theme.system", icon: LaptopIcon },
{ value: "dark", labelKey: "theme.dark", icon: MoonIcon },
{ value: "light", labelKey: "theme.light", icon: SunIcon },
]
export function ThemeToggle({
variant = "outline",
size = "sm",
className,
}: {
variant?: ThemeToggleButtonVariant
size?: ThemeToggleButtonSize
className?: string
}) {
const t = useI18n()
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 modal={false}>
<DropdownMenuTrigger
render={
<Button
variant={variant}
size={size}
className={cn(
"rounded-md text-muted-foreground hover:text-foreground",
className
)}
/>
}
aria-label={t("theme.toggle")}
>
<ActiveIcon />
</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}
className="h-8 px-2"
>
<Icon />
{t(option.labelKey)}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,128 @@
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 { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
),
},
props
),
render,
state: {
slot: "bubble-content",
},
})
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }

View File

@@ -0,0 +1,87 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

Some files were not shown because too many files have changed in this diff Show More