chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
38
web/components/agent-realtime-provider.test.mjs
Normal file
38
web/components/agent-realtime-provider.test.mjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
9
web/components/agent-realtime-provider.tsx
Normal file
9
web/components/agent-realtime-provider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime"
|
||||
|
||||
export function AgentRealtimeProvider() {
|
||||
useAgentConversationRealtime()
|
||||
|
||||
return null
|
||||
}
|
||||
45
web/components/api-error-provider.tsx
Normal file
45
web/components/api-error-provider.tsx
Normal 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)
|
||||
}
|
||||
77
web/components/app-sidebar.tsx
Normal file
77
web/components/app-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
web/components/auth-provider.test.mjs
Normal file
15
web/components/auth-provider.test.mjs
Normal 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\(\)/);
|
||||
});
|
||||
76
web/components/auth-provider.tsx
Normal file
76
web/components/auth-provider.tsx
Normal 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
|
||||
}
|
||||
155
web/components/change-password-dialog.tsx
Normal file
155
web/components/change-password-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
web/components/chart-area-interactive.tsx
Normal file
159
web/components/chart-area-interactive.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
web/components/chat/agent-activity-bubble.tsx
Normal file
54
web/components/chat/agent-activity-bubble.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
64
web/components/chat/conversation-message-bubble.tsx
Normal file
64
web/components/chat/conversation-message-bubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
web/components/chat/conversation-message-row.tsx
Normal file
109
web/components/chat/conversation-message-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
web/components/chat/conversation-message-scroller.tsx
Normal file
151
web/components/chat/conversation-message-scroller.tsx
Normal 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 }
|
||||
729
web/components/chat/shared-message-editor.tsx
Normal file
729
web/components/chat/shared-message-editor.tsx
Normal 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
|
||||
}
|
||||
289
web/components/company-picker.tsx
Normal file
289
web/components/company-picker.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
122
web/components/confirm-provider.tsx
Normal file
122
web/components/confirm-provider.tsx
Normal 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 }
|
||||
26
web/components/content-editor/convert.ts
Normal file
26
web/components/content-editor/convert.ts
Normal 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 ?? "")
|
||||
}
|
||||
73
web/components/content-editor/editor-mode-switch.tsx
Normal file
73
web/components/content-editor/editor-mode-switch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
376
web/components/content-editor/html-editor.tsx
Normal file
376
web/components/content-editor/html-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
195
web/components/content-editor/index.tsx
Normal file
195
web/components/content-editor/index.tsx
Normal 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"
|
||||
48
web/components/content-editor/markdown-editor.css
Normal file
48
web/components/content-editor/markdown-editor.css
Normal 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;
|
||||
}
|
||||
179
web/components/content-editor/markdown-editor.tsx
Normal file
179
web/components/content-editor/markdown-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
20
web/components/content-editor/toolbar.css
Normal file
20
web/components/content-editor/toolbar.css
Normal 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);
|
||||
}
|
||||
142
web/components/content-editor/toolbar.tsx
Normal file
142
web/components/content-editor/toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
web/components/content-editor/types.ts
Normal file
44
web/components/content-editor/types.ts
Normal 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
|
||||
205
web/components/conversation-actions/close-dialog.tsx
Normal file
205
web/components/conversation-actions/close-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
254
web/components/conversation-actions/transfer-dialog.tsx
Normal file
254
web/components/conversation-actions/transfer-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
web/components/customer-form-dialog.tsx
Normal file
90
web/components/customer-form-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
490
web/components/customer-form.tsx
Normal file
490
web/components/customer-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
279
web/components/customer-link-or-create-dialog.tsx
Normal file
279
web/components/customer-link-or-create-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
web/components/dashboard-page.tsx
Normal file
100
web/components/dashboard-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
web/components/dashboard-placeholder.tsx
Normal file
73
web/components/dashboard-placeholder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
321
web/components/dashboard/crud/dashboard-crud-field-control.tsx
Normal file
321
web/components/dashboard/crud/dashboard-crud-field-control.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
295
web/components/dashboard/crud/dashboard-crud-form-dialog.tsx
Normal file
295
web/components/dashboard/crud/dashboard-crud-form-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
741
web/components/dashboard/crud/dashboard-crud-page.tsx
Normal file
741
web/components/dashboard/crud/dashboard-crud-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
web/components/dashboard/crud/dashboard-crud-presets.tsx
Normal file
130
web/components/dashboard/crud/dashboard-crud-presets.tsx
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
250
web/components/dashboard/crud/dashboard-crud-utils.test.mjs
Normal file
250
web/components/dashboard/crud/dashboard-crud-utils.test.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
262
web/components/dashboard/crud/dashboard-crud-utils.ts
Normal file
262
web/components/dashboard/crud/dashboard-crud-utils.ts
Normal 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
|
||||
}
|
||||
30
web/components/dashboard/crud/index.ts
Normal file
30
web/components/dashboard/crud/index.ts
Normal 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"
|
||||
65
web/components/dashboard/crud/use-dashboard-crud-filters.ts
Normal file
65
web/components/dashboard/crud/use-dashboard-crud-filters.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
290
web/components/dashboard/list/dashboard-list-page.tsx
Normal file
290
web/components/dashboard/list/dashboard-list-page.tsx
Normal 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>
|
||||
}
|
||||
12
web/components/dashboard/list/index.ts
Normal file
12
web/components/dashboard/list/index.ts
Normal 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"
|
||||
136
web/components/dashboard/list/use-dashboard-paged-list.ts
Normal file
136
web/components/dashboard/list/use-dashboard-paged-list.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
146
web/components/data-table.tsx
Normal file
146
web/components/data-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
235
web/components/editor/DESIGN.md
Normal file
235
web/components/editor/DESIGN.md
Normal 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 编辑器可在下一阶段迁移到同一核心配置,避免破坏现有发送交互。
|
||||
|
||||
150
web/components/editor/html.tsx
Normal file
150
web/components/editor/html.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
web/components/editor/index.tsx
Normal file
49
web/components/editor/index.tsx
Normal 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"
|
||||
|
||||
172
web/components/editor/markdown.tsx
Normal file
172
web/components/editor/markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
web/components/editor/toolbar.tsx
Normal file
63
web/components/editor/toolbar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
|
||||
type EditorToolbarButtonAction = {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
pressed?: boolean
|
||||
}
|
||||
|
||||
type EditorToolbarSeparatorAction = {
|
||||
key: string
|
||||
type: "separator"
|
||||
}
|
||||
|
||||
export type EditorToolbarAction = EditorToolbarButtonAction | EditorToolbarSeparatorAction
|
||||
|
||||
type EditorToolbarProps = {
|
||||
actions: ReadonlyArray<EditorToolbarAction>
|
||||
}
|
||||
|
||||
function isSeparatorAction(
|
||||
action: EditorToolbarAction
|
||||
): action is EditorToolbarSeparatorAction {
|
||||
return "type" in action && action.type === "separator"
|
||||
}
|
||||
|
||||
export function EditorToolbar({ actions }: EditorToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-b p-2">
|
||||
<ToggleGroup className="flex-wrap gap-1">
|
||||
{actions.map((action) => {
|
||||
if (isSeparatorAction(action)) {
|
||||
return <Separator key={action.key} orientation="vertical" className="mx-1 h-6" />
|
||||
}
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
key={action.key}
|
||||
value={action.key}
|
||||
aria-label={action.label}
|
||||
disabled={action.disabled}
|
||||
pressed={action.pressed}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
)
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
21
web/components/editor/types.ts
Normal file
21
web/components/editor/types.ts
Normal 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
|
||||
}
|
||||
|
||||
86
web/components/im-message-html.tsx
Normal file
86
web/components/im-message-html.tsx
Normal 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
|
||||
)
|
||||
153
web/components/image-input.tsx
Normal file
153
web/components/image-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
521
web/components/image-lightbox.tsx
Normal file
521
web/components/image-lightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
web/components/json-code-editor.tsx
Normal file
77
web/components/json-code-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
web/components/json-tree-viewer.tsx
Normal file
70
web/components/json-tree-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
web/components/json-viewer.tsx
Normal file
79
web/components/json-viewer.tsx
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
}
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return ""
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function highlightJson(value: string) {
|
||||
const escaped = escapeHtml(value)
|
||||
return escaped.replace(
|
||||
/("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match) => {
|
||||
let className = "text-slate-200"
|
||||
if (match.startsWith('"')) {
|
||||
className = match.endsWith(":") ? "text-sky-300" : "text-emerald-300"
|
||||
} else if (match === "true" || match === "false") {
|
||||
className = "text-amber-300"
|
||||
} else if (match === "null") {
|
||||
className = "text-rose-300"
|
||||
} else {
|
||||
className = "text-violet-300"
|
||||
}
|
||||
return `<span class="${className}">${match}</span>`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function JsonViewer({
|
||||
value,
|
||||
emptyText,
|
||||
className,
|
||||
}: JsonViewerProps) {
|
||||
const 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) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
110
web/components/legal-document-page.tsx
Normal file
110
web/components/legal-document-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
web/components/list-pagination.tsx
Normal file
90
web/components/list-pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
web/components/load-more.tsx
Normal file
234
web/components/load-more.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
279
web/components/login-form.tsx
Normal file
279
web/components/login-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
web/components/nav-documents.tsx
Normal file
97
web/components/nav-documents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
web/components/nav-main.test.mjs
Normal file
20
web/components/nav-main.test.mjs
Normal 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
160
web/components/nav-main.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
web/components/nav-secondary.tsx
Normal file
53
web/components/nav-secondary.tsx
Normal 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
155
web/components/nav-user.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
164
web/components/notification-provider.tsx
Normal file
164
web/components/notification-provider.tsx
Normal 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
|
||||
}
|
||||
283
web/components/operator-readiness-indicator.tsx
Normal file
283
web/components/operator-readiness-indicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
web/components/option-combobox.tsx
Normal file
192
web/components/option-combobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
web/components/palette-toggle.test.mjs
Normal file
28
web/components/palette-toggle.test.mjs
Normal 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")
|
||||
})
|
||||
113
web/components/palette-toggle.tsx
Normal file
113
web/components/palette-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
web/components/project-dialog.tsx
Normal file
178
web/components/project-dialog.tsx
Normal 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 };
|
||||
64
web/components/realtime-connection-status.tsx
Normal file
64
web/components/realtime-connection-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
web/components/safe-rich-html.tsx
Normal file
183
web/components/safe-rich-html.tsx
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
111
web/components/section-cards.tsx
Normal file
111
web/components/section-cards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
web/components/session-provider.tsx
Normal file
110
web/components/session-provider.tsx
Normal 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
|
||||
}
|
||||
14
web/components/site-header.test.mjs
Normal file
14
web/components/site-header.test.mjs
Normal 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 \/>/);
|
||||
});
|
||||
});
|
||||
88
web/components/site-header.tsx
Normal file
88
web/components/site-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
web/components/support-chat/chat-error-boundary.tsx
Normal file
51
web/components/support-chat/chat-error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
439
web/components/support-chat/chat-shell.tsx
Normal file
439
web/components/support-chat/chat-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
web/components/support-chat/close-navigation.test.mjs
Normal file
29
web/components/support-chat/close-navigation.test.mjs
Normal 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")
|
||||
})
|
||||
5
web/components/support-chat/close-navigation.ts
Normal file
5
web/components/support-chat/close-navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
|
||||
|
||||
export function getStandaloneClosedUrl() {
|
||||
return SUPPORT_CHAT_CLOSED_PATH
|
||||
}
|
||||
38
web/components/support-chat/connection-status.tsx
Normal file
38
web/components/support-chat/connection-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
web/components/support-chat/conversation-switcher.test.mjs
Normal file
21
web/components/support-chat/conversation-switcher.test.mjs
Normal 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>/
|
||||
)
|
||||
})
|
||||
})
|
||||
262
web/components/support-chat/conversation-switcher.tsx
Normal file
262
web/components/support-chat/conversation-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
web/components/support-chat/csat-card.tsx
Normal file
228
web/components/support-chat/csat-card.tsx
Normal 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">
|
||||
“{satisfaction.feedback}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
34
web/components/support-chat/customer-message-editor.tsx
Normal file
34
web/components/support-chat/customer-message-editor.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
web/components/support-chat/demo-navigation.test.mjs
Normal file
29
web/components/support-chat/demo-navigation.test.mjs
Normal 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")
|
||||
})
|
||||
5
web/components/support-chat/demo-navigation.ts
Normal file
5
web/components/support-chat/demo-navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SUPPORT_DEMO_PATH = "/support/demo"
|
||||
|
||||
export function getWidgetDemoPath() {
|
||||
return SUPPORT_DEMO_PATH
|
||||
}
|
||||
335
web/components/support-chat/message-list.tsx
Normal file
335
web/components/support-chat/message-list.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
171
web/components/support-chat/new-request-dialog.tsx
Normal file
171
web/components/support-chat/new-request-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
web/components/support-chat/prompt-starters.tsx
Normal file
51
web/components/support-chat/prompt-starters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
466
web/components/support-chat/widget-demo.tsx
Normal file
466
web/components/support-chat/widget-demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
355
web/components/tag-selector.tsx
Normal file
355
web/components/tag-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/components/theme-provider.tsx
Normal file
17
web/components/theme-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
web/components/theme-toggle.tsx
Normal file
95
web/components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
web/components/ui/avatar.tsx
Normal file
109
web/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
web/components/ui/badge.tsx
Normal file
52
web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
125
web/components/ui/breadcrumb.tsx
Normal file
125
web/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
128
web/components/ui/bubble.tsx
Normal file
128
web/components/ui/bubble.tsx
Normal 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 }
|
||||
87
web/components/ui/button-group.tsx
Normal file
87
web/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||
vertical:
|
||||
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "button-group-text",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
60
web/components/ui/button.tsx
Normal file
60
web/components/ui/button.tsx
Normal 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
Reference in New Issue
Block a user