|
|
|
@@ -0,0 +1,590 @@
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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);
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [applyPayload]);
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
</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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|