picoclaw/web/frontend/src/components/config/raw-config-page.tsx
wenjie 2e222c2bc0 refactor(web): redesign config pages and extract raw JSON editor
- reorganize visual config into card-based setting sections
- move the raw config editor into a dedicated full-page view
- simplify shared form layouts and remove unused locale strings
2026-03-12 18:08:13 +08:00

207 lines
6.7 KiB
TypeScript

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>
)
}