Refine settings and panel preferences

This commit is contained in:
2026-04-02 00:58:43 +03:00
parent 69c97ea387
commit 760ab62c10
10 changed files with 853 additions and 286 deletions

View File

@@ -1,22 +1,48 @@
import { FormEvent, useEffect, useState } from 'react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences';
import { getPanelText, getThemeLabel } from './lib/panelText';
import { getProtocolForCommand, validateSystemInput } from './shared/validation';
interface SystemTabProps {
snapshot: DashboardSnapshot;
preferences: PanelPreferences;
onPreferencesChange: (next: PanelPreferences) => void;
onSaveSystem: (input: UpdateSystemInput) => Promise<void>;
}
export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
export default function SystemTab({
snapshot,
preferences,
onPreferencesChange,
onSaveSystem,
}: SystemTabProps) {
const [draft, setDraft] = useState<UpdateSystemInput>(() => cloneSystemSettings(snapshot.system));
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [removeServiceId, setRemoveServiceId] = useState<string | null>(null);
const text = getPanelText(preferences.language);
useEffect(() => {
setDraft(cloneSystemSettings(snapshot.system));
setError('');
}, [snapshot.system]);
const linkedUsersByService = useMemo(() => {
const result = new Map<string, string[]>();
snapshot.userRecords.forEach((user) => {
const usernames = result.get(user.serviceId) ?? [];
usernames.push(user.username);
result.set(user.serviceId, usernames);
});
return result;
}, [snapshot.userRecords]);
const removeTarget = draft.services.find((service) => service.id === removeServiceId) ?? null;
const removeTargetUsers = removeTarget ? linkedUsersByService.get(removeTarget.id) ?? [] : [];
const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => {
setDraft((current) => ({
...current,
@@ -30,7 +56,9 @@ export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
setError('');
try {
const validated = validateSystemInput(draft, snapshot.userRecords);
const nextServiceIds = new Set(draft.services.map((service) => service.id));
const remainingUsers = snapshot.userRecords.filter((user) => nextServiceIds.has(user.serviceId));
const validated = validateSystemInput(draft, remainingUsers);
await onSaveSystem(validated);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.');
@@ -40,204 +68,241 @@ export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) {
};
return (
<form className="system-editor" onSubmit={handleSubmit}>
<section className="page-grid single-column system-grid">
<article className="panel-card system-settings-card">
<div className="card-header">
<h2>Panel settings</h2>
</div>
<div className="system-fields">
<label className="field-group">
Public host
<input
value={draft.publicHost}
onChange={(event) => setDraft((current) => ({ ...current, publicHost: event.target.value }))}
/>
</label>
<label className="field-group">
Config mode
<input
value={draft.configMode}
onChange={(event) => setDraft((current) => ({ ...current, configMode: event.target.value }))}
/>
</label>
<label className="field-group">
Reload mode
<input
value={draft.reloadMode}
onChange={(event) => setDraft((current) => ({ ...current, reloadMode: event.target.value }))}
/>
</label>
<label className="field-group">
Storage mode
<input
value={draft.storageMode}
onChange={(event) => setDraft((current) => ({ ...current, storageMode: event.target.value }))}
/>
</label>
</div>
<p className="system-hint">
These values describe how the panel generates and reloads the 3proxy config. Saving keeps
existing users attached only to enabled assignable services.
</p>
</article>
<>
<form className="system-editor" onSubmit={handleSubmit}>
<section className="page-grid single-column system-grid">
<article className="panel-card">
<div className="card-header">
<h2>{text.common.panelPreferences}</h2>
</div>
<div className="system-fields">
<label className="field-group">
{text.common.language}
<select
value={preferences.language}
onChange={(event) =>
onPreferencesChange({
...preferences,
language: event.target.value as PanelLanguage,
})
}
>
<option value="en">{text.common.english}</option>
<option value="ru">{text.common.russian}</option>
</select>
</label>
<label className="field-group">
{text.common.theme}
<select
value={preferences.theme}
onChange={(event) =>
onPreferencesChange({
...preferences,
theme: event.target.value as PanelTheme,
})
}
>
<option value="light">{getThemeLabel(preferences.language, 'light')}</option>
<option value="dark">{getThemeLabel(preferences.language, 'dark')}</option>
<option value="system">{getThemeLabel(preferences.language, 'system')}</option>
</select>
</label>
</div>
</article>
<article className="panel-card">
<div className="card-header">
<h2>Services</h2>
<button
type="button"
className="button-secondary"
onClick={() =>
setDraft((current) => ({
...current,
services: [...current.services, createServiceDraft(current.services)],
}))
}
>
Add service
</button>
</div>
<div className="service-editor-list">
{draft.services.map((service, index) => (
<section key={service.id} className="service-editor-row">
<div className="service-editor-header">
<div>
<strong>Service {index + 1}</strong>
<p>{service.id}</p>
</div>
<button
type="button"
className="button-secondary button-small"
onClick={() =>
setDraft((current) => ({
...current,
services: current.services.filter((entry) => entry.id !== service.id),
}))
}
>
Remove
</button>
</div>
<div className="service-editor-grid">
<label className="field-group">
Name
<input
value={service.name}
onChange={(event) =>
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label className="field-group">
Port
<input
inputMode="numeric"
value={String(service.port)}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
port: Number(event.target.value),
}))
}
/>
</label>
<label className="field-group">
Command
<select
value={service.command}
onChange={(event) =>
updateService(service.id, (current) => {
const command = event.target.value as ProxyServiceRecord['command'];
return {
...current,
command,
protocol: getProtocolForCommand(command),
assignable: command === 'admin' ? false : current.assignable,
};
})
}
<article className="panel-card">
<div className="card-header">
<h2>{text.settings.title}</h2>
<button
type="button"
className="button-secondary"
onClick={() =>
setDraft((current) => ({
...current,
services: [...current.services, createServiceDraft(current.services)],
}))
}
>
{text.common.addService}
</button>
</div>
<div className="service-editor-list">
{draft.services.map((service, index) => (
<section key={service.id} className="service-editor-row">
<div className="service-editor-header">
<div>
<strong>
{text.settings.serviceLabel} {index + 1}
</strong>
<p>{service.id}</p>
</div>
<button
type="button"
className="button-secondary button-small"
onClick={() => setRemoveServiceId(service.id)}
>
<option value="socks">socks</option>
<option value="proxy">proxy</option>
<option value="admin">admin</option>
</select>
</label>
{text.common.remove}
</button>
</div>
<div className="service-editor-grid">
<label className="field-group">
{text.settings.name}
<input
value={service.name}
onChange={(event) =>
updateService(service.id, (current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label className="field-group">
{text.settings.port}
<input
inputMode="numeric"
value={String(service.port)}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
port: Number(event.target.value),
}))
}
/>
</label>
<label className="field-group">
Protocol
<input value={service.protocol} readOnly />
</label>
<label className="field-group field-span-2">
Description
<input
value={service.description}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
description: event.target.value,
}))
}
/>
</label>
</div>
<div className="toggle-row">
<label className="toggle-check">
<input
type="checkbox"
checked={service.enabled}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
enabled: event.target.checked,
}))
}
/>
Enabled
</label>
<label className="toggle-check">
<input
type="checkbox"
checked={service.assignable}
disabled={service.command === 'admin'}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
assignable: current.command === 'admin' ? false : event.target.checked,
}))
}
/>
Assignable to users
</label>
</div>
</section>
))}
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="system-actions">
<button
type="button"
className="button-secondary"
onClick={() => {
setDraft(cloneSystemSettings(snapshot.system));
setError('');
}}
>
Reset
</button>
<button type="submit" disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save system'}
</button>
</div>
</article>
{text.settings.serviceType}
<select
value={service.command}
onChange={(event) =>
updateService(service.id, (current) => {
const command = event.target.value as ProxyServiceRecord['command'];
return {
...current,
command,
protocol: getProtocolForCommand(command),
assignable: command === 'admin' ? false : current.assignable,
};
})
}
>
<option value="socks">{text.settings.typeSocks}</option>
<option value="proxy">{text.settings.typeProxy}</option>
<option value="admin">{text.settings.typeAdmin}</option>
</select>
</label>
<label className="field-group field-span-2">
{text.settings.description}
<input
value={service.description}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
description: event.target.value,
}))
}
/>
</label>
</div>
<div className="toggle-row">
<label className="toggle-check">
<input
type="checkbox"
checked={service.enabled}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
enabled: event.target.checked,
}))
}
/>
{text.common.enabled}
</label>
<label className="toggle-check">
<input
type="checkbox"
checked={service.assignable}
disabled={service.command === 'admin'}
onChange={(event) =>
updateService(service.id, (current) => ({
...current,
assignable: current.command === 'admin' ? false : event.target.checked,
}))
}
/>
{text.common.assignable}
</label>
</div>
</section>
))}
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="system-actions">
<button
type="button"
className="button-secondary"
onClick={() => {
setDraft(cloneSystemSettings(snapshot.system));
setError('');
}}
>
{text.common.reset}
</button>
<button type="submit" disabled={isSaving}>
{isSaving ? `${text.common.save}...` : text.common.saveSettings}
</button>
</div>
</article>
<article className="panel-card wide-card">
<div className="card-header">
<h2>Generated config</h2>
</div>
<pre>{snapshot.system.previewConfig}</pre>
</article>
</section>
</form>
<article className="panel-card wide-card">
<div className="card-header">
<h2>{text.settings.generatedConfig}</h2>
</div>
<pre>{snapshot.system.previewConfig}</pre>
</article>
</section>
</form>
{removeTarget ? (
<div className="modal-backdrop" role="presentation" onClick={() => setRemoveServiceId(null)}>
<section
aria-labelledby="remove-service-title"
aria-modal="true"
className="modal-card confirm-card"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<h2 id="remove-service-title">{text.settings.serviceRemoveTitle}</h2>
</div>
<p className="confirm-copy">
<strong>{removeTarget.name}</strong>{' '}
{removeTargetUsers.length > 0 ? text.settings.removeWarningUsers : text.settings.removeWarningNone}
</p>
{removeTargetUsers.length > 0 ? (
<p className="confirm-copy">
{text.settings.removeWarningCount} {removeTargetUsers.join(', ')}
</p>
) : null}
<div className="modal-actions">
<button type="button" className="button-secondary" onClick={() => setRemoveServiceId(null)}>
{text.common.cancel}
</button>
<button
type="button"
className="button-danger"
onClick={() => {
setDraft((current) => ({
...current,
services: current.services.filter((service) => service.id !== removeTarget.id),
}));
setRemoveServiceId(null);
}}
>
{text.common.remove}
</button>
</div>
</section>
</div>
) : null}
</>
);
}