Files
3x-ui-agent/frontend/src/pages/settings/PublicPageTab.tsx
T

909 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Switch,
Tag,
Typography,
message,
} from 'antd';
import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
// Item 7 B5: the public status page's settings + theme store. Like
// the probe-meta section this surface is self-contained — it loads
// and saves through its own endpoints and every change takes effect
// immediately (no panel save, no restart). Theme packages are static
// files served by the panel; installing one never runs its code here.
interface ThemeConfigField {
key: string;
type: string;
label: string;
default?: unknown;
help?: string;
options?: { value: string; label: string }[];
}
interface ThemeWire {
name: string;
short: string;
description: string;
version: string;
author: string;
url: string;
config?: ThemeConfigField[];
selected: boolean;
builtin: boolean;
preview: boolean;
}
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
}
interface ThemesPayload {
themes: ThemeWire[];
settings: ProbeSettingsWire;
favicon: string;
touchIcon: string;
}
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
// The webhook URL and headers are secrets: the GET answers only
// whether they are set, and the PUT omits them unless the admin
// edited the field (an explicit clear sends "").
interface NotifySettingsWire {
notifyGrace: number;
notifyTraffic: number;
notifyExpiry: number;
notifyLogin: boolean;
notifyTelegramText: string;
notifyWebhookBody: string;
telegramConfigured: boolean;
webhookConfigured: boolean;
notifyWebhookUrlSet: boolean;
notifyWebhookHeadersSet: boolean;
}
interface CoverageRow {
name: string;
online: boolean;
hasProbe: boolean;
probeAt: number;
}
interface NotifyPayload {
settings: NotifySettingsWire;
coverage: CoverageRow[];
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const UPLOAD_CHUNK = 1 << 20;
const MAX_ICON_BYTES = 20 * 1024;
function basePathPrefix(): string {
const bp = window.X_UI_BASE_PATH;
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
}
function readAsDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
// icon budget: small files pass through untouched (the server judges
// the bytes, not the declared type); a large raster is scaled down
// until it fits; a large SVG cannot be scaled and is refused here.
async function fileToIconDataURL(file: File): Promise<string> {
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
throw new Error('tooLarge');
}
const bitmap = await createImageBitmap(file);
try {
for (const size of [256, 180, 128, 96, 64]) {
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) break;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
}
} finally {
bitmap.close();
}
throw new Error('tooLarge');
}
export default function PublicPageTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [themes, setThemes] = useState<ThemeWire[]>([]);
const [settings, setSettings] = useState<ProbeSettingsWire>({
publicPage: false,
siteName: 'Monitor',
site: '',
githubProxy: '',
});
const [favicon, setFavicon] = useState('');
const [touchIcon, setTouchIcon] = useState('');
const [saving, setSaving] = useState(false);
const [installURL, setInstallURL] = useState('');
const [installing, setInstalling] = useState(false);
const [uploadPct, setUploadPct] = useState<number | null>(null);
const [busyShort, setBusyShort] = useState('');
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
const [coverage, setCoverage] = useState<CoverageRow[]>([]);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookHeaders, setWebhookHeaders] = useState('');
const [urlTouched, setUrlTouched] = useState(false);
const [headersTouched, setHeadersTouched] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const applyPayload = useCallback((payload: ThemesPayload) => {
setThemes(payload.themes ?? []);
setSettings(payload.settings);
setFavicon(payload.favicon ?? '');
setTouchIcon(payload.touchIcon ?? '');
}, []);
const reload = useCallback(async () => {
const msg = await HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true });
if (msg?.success && msg.obj) applyPayload(msg.obj);
}, [applyPayload]);
useEffect(() => {
let cancelled = false;
HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) applyPayload(msg.obj);
});
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
}
});
return () => {
cancelled = true;
};
}, [applyPayload]);
async function saveNotify() {
if (!notify) return;
setNotifySaving(true);
try {
const body: Record<string, unknown> = {
notifyGrace: notify.notifyGrace,
notifyTraffic: notify.notifyTraffic,
notifyExpiry: notify.notifyExpiry,
notifyLogin: notify.notifyLogin,
notifyTelegramText: notify.notifyTelegramText,
notifyWebhookBody: notify.notifyWebhookBody,
};
// Secrets ride along only when the field was edited; an
// edited-but-empty field clears the stored value server-side.
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
setWebhookUrl('');
setWebhookHeaders('');
setUrlTouched(false);
setHeadersTouched(false);
messageApi.success(t('pages.settings.probeThemes.saved'));
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setNotifySaving(false);
}
}
async function testNotify() {
setNotifyTesting(true);
try {
const msg = await HttpUtil.post<{ sent: string[] }>('/panel/api/probe/notify/test', undefined, {
silent: true,
});
if (msg?.success) {
messageApi.success(
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
);
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setNotifyTesting(false);
}
}
async function saveSettings() {
setSaving(true);
try {
const msg = await HttpUtil.put(
'/panel/api/probe/settings',
{ ...settings, favicon, touchIcon },
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
} finally {
setSaving(false);
}
}
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
if (!file) return;
try {
const url = await fileToIconDataURL(file);
if (which === 'favicon') setFavicon(url);
else setTouchIcon(url);
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
async function uploadTheme(file: File | undefined) {
if (!file) return;
if (file.size === 0) {
messageApi.error(t('pages.settings.probeThemes.uploadFailed'));
return;
}
setUploadPct(0);
try {
let offset = 0;
while (offset < file.size) {
const slice = file.slice(offset, offset + UPLOAD_CHUNK);
const msg = await HttpUtil.post<{ received?: number; theme?: ThemeWire }>(
'/panel/api/probe/theme-upload',
slice,
{
silent: true,
params: { offset, total: file.size },
headers: { 'Content-Type': 'application/octet-stream' },
},
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.uploadFailed'));
return;
}
if (msg.obj?.theme) {
messageApi.success(
t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }),
);
await reload();
return;
}
offset = msg.obj?.received ?? offset + slice.size;
setUploadPct(Math.round((offset / file.size) * 100));
}
} finally {
setUploadPct(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function installByURL() {
setInstalling(true);
try {
const msg = await HttpUtil.post<{ theme: ThemeWire }>(
'/panel/api/probe/theme-install',
{ url: installURL },
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success && msg.obj?.theme) {
messageApi.success(t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }));
setInstallURL('');
await reload();
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.installFailed'));
}
} finally {
setInstalling(false);
}
}
async function themeAction(short: string, action: 'select' | 'update' | 'delete') {
setBusyShort(short);
try {
if (action === 'delete') {
const msg = await HttpUtil.delete(`/panel/api/probe/themes/${short}`, { silent: true });
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
return;
}
} else {
const msg = await HttpUtil.post<{ updated?: boolean; version?: string }>(
`/panel/api/probe/themes/${short}/${action}`,
undefined,
{ silent: true },
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
return;
}
if (action === 'update') {
if (msg.obj?.updated) {
messageApi.success(
t('pages.settings.probeThemes.updatedTo', { version: msg.obj.version }),
);
} else {
messageApi.success(t('pages.settings.probeThemes.alreadyLatest'));
}
}
}
await reload();
} finally {
setBusyShort('');
}
}
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => setSettings({ ...settings, publicPage: v })}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, site: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, githubProxy: e.target.value })}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{favicon ? <img src={favicon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{favicon ? (
<Button size="small" onClick={() => setFavicon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{touchIcon ? <img src={touchIcon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{touchIcon ? (
<Button size="small" onClick={() => setTouchIcon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeThemes.title')}
</Typography.Title>
<Space wrap style={{ marginBottom: 16 }}>
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
{uploadPct === null
? t('pages.settings.probeThemes.uploadInstall')
: `${t('pages.settings.probeThemes.uploadInstall')} ${uploadPct}%`}
</Button>
<input
ref={fileRef}
type="file"
accept=".gz,application/gzip"
hidden
onChange={(e) => uploadTheme(e.target.files?.[0])}
/>
<Input
style={{ width: 340 }}
value={installURL}
placeholder={t('pages.settings.probeThemes.installByUrlPlaceholder')}
onChange={(e) => setInstallURL(e.target.value)}
/>
<Button loading={installing} disabled={!installURL.trim()} onClick={installByURL}>
{t('pages.settings.probeThemes.installByUrl')}
</Button>
</Space>
<Row gutter={[16, 16]}>
{themes.map((theme) => (
<Col key={theme.short} xs={24} sm={12} lg={8}>
<Card
hoverable
cover={
theme.preview ? (
<img
alt={theme.name}
src={`${basePathPrefix()}/panel/api/probe/themes/${theme.short}/preview`}
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
/>
) : undefined
}
actions={[
theme.selected ? (
<Tag key="on" color="green">
{t('pages.settings.probeThemes.inUse')}
</Tag>
) : (
<Button
key="use"
type="link"
loading={busyShort === theme.short}
onClick={() => themeAction(theme.short, 'select')}
>
{t('pages.settings.probeThemes.use')}
</Button>
),
<Button
key="update"
type="link"
loading={busyShort === theme.short}
onClick={() => themeAction(theme.short, 'update')}
>
{t('update')}
</Button>,
...(theme.config && theme.config.length > 0
? [
<ThemeConfigButton
key="config"
theme={theme}
onSaved={() => messageApi.success(t('pages.settings.probeThemes.saved'))}
onError={(m) => messageApi.error(m)}
/>,
]
: []),
...(!theme.builtin
? [
<Popconfirm
key="del"
title={t('pages.settings.probeThemes.deleteConfirm')}
onConfirm={() => themeAction(theme.short, 'delete')}
>
<Button type="link" danger icon={<DeleteOutlined />}>
{t('delete')}
</Button>
</Popconfirm>,
]
: []),
]}
>
<Card.Meta
title={
<Space>
{theme.name}
{theme.builtin ? <Tag>{t('pages.settings.probeThemes.builtin')}</Tag> : null}
</Space>
}
description={
<div>
<div>{theme.description}</div>
<div>
v{theme.version} · {theme.author}
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.notifyTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.notifyHint')}
</Typography.Paragraph>
{notify ? (
<>
<Row gutter={[16, 12]}>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
<InputNumber
min={1}
max={30}
value={notify.notifyGrace}
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.graceHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
<InputNumber
min={0}
max={100}
value={notify.notifyTraffic}
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.trafficHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
<InputNumber
min={0}
max={365}
value={notify.notifyExpiry}
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.expiryHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
<Switch
checked={notify.notifyLogin}
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
/>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.telegramChannel')}
</div>
{notify.telegramConfigured ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
)}
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.telegramHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.webhookChannel')}
</div>
{notify.webhookConfigured ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
)}
</Col>
<Col xs={24}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
<Input.TextArea
rows={3}
value={notify.notifyTelegramText}
placeholder={'{{title}}\n{{message}}'}
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
<Input
value={webhookUrl}
placeholder={
notify.notifyWebhookUrlSet ? t('pages.settings.probeNotify.secretSet') : 'https://'
}
onChange={(e) => {
setWebhookUrl(e.target.value);
setUrlTouched(true);
}}
/>
{notify.notifyWebhookUrlSet && !urlTouched ? (
<Button
type="link"
size="small"
onClick={() => {
setWebhookUrl('');
setUrlTouched(true);
}}
>
{t('pages.settings.probeNotify.clearSecret')}
</Button>
) : null}
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.webhookHeaders')}
</div>
<Input.TextArea
rows={2}
value={webhookHeaders}
placeholder={
notify.notifyWebhookHeadersSet
? t('pages.settings.probeNotify.secretSet')
: 'Authorization: Bearer …'
}
onChange={(e) => {
setWebhookHeaders(e.target.value);
setHeadersTouched(true);
}}
/>
{notify.notifyWebhookHeadersSet && !headersTouched ? (
<Button
type="link"
size="small"
onClick={() => {
setWebhookHeaders('');
setHeadersTouched(true);
}}
>
{t('pages.settings.probeNotify.clearSecret')}
</Button>
) : null}
</Col>
<Col xs={24}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
<Input.TextArea
rows={3}
value={notify.notifyWebhookBody}
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.templateHint')}
</Typography.Text>
</div>
</Col>
</Row>
<Space style={{ marginTop: 12 }}>
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
{t('save')}
</Button>
<Button loading={notifyTesting} onClick={testNotify}>
{t('pages.settings.probeNotify.testSend')}
</Button>
</Space>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.retireTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.retireHint')}
</Typography.Paragraph>
<Space direction="vertical">
<span>
{t('pages.settings.probeNotify.retirePublicPage')}:
{settings.publicPage ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag color="orange">{t('pages.settings.probeNotify.retirePublicPageOff')}</Tag>
)}
</span>
{coverage.map((row) => (
<span key={row.name}>
{row.name}:
{row.hasProbe ? (
<Tag color="green">{t('pages.settings.probeNotify.retireHasProbe')}</Tag>
) : (
<Tag color="red">{t('pages.settings.probeNotify.retireNoProbe')}</Tag>
)}
{row.online ? (
<Tag color="green">{t('pages.settings.probeNotify.retireOnline')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.retireOffline')}</Tag>
)}
{row.probeAt > 0 ? (
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.retireProbeAt', {
time: new Date(row.probeAt * 1000).toLocaleString(),
})}
</Typography.Text>
) : null}
</span>
))}
{coverage.length > 0 && coverage.every((row) => row.hasProbe) ? (
<Typography.Text type="success">
{t('pages.settings.probeNotify.retireAllReady')}
</Typography.Text>
) : (
<Typography.Text type="warning">
{t('pages.settings.probeNotify.retireNotReady')}
</Typography.Text>
)}
</Space>
</div>
);
}
// ThemeConfigButton edits one theme's saved config. The form is
// drawn from the theme.json config declaration (passed through
// unparsed by the server); values load from and save to the contract
// endpoint the themes themselves use, and only fields changed from
// their declared defaults are stored (the hub's semantics).
function ThemeConfigButton({
theme,
onSaved,
onError,
}: {
theme: ThemeWire;
onSaved: () => void;
onError: (msg: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const fields = theme.config ?? [];
async function load() {
let saved: Record<string, unknown> = {};
try {
const res = await fetch(`/api/themes/${theme.short}/config`, { credentials: 'same-origin' });
if (res.ok) saved = (await res.json()) as Record<string, unknown>;
} catch {
/* an unreadable config edits from the defaults */
}
const merged: Record<string, unknown> = {};
for (const f of fields) merged[f.key] = saved[f.key] ?? f.default;
setValues(merged);
setOpen(true);
}
async function save() {
setSaving(true);
try {
const changed: Record<string, unknown> = {};
for (const f of fields) {
if (JSON.stringify(values[f.key]) !== JSON.stringify(f.default)) {
changed[f.key] = values[f.key];
}
}
const res = await fetch(`/api/themes/${theme.short}/config`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changed),
});
if (res.ok) {
setOpen(false);
onSaved();
} else {
onError((await res.text()) || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setSaving(false);
}
}
return (
<>
<Button type="link" icon={<SettingOutlined />} onClick={load}>
{t('pages.settings.probeThemes.config')}
</Button>
<Modal
open={open}
title={`${theme.name} · ${t('pages.settings.probeThemes.config')}`}
onCancel={() => setOpen(false)}
onOk={save}
confirmLoading={saving}
>
{fields.map((f) => (
<div key={f.key} style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>{f.label}</div>
{f.type === 'boolean' ? (
<Switch
checked={Boolean(values[f.key])}
onChange={(v) => setValues({ ...values, [f.key]: v })}
/>
) : f.type === 'number' ? (
<InputNumber
value={typeof values[f.key] === 'number' ? (values[f.key] as number) : undefined}
onChange={(v) => setValues({ ...values, [f.key]: v })}
style={{ width: '100%' }}
/>
) : f.type === 'select' && f.options ? (
<Select
value={values[f.key] as string}
options={f.options}
onChange={(v) => setValues({ ...values, [f.key]: v })}
style={{ width: '100%' }}
/>
) : (
<Input
value={typeof values[f.key] === 'string' ? (values[f.key] as string) : ''}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
)}
{f.help ? (
<div>
<Typography.Text type="secondary">{f.help}</Typography.Text>
</div>
) : null}
</div>
))}
</Modal>
</>
);
}