feat: Добавить API для управления настройками пользовательского интерфейса, включая получение и обновление конфигурации из S3; интегрировать модальное окно настроек в интерфейс
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s

This commit is contained in:
2025-08-26 17:07:26 +07:00
parent f11374880c
commit 03d24e8746
3 changed files with 204 additions and 1 deletions
+8
View File
@@ -22,6 +22,7 @@ import CommunitiesManager from './CommunitiesManager';
import Dashboard from './Dashboard';
import './App.css';
import { NotifyProvider } from './components/NotifyProvider.jsx';
import SettingsModal from './components/SettingsModal.jsx';
// axios не используется напрямую; сетевые вызовы через src/lib/api.js
// --- Simple i18n (RU/EN) ---
@@ -96,6 +97,7 @@ function MainLayout() {
const { lang, setLang, t } = useContext(LanguageContext);
const { theme, setTheme } = useContext(ThemeContext);
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
const [settingsOpen, setSettingsOpen] = useState(false);
// Состояние для управления выпадающими меню
const [dropdownStates, setDropdownStates] = useState({
@@ -301,6 +303,11 @@ function MainLayout() {
<option value="dark">{t('dark')}</option>
</select>
</div>
<div className="nav-item ms-2">
<button className="btn btn-outline-primary btn-sm" onClick={() => setSettingsOpen(true)}>
<IconSettings className="me-1" /> Настройки
</button>
</div>
</div>
</div>
</header>
@@ -318,6 +325,7 @@ function MainLayout() {
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</div>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react'
import api from '../lib/api.js'
export default function SettingsModal({ open, onClose }) {
const ref = useRef(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [etag, setEtag] = useState('')
const [dohServer, setDohServer] = useState('')
useEffect(() => {
if (!open) return
setError('')
setSuccess('')
setLoading(true)
;(async () => {
try {
const res = await api.get('/ui-settings')
const data = res?.data || {}
setDohServer(String(data?.dohServer || ''))
const e = res?.headers?.etag || res?.headers?.ETag || ''
setEtag(e ? String(e) : '')
// фокус на первом поле
setTimeout(() => { try { ref.current?.querySelector('input[data-primary]')?.focus() } catch {} }, 0)
} catch (e) {
setError('Не удалось загрузить настройки')
} finally {
setLoading(false)
}
})()
}, [open])
if (!open) return null
const validateDoh = (value) => {
if (!value) return true // допускаем пустое значение
try {
const u = new URL(String(value))
return u.protocol === 'https:'
} catch { return false }
}
const onSave = async () => {
setError('')
setSuccess('')
if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
setSaving(true)
try {
const payload = { settings: { dohServer: String(dohServer || '').trim() }, etag }
const res = await api.post('/ui-settings', payload)
const meta = res?.data || {}
setSuccess('Настройки сохранены')
setEtag(String(meta?.etag || ''))
setTimeout(() => setSuccess(''), 2500)
} catch (e) {
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек')
} finally {
setSaving(false)
}
}
return (
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="settings-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
<div className="modal-dialog" role="document">
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') {
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 id="settings-title" className="modal-title">Настройки интерфейса</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose}></button>
</div>
<div className="modal-body">
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<div className="mb-3">
<label className="form-label">DoH сервер</label>
<input
type="text"
className={`form-control${dohServer && !validateDoh(dohServer) ? ' is-invalid' : ''}`}
placeholder="https://dns.google/dns-query"
value={dohServer}
onChange={(e) => setDohServer(e.target.value)}
disabled={loading || saving}
data-primary
/>
<div className="form-hint">HTTPS URL для DNS-over-HTTPS (например, https://dns.google/dns-query)</div>
{dohServer && !validateDoh(dohServer) && (
<div className="invalid-feedback">Укажите корректный HTTPS URL</div>
)}
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>Закрыть</button>
<button type="button" className="btn btn-primary" onClick={onSave} disabled={saving || loading}>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" />
Сохранение...
</>
) : (
'Сохранить'
)}
</button>
</div>
</div>
</div>
</div>
)
}