mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge pull request #1421 from sipeed/refactor/config-ui
refactor(web): redesign config pages and extract raw JSON editor
This commit is contained in:
@@ -13,7 +13,6 @@ import {
|
||||
setLauncherConfig as updateLauncherConfig,
|
||||
} from "@/api/system"
|
||||
import {
|
||||
AdvancedSection,
|
||||
AgentDefaultsSection,
|
||||
DevicesSection,
|
||||
LauncherSection,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
} from "@/components/config/form-model"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -56,11 +54,7 @@ export function ConfigPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: launcherConfig,
|
||||
isLoading: isLauncherLoading,
|
||||
error: launcherError,
|
||||
} = useQuery({
|
||||
const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({
|
||||
queryKey: ["system", "launcher-config"],
|
||||
queryFn: getLauncherConfig,
|
||||
})
|
||||
@@ -111,10 +105,6 @@ export function ConfigPage() {
|
||||
? t("pages.config.autostart_unsupported")
|
||||
: t("pages.config.autostart_hint")
|
||||
|
||||
const launcherHint = launcherError
|
||||
? t("pages.config.launcher_load_error")
|
||||
: t("pages.config.launcher_restart_hint")
|
||||
|
||||
const updateField = <K extends keyof CoreConfigForm>(
|
||||
key: K,
|
||||
value: CoreConfigForm[K],
|
||||
@@ -287,21 +277,14 @@ export function ConfigPage() {
|
||||
|
||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<LauncherSection
|
||||
launcherForm={launcherForm}
|
||||
onFieldChange={updateLauncherField}
|
||||
launcherHint={launcherHint}
|
||||
disabled={saving || isLauncherLoading}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DevicesSection
|
||||
form={form}
|
||||
onFieldChange={updateField}
|
||||
@@ -316,10 +299,6 @@ export function ConfigPage() {
|
||||
onAutoStartChange={setAutoStartEnabled}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<AdvancedSection />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IconCode } from "@tabler/icons-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
@@ -8,7 +7,13 @@ import {
|
||||
type LauncherForm,
|
||||
} from "@/components/config/form-model"
|
||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
@@ -29,6 +34,30 @@ type UpdateLauncherField = <K extends keyof LauncherForm>(
|
||||
value: LauncherForm[K],
|
||||
) => void
|
||||
|
||||
interface ConfigSectionCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function ConfigSectionCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: ConfigSectionCardProps) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader className="border-border border-b">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="divide-border/70 divide-y">{children}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentDefaultsSectionProps {
|
||||
form: CoreConfigForm
|
||||
onFieldChange: UpdateCoreField
|
||||
@@ -41,89 +70,94 @@ export function AgentDefaultsSection({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.workspace")}
|
||||
hint={t("pages.config.workspace_hint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||
placeholder="~/.picoclaw/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<ConfigSectionCard title={t("pages.config.sections.agent")}>
|
||||
<Field
|
||||
label={t("pages.config.workspace")}
|
||||
hint={t("pages.config.workspace_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||
placeholder="~/.picoclaw/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.restrict_workspace")}
|
||||
hint={t("pages.config.restrict_workspace_hint")}
|
||||
checked={form.restrictToWorkspace}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("restrictToWorkspace", checked)
|
||||
<SwitchCardField
|
||||
label={t("pages.config.restrict_workspace")}
|
||||
hint={t("pages.config.restrict_workspace_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.restrictToWorkspace}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("restrictToWorkspace", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.allow_remote")}
|
||||
hint={t("pages.config.allow_remote_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.allowRemote}
|
||||
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tokens")}
|
||||
hint={t("pages.config.max_tokens_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tool_iterations")}
|
||||
hint={t("pages.config.max_tool_iterations_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxToolIterations}
|
||||
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_threshold")}
|
||||
hint={t("pages.config.summarize_threshold_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.summarizeMessageThreshold}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.allow_remote")}
|
||||
hint={t("pages.config.allow_remote_hint")}
|
||||
checked={form.allowRemote}
|
||||
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
||||
<Field
|
||||
label={t("pages.config.summarize_token_percent")}
|
||||
hint={t("pages.config.summarize_token_percent_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.summarizeTokenPercent}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tokens")}
|
||||
hint={t("pages.config.max_tokens_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tool_iterations")}
|
||||
hint={t("pages.config.max_tool_iterations_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxToolIterations}
|
||||
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_threshold")}
|
||||
hint={t("pages.config.summarize_threshold_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.summarizeMessageThreshold}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.summarize_token_percent")}
|
||||
hint={t("pages.config.summarize_token_percent_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.summarizeTokenPercent}
|
||||
onChange={(e) =>
|
||||
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</Field>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,126 +173,123 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.session_scope")}
|
||||
hint={t("pages.config.session_scope_hint")}
|
||||
<ConfigSectionCard title={t("pages.config.sections.runtime")}>
|
||||
<Field
|
||||
label={t("pages.config.session_scope")}
|
||||
hint={t("pages.config.session_scope_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Select
|
||||
value={form.dmScope}
|
||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||
>
|
||||
<Select
|
||||
value={form.dmScope}
|
||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{selectedDmScopeOption
|
||||
? t(
|
||||
selectedDmScopeOption.labelKey,
|
||||
selectedDmScopeOption.labelDefault,
|
||||
)
|
||||
: form.dmScope}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||
<SelectItem key={scope.value} value={scope.value}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{t(scope.labelKey)}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(scope.descKey)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{selectedDmScopeOption
|
||||
? t(
|
||||
selectedDmScopeOption.labelKey,
|
||||
selectedDmScopeOption.labelDefault,
|
||||
)
|
||||
: form.dmScope}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||
<SelectItem key={scope.value} value={scope.value}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{t(scope.labelKey)}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(scope.descKey)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.heartbeat_enabled")}
|
||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.heartbeatEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("heartbeatEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{form.heartbeatEnabled && (
|
||||
<Field
|
||||
label={t("pages.config.heartbeat_interval")}
|
||||
hint={t("pages.config.heartbeat_interval_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.heartbeatInterval}
|
||||
onChange={(e) => onFieldChange("heartbeatInterval", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.heartbeat_enabled")}
|
||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||
checked={form.heartbeatEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("heartbeatEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{form.heartbeatEnabled && (
|
||||
<Field
|
||||
label={t("pages.config.heartbeat_interval")}
|
||||
hint={t("pages.config.heartbeat_interval_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.heartbeatInterval}
|
||||
onChange={(e) =>
|
||||
onFieldChange("heartbeatInterval", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface LauncherSectionProps {
|
||||
launcherForm: LauncherForm
|
||||
onFieldChange: UpdateLauncherField
|
||||
launcherHint: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function LauncherSection({
|
||||
launcherForm,
|
||||
onFieldChange,
|
||||
launcherHint,
|
||||
disabled,
|
||||
}: LauncherSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={t("pages.config.server_port")}
|
||||
hint={t("pages.config.server_port_hint")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={launcherForm.port}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<ConfigSectionCard title={t("pages.config.sections.launcher")}>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
layout="setting-row"
|
||||
checked={launcherForm.publicAccess}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.lan_access")}
|
||||
hint={t("pages.config.lan_access_hint")}
|
||||
checked={launcherForm.publicAccess}
|
||||
<Field
|
||||
label={t("pages.config.server_port")}
|
||||
hint={t("pages.config.server_port_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={launcherForm.port}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.allowed_cidrs")}
|
||||
hint={t("pages.config.allowed_cidrs_hint")}
|
||||
>
|
||||
<Textarea
|
||||
value={launcherForm.allowedCIDRsText}
|
||||
disabled={disabled}
|
||||
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
||||
className="min-h-[88px]"
|
||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
||||
</div>
|
||||
</section>
|
||||
<Field
|
||||
label={t("pages.config.allowed_cidrs")}
|
||||
hint={t("pages.config.allowed_cidrs_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-md"
|
||||
>
|
||||
<Textarea
|
||||
value={launcherForm.allowedCIDRsText}
|
||||
disabled={disabled}
|
||||
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
||||
className="min-h-[88px]"
|
||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -282,52 +313,31 @@ export function DevicesSection({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<SwitchCardField
|
||||
label={t("pages.config.devices_enabled")}
|
||||
hint={t("pages.config.devices_enabled_hint")}
|
||||
checked={form.devicesEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("devicesEnabled", checked)
|
||||
}
|
||||
/>
|
||||
<ConfigSectionCard title={t("pages.config.sections.devices")}>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.devices_enabled")}
|
||||
hint={t("pages.config.devices_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.devicesEnabled}
|
||||
onCheckedChange={(checked) => onFieldChange("devicesEnabled", checked)}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.monitor_usb")}
|
||||
hint={t("pages.config.monitor_usb_hint")}
|
||||
checked={form.monitorUSB}
|
||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||
/>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.monitor_usb")}
|
||||
hint={t("pages.config.monitor_usb_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.monitorUSB}
|
||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.autostart_label")}
|
||||
hint={autoStartHint}
|
||||
checked={autoStartEnabled}
|
||||
disabled={autoStartDisabled}
|
||||
onCheckedChange={onAutoStartChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdvancedSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("pages.config.advanced_desc")}
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/config/raw">
|
||||
<IconCode className="size-4" />
|
||||
{t("pages.config.open_raw")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.autostart_label")}
|
||||
hint={autoStartHint}
|
||||
layout="setting-row"
|
||||
checked={autoStartEnabled}
|
||||
disabled={autoStartDisabled}
|
||||
onCheckedChange={onAutoStartChange}
|
||||
/>
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { IconAdjustments } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function RawConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch config")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (newConfig: string) => {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: newConfig,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save config")
|
||||
}
|
||||
},
|
||||
onSuccess: (_, submittedConfig) => {
|
||||
toast.success(t("pages.config.save_success"))
|
||||
try {
|
||||
const savedConfig = JSON.parse(submittedConfig)
|
||||
setLastSavedConfig(savedConfig)
|
||||
setIsDirty(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
} catch {
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("pages.config.save_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const [editorValue, setEditorValue] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null)
|
||||
|
||||
const effectiveEditorValue =
|
||||
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
JSON.parse(effectiveEditorValue)
|
||||
mutation.mutate(effectiveEditorValue)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.invalid_json",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(effectiveEditorValue),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
setEditorValue(formatted)
|
||||
toast.success(t("pages.config.format_success"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.format_error",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||
|
||||
const confirmReset = () => {
|
||||
if (lastSavedConfig) {
|
||||
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
||||
} else if (config) {
|
||||
setEditorValue(JSON.stringify(config, null, 2))
|
||||
}
|
||||
setIsDirty(false)
|
||||
toast.info(t("pages.config.reset_success"))
|
||||
setShowResetDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("pages.config.raw_json_title")}>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/config">
|
||||
<IconAdjustments className="size-4" />
|
||||
{t("pages.config.back_to_visual")}
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col p-1 lg:p-3 lg:p-6">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1000px] flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p>{t("labels.loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{isDirty && (
|
||||
<div className="shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
|
||||
<Textarea
|
||||
value={effectiveEditorValue}
|
||||
onChange={(e) => {
|
||||
setEditorValue(e.target.value)
|
||||
setIsDirty(true)
|
||||
}}
|
||||
wrap="off"
|
||||
className="h-full min-h-0 resize-none overflow-auto border-0 bg-transparent px-4 py-3 font-mono text-sm [overflow-wrap:normal] whitespace-pre shadow-none focus-visible:ring-0"
|
||||
placeholder={t("pages.config.json_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormat}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{t("pages.config.format")}
|
||||
</Button>
|
||||
<AlertDialog
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isDirty}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("pages.config.reset_confirm_title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("pages.config.reset_confirm_desc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>
|
||||
{t("common.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function RawJsonPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/config")
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch config")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (newConfig: string) => {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: newConfig,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save config")
|
||||
}
|
||||
},
|
||||
onSuccess: (_, submittedConfig) => {
|
||||
toast.success(t("pages.config.save_success"))
|
||||
try {
|
||||
const savedConfig = JSON.parse(submittedConfig)
|
||||
setLastSavedConfig(savedConfig)
|
||||
setIsDirty(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
} catch {
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("pages.config.save_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const [editorValue, setEditorValue] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null)
|
||||
|
||||
const effectiveEditorValue =
|
||||
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
JSON.parse(effectiveEditorValue)
|
||||
mutation.mutate(effectiveEditorValue)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.invalid_json",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(effectiveEditorValue),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
setEditorValue(formatted)
|
||||
toast.success(t("pages.config.format_success"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"pages.config.format_error",
|
||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||
|
||||
const confirmReset = () => {
|
||||
if (lastSavedConfig) {
|
||||
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
||||
} else if (config) {
|
||||
setEditorValue(JSON.stringify(config, null, 2))
|
||||
}
|
||||
setIsDirty(false)
|
||||
toast.info(t("pages.config.reset_success"))
|
||||
setShowResetDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
|
||||
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p>{t("labels.loading")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isDirty && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||
{t("pages.config.unsaved_changes")}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted/30 relative rounded-lg border">
|
||||
<Textarea
|
||||
value={effectiveEditorValue}
|
||||
onChange={(e) => {
|
||||
setEditorValue(e.target.value)
|
||||
setIsDirty(true)
|
||||
}}
|
||||
wrap="off"
|
||||
className="min-h-[200px] resize-none overflow-x-auto border-0 bg-transparent px-4 py-3 font-mono text-sm whitespace-pre shadow-none [overflow-wrap:normal] focus-visible:ring-0"
|
||||
placeholder={t("pages.config.json_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormat}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{t("pages.config.format")}
|
||||
</Button>
|
||||
<AlertDialog
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isDirty}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("pages.config.reset_confirm_title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("pages.config.reset_confirm_desc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>
|
||||
{t("common.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type FieldLayout = "default" | "setting-row"
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
@@ -16,9 +19,45 @@ interface FieldProps {
|
||||
error?: string
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
layout?: FieldLayout
|
||||
controlClassName?: string
|
||||
}
|
||||
|
||||
export function Field({ label, hint, error, required, children }: FieldProps) {
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
children,
|
||||
layout = "default",
|
||||
controlClassName,
|
||||
}: FieldProps) {
|
||||
if (layout === "setting-row") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
|
||||
<div className="space-y-1">
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</FieldLabel>
|
||||
{hint && (
|
||||
<FieldDescription className="text-xs leading-normal">
|
||||
{hint}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("w-full md:justify-self-center", controlClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
{error && (
|
||||
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
|
||||
{error}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UiField className="gap-2.5">
|
||||
<div className="space-y-1">
|
||||
@@ -85,6 +124,7 @@ interface SwitchCardFieldProps {
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
layout?: FieldLayout
|
||||
}
|
||||
|
||||
export function SwitchCardField({
|
||||
@@ -96,7 +136,37 @@ export function SwitchCardField({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
children,
|
||||
layout = "default",
|
||||
}: SwitchCardFieldProps) {
|
||||
if (layout === "setting-row") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hint && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center md:justify-self-center">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
</div>
|
||||
{children && <div className="md:col-start-2">{children}</div>}
|
||||
{error && (
|
||||
<p className="text-destructive text-xs leading-normal md:col-start-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
Reference in New Issue
Block a user