diff --git a/docs/PLAN.md b/docs/PLAN.md index d24da5a..49c39da 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -31,4 +31,3 @@ Updated: 2026-04-02 15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows. 16. Simplified the System tab layout by removing the redundant Runtime card and collapsing those fields into a compact settings section. 17. Moved panel auth to server-issued expiring tokens with `sessionStorage` persistence and Compose-configurable credentials/TTL. -18. Reworked Settings to store panel language/theme in `localStorage`, added RU/EN UI switching, and made service deletion warn before cascading linked-user removal. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 088c5fa..35180df 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -29,8 +29,6 @@ Updated: 2026-04-02 - `src/app.css`: full panel styling - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers -- `src/lib/panelPreferences.ts`: client-side `localStorage` preferences and theme application helpers -- `src/lib/panelText.ts`: EN/RU panel copy and labels for translated UI chrome - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/shared/contracts.ts`: shared panel, service, user, and API data contracts - `src/shared/validation.ts`: shared validation for user creation, system edits, protocol mapping, and quota conversion diff --git a/server/app.test.ts b/server/app.test.ts index 359ff38..5c4005a 100644 --- a/server/app.test.ts +++ b/server/app.test.ts @@ -153,24 +153,6 @@ describe('panel api', () => { expect(response.body.system.previewConfig).toContain('socks -p1180 -u2'); expect(response.body.service.lastEvent).toBe('System configuration updated from panel'); }); - - it('removes linked users when a service is deleted from settings', async () => { - const app = await createTestApp(); - const token = await authorize(app); - const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); - const system = createSystemPayload(initial.body); - - system.services = system.services.filter((service) => service.id !== 'socks-main'); - - const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system); - - expect(response.status).toBe(200); - expect(response.body.userRecords.some((user: { username: string }) => user.username === 'night-shift')).toBe( - false, - ); - expect(response.body.userRecords.some((user: { username: string }) => user.username === 'ops-east')).toBe(false); - expect(response.body.service.lastEvent).toMatch(/removed 2 linked users/i); - }); }); async function createTestApp() { diff --git a/server/app.ts b/server/app.ts index 9b89ae7..58c125b 100644 --- a/server/app.ts +++ b/server/app.ts @@ -129,23 +129,8 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) app.put('/api/system', async (request, response, next) => { try { const state = await store.read(); - const requestedSystem = request.body as Partial; - const nextServiceIds = new Set( - (Array.isArray(requestedSystem.services) ? requestedSystem.services : []).map((service) => service.id), - ); - const removedServiceIds = new Set( - state.system.services - .map((service) => service.id) - .filter((serviceId) => !nextServiceIds.has(serviceId)), - ); - - const removedUsers = state.userRecords.filter((user) => removedServiceIds.has(user.serviceId)); - state.userRecords = state.userRecords.filter((user) => !removedServiceIds.has(user.serviceId)); - state.system = validateSystemInput(requestedSystem, state.userRecords); - state.service.lastEvent = - removedUsers.length > 0 - ? `System configuration updated from panel and removed ${removedUsers.length} linked users` - : 'System configuration updated from panel'; + state.system = validateSystemInput(request.body as Partial, state.userRecords); + state.service.lastEvent = 'System configuration updated from panel'; await persistRuntimeMutation(store, runtime, state, runtimePaths); response.json(await getSnapshot(store, runtime, runtimePaths)); } catch (error) { diff --git a/src/App.test.tsx b/src/App.test.tsx index dc29511..bd396eb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -11,7 +11,6 @@ async function loginIntoPanel(user: ReturnType) { beforeEach(() => { window.sessionStorage.clear(); - window.localStorage.clear(); }); describe('App login gate', () => { @@ -51,23 +50,6 @@ describe('App login gate', () => { expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument(); }); - it('stores panel language in localStorage and restores it after a remount', async () => { - const user = userEvent.setup(); - const firstRender = render(); - - await loginIntoPanel(user); - await user.click(screen.getByRole('button', { name: /settings/i })); - await user.selectOptions(screen.getByLabelText(/panel language/i), 'ru'); - - expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument(); - - firstRender.unmount(); - render(); - - expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument(); - }); - it('opens add-user flow in a modal and closes it on escape', async () => { const user = userEvent.setup(); render(); @@ -125,43 +107,26 @@ describe('App login gate', () => { expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument(); }); - it('saves service settings from the settings tab and applies them to the local fallback state', async () => { + it('saves system settings from the system tab and applies them to the local fallback state', async () => { const user = userEvent.setup(); render(); await loginIntoPanel(user); - await user.click(screen.getByRole('button', { name: /settings/i })); + await user.click(screen.getByRole('button', { name: /system/i })); + + await user.clear(screen.getByLabelText(/public host/i)); + await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net'); const firstPortInput = screen.getAllByLabelText(/port/i)[0]; await user.clear(firstPortInput); await user.type(firstPortInput, '1180'); - await user.click(screen.getByRole('button', { name: /save settings/i })); + await user.click(screen.getByRole('button', { name: /save system/i })); + + expect(screen.getByText(/ops-gateway\.example\.net/i)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /users/i })); - expect(screen.getAllByText(/edge\.example\.net:1180/i).length).toBeGreaterThan(0); - }); - - it('warns before deleting a service and removes linked users after confirmation', async () => { - const user = userEvent.setup(); - render(); - - await loginIntoPanel(user); - await user.click(screen.getByRole('button', { name: /settings/i })); - - await user.click(screen.getAllByRole('button', { name: /^remove$/i })[0]); - - const dialog = screen.getByRole('dialog', { name: /delete service/i }); - expect(dialog).toBeInTheDocument(); - expect(within(dialog).getByText(/linked users to be removed/i)).toBeInTheDocument(); - expect(within(dialog).getByText(/night-shift, ops-east/i)).toBeInTheDocument(); - - await user.click(within(dialog).getByRole('button', { name: /^remove$/i })); - await user.click(screen.getByRole('button', { name: /save settings/i })); - await user.click(screen.getByRole('button', { name: /users/i })); - - expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/ops-east/i)).not.toBeInTheDocument(); + expect(screen.getAllByText(/ops-gateway\.example\.net:1180/i).length).toBeGreaterThan(0); }); }); diff --git a/src/App.tsx b/src/App.tsx index ff6facb..64deec2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,12 +4,11 @@ import { fallbackDashboardSnapshot, panelAuth } from './data/mockDashboard'; import { buildProxyLink, formatBytes, + formatQuotaState, formatTrafficShare, getServiceTone, isQuotaExceeded, } from './lib/3proxy'; -import { applyPanelTheme, loadPanelPreferences, observeSystemTheme, savePanelPreferences, type PanelPreferences } from './lib/panelPreferences'; -import { getPanelText } from './lib/panelText'; import type { CreateUserInput, DashboardSnapshot, @@ -23,10 +22,10 @@ import { quotaMbToBytes, validateCreateUserInput } from './shared/validation'; type TabId = 'dashboard' | 'users' | 'system'; -const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> = [ - { id: 'dashboard', textKey: 'dashboard' }, - { id: 'users', textKey: 'users' }, - { id: 'system', textKey: 'settings' }, +const tabs: Array<{ id: TabId; label: string }> = [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'users', label: 'Users' }, + { id: 'system', label: 'System' }, ]; const SESSION_KEY = '3proxy-ui-panel-session'; @@ -37,14 +36,7 @@ interface StoredSession { expiresAt: string; } -function LoginGate({ - onUnlock, - preferences, -}: { - onUnlock: (login: string, password: string) => Promise; - preferences: PanelPreferences; -}) { - const text = getPanelText(preferences.language); +function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) => Promise }) { const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -68,12 +60,12 @@ function LoginGate({
-

{text.auth.title}

-

{text.auth.subtitle}

+

3proxy UI

+

Sign in to the control panel.

{error ?

{error}

: null}
@@ -108,15 +100,12 @@ function AddUserModal({ services, onClose, onCreate, - preferences, }: { host: string; services: ProxyServiceRecord[]; onClose: () => void; onCreate: (input: CreateUserInput) => Promise; - preferences: PanelPreferences; }) { - const text = getPanelText(preferences.language); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [serviceId, setServiceId] = useState(services[0]?.id ?? ''); @@ -167,22 +156,22 @@ function AddUserModal({ onClick={stopPropagation} >
-

{text.users.addUser}

+

Add user

- {text.common.endpoint} - {selectedService ? `${host}:${selectedService.port}` : text.common.unavailable} + Endpoint + {selectedService ? `${host}:${selectedService.port}` : 'Unavailable'}
- {text.common.protocol} - {selectedService ? selectedService.protocol : text.common.unavailable} + Protocol + {selectedService ? selectedService.protocol : 'Unavailable'}
{error ?

{error}

: null}
- +
@@ -220,14 +209,11 @@ function ConfirmDeleteModal({ username, onClose, onConfirm, - preferences, }: { username: string; onClose: () => void; onConfirm: () => Promise; - preferences: PanelPreferences; }) { - const text = getPanelText(preferences.language); useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') { @@ -254,18 +240,18 @@ function ConfirmDeleteModal({ onClick={stopPropagation} >
-

{text.users.deleteTitle}

+

Delete user

- {text.users.deletePrompt} {username} - {text.users.deletePromptSuffix} + Remove profile {username}? This action will delete the user entry from the + current panel state.

@@ -276,65 +262,62 @@ function ConfirmDeleteModal({ function DashboardTab({ snapshot, onRuntimeAction, - preferences, }: { snapshot: DashboardSnapshot; onRuntimeAction: (action: 'start' | 'restart') => Promise; - preferences: PanelPreferences; }) { - const text = getPanelText(preferences.language); const serviceTone = getServiceTone(snapshot.service.status); return (
-

{text.dashboard.service}

- {text.status[snapshot.service.status]} +

Service

+ {snapshot.service.status}
-
{text.common.process}
+
Process
{snapshot.service.pidLabel}
-
{text.common.version}
+
Version
{snapshot.service.versionLabel}
-
{text.common.uptime}
+
Uptime
{snapshot.service.uptimeLabel}
-
{text.common.lastEvent}
+
Last event
{snapshot.service.lastEvent}
-

{text.dashboard.traffic}

+

Traffic

- {text.common.total} + Total {formatBytes(snapshot.traffic.totalBytes)}
- {text.common.connections} + Connections {snapshot.traffic.liveConnections}
- {text.common.activeUsers} + Active users {snapshot.traffic.activeUsers}
@@ -342,7 +325,7 @@ function DashboardTab({
-

{text.dashboard.dailyUsage}

+

Daily usage

{snapshot.traffic.daily.map((bucket) => ( @@ -359,7 +342,7 @@ function DashboardTab({
-

{text.dashboard.attention}

+

Attention

{snapshot.attention.map((item) => ( @@ -382,15 +365,12 @@ function UsersTab({ onCreateUser, onTogglePause, onDeleteUser, - preferences, }: { snapshot: DashboardSnapshot; onCreateUser: (input: CreateUserInput) => Promise; onTogglePause: (userId: string) => Promise; onDeleteUser: (userId: string) => Promise; - preferences: PanelPreferences; }) { - const text = getPanelText(preferences.language); const [copiedId, setCopiedId] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); @@ -415,37 +395,35 @@ function UsersTab({
-

{text.users.title}

-

- {snapshot.userRecords.length} {text.users.accountsInProfile} -

+

Users

+

{snapshot.userRecords.length} accounts in current profile

- {snapshot.users.live} {text.users.live} - {snapshot.users.nearQuota} {text.users.nearQuota} - {snapshot.users.exceeded} {text.users.exceeded} + {snapshot.users.live} live + {snapshot.users.nearQuota} near quota + {snapshot.users.exceeded} exceeded
{assignableServices.length === 0 ? ( -

{text.users.enableServiceHint}

+

Enable an assignable service in System before creating new users.

) : null}
- - - - - - - - + + + + + + + + @@ -453,7 +431,7 @@ function UsersTab({ const service = servicesById.get(user.serviceId); const endpoint = service ? `${snapshot.system.publicHost}:${service.port}` - : text.common.serviceMissing; + : 'service missing'; const proxyLink = service ? buildProxyLink( user.username, @@ -475,17 +453,17 @@ function UsersTab({ - + @@ -530,7 +508,6 @@ function UsersTab({ services={assignableServices} onClose={() => setIsModalOpen(false)} onCreate={onCreateUser} - preferences={preferences} /> ) : null} @@ -542,7 +519,6 @@ function UsersTab({ await onDeleteUser(deleteTarget.id); setDeleteTargetId(null); }} - preferences={preferences} /> ) : null} @@ -550,24 +526,9 @@ function UsersTab({ } export default function App() { - const [preferences, setPreferences] = useState(() => loadPanelPreferences()); const [session, setSession] = useState(() => loadStoredSession()); const [activeTab, setActiveTab] = useState('dashboard'); const [snapshot, setSnapshot] = useState(fallbackDashboardSnapshot); - const text = getPanelText(preferences.language); - - useEffect(() => { - applyPanelTheme(preferences.theme); - savePanelPreferences(preferences); - }, [preferences]); - - useEffect(() => { - if (preferences.theme !== 'system') { - return; - } - - return observeSystemTheme(() => applyPanelTheme('system')); - }, [preferences.theme]); const resetSession = () => { clearStoredSession(); @@ -644,7 +605,7 @@ export default function App() { }, [session]); if (!session) { - return ; + return ; } const mutateSnapshot = async ( @@ -799,7 +760,6 @@ export default function App() { } if (!payload) { - const nextServiceIds = new Set(input.services.map((service) => service.id)); setSnapshot((current) => withDerivedSnapshot({ ...current, @@ -807,7 +767,6 @@ export default function App() { ...current.service, lastEvent: 'System configuration updated from panel', }, - userRecords: current.userRecords.filter((user) => nextServiceIds.has(user.serviceId)), system: { ...input, previewConfig: current.system.previewConfig, @@ -829,15 +788,15 @@ export default function App() {
- {text.common.status} - {text.status[snapshot.service.status]} + Status + {snapshot.service.status}
- {text.common.version} + Version {snapshot.service.versionLabel}
- {text.common.users} + Users {snapshot.users.total}
@@ -858,31 +817,21 @@ export default function App() { className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} onClick={() => setActiveTab(tab.id)} > - {text.tabs[tab.textKey]} + {tab.label} ))} - {activeTab === 'dashboard' ? ( - - ) : null} + {activeTab === 'dashboard' ? : null} {activeTab === 'users' ? ( - ) : null} - {activeTab === 'system' ? ( - ) : null} + {activeTab === 'system' ? : null} ); } @@ -915,26 +864,6 @@ function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot { }; } -function formatQuotaStateLabel( - usedBytes: number, - quotaBytes: number | null, - language: PanelPreferences['language'], -): string { - const text = getPanelText(language); - - if (quotaBytes === null) { - return text.common.unlimited; - } - - const remaining = quotaBytes - usedBytes; - - if (remaining <= 0) { - return text.common.exceeded; - } - - return `${formatBytes(remaining)} ${text.common.left}`; -} - async function requestSnapshot(request: () => Promise): Promise { try { const response = await request(); diff --git a/src/SystemTab.tsx b/src/SystemTab.tsx index e68064a..e060cb5 100644 --- a/src/SystemTab.tsx +++ b/src/SystemTab.tsx @@ -1,48 +1,22 @@ -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useEffect, 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; } -export default function SystemTab({ - snapshot, - preferences, - onPreferencesChange, - onSaveSystem, -}: SystemTabProps) { +export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { const [draft, setDraft] = useState(() => cloneSystemSettings(snapshot.system)); const [error, setError] = useState(''); const [isSaving, setIsSaving] = useState(false); - const [removeServiceId, setRemoveServiceId] = useState(null); - const text = getPanelText(preferences.language); useEffect(() => { setDraft(cloneSystemSettings(snapshot.system)); setError(''); }, [snapshot.system]); - const linkedUsersByService = useMemo(() => { - const result = new Map(); - - 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, @@ -56,9 +30,7 @@ export default function SystemTab({ setError(''); try { - 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); + const validated = validateSystemInput(draft, snapshot.userRecords); await onSaveSystem(validated); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.'); @@ -68,241 +40,204 @@ export default function SystemTab({ }; return ( - <> -
-
-
-
-

{text.common.panelPreferences}

-
-
- - -
-
+ +
+
+
+

Panel settings

+
+
+ + + + +
+

+ These values describe how the panel generates and reloads the 3proxy config. Saving keeps + existing users attached only to enabled assignable services. +

+
-
-
-

{text.settings.title}

- -
-
- {draft.services.map((service, index) => ( -
-
-
- - {text.settings.serviceLabel} {index + 1} - -

{service.id}

-
- +
+
+

Services

+ +
+
+ {draft.services.map((service, index) => ( +
+
+
+ Service {index + 1} +

{service.id}

-
- - + +
+
- + + - -
-
- ))} -
- {error ?

{error}

: null} -
- - -
-
+ command, + protocol: getProtocolForCommand(command), + assignable: command === 'admin' ? false : current.assignable, + }; + }) + } + > + + + + + + + +
+
+ + +
+
+ ))} +
+ {error ?

{error}

: null} +
+ + +
+
-
-
-

{text.settings.generatedConfig}

-
-
{snapshot.system.previewConfig}
-
-
- - - {removeTarget ? ( -
setRemoveServiceId(null)}> -
event.stopPropagation()} - > -
-

{text.settings.serviceRemoveTitle}

-
-

- {removeTarget.name}{' '} - {removeTargetUsers.length > 0 ? text.settings.removeWarningUsers : text.settings.removeWarningNone} -

- {removeTargetUsers.length > 0 ? ( -

- {text.settings.removeWarningCount} {removeTargetUsers.join(', ')} -

- ) : null} -
- - -
-
-
- ) : null} - +
+
+

Generated config

+
+
{snapshot.system.previewConfig}
+
+
+ ); } diff --git a/src/app.css b/src/app.css index 37b6a0a..774c2f2 100644 --- a/src/app.css +++ b/src/app.css @@ -22,23 +22,6 @@ --shadow: 0 1px 2px rgba(17, 24, 39, 0.04); } -:root[data-theme='dark'] { - color-scheme: dark; - --page-bg: #111827; - --surface: #18212f; - --surface-muted: #1f2937; - --border: #334155; - --border-strong: #475569; - --text: #f8fafc; - --muted: #94a3b8; - --accent: #60a5fa; - --accent-muted: rgba(96, 165, 250, 0.15); - --success: #4ade80; - --warning: #fbbf24; - --danger: #f87171; - --shadow: 0 1px 2px rgba(2, 6, 23, 0.35); -} - * { box-sizing: border-box; } @@ -389,13 +372,13 @@ button, .usage-bar { height: 8px; border-radius: 999px; - background: color-mix(in srgb, var(--border) 70%, transparent); + background: #eceff3; overflow: hidden; } .usage-bar div { height: 100%; - background: color-mix(in srgb, var(--accent) 55%, var(--muted)); + background: #94a3b8; } .event-row, @@ -625,8 +608,8 @@ pre { overflow: auto; border: 1px solid var(--border); border-radius: 10px; - background: color-mix(in srgb, var(--surface-muted) 80%, var(--surface)); - color: var(--text); + background: #fbfbfc; + color: #1f2937; font: 13px/1.55 Consolas, "Courier New", monospace; } diff --git a/src/lib/panelPreferences.ts b/src/lib/panelPreferences.ts deleted file mode 100644 index 2b4e6f5..0000000 --- a/src/lib/panelPreferences.ts +++ /dev/null @@ -1,74 +0,0 @@ -export type PanelLanguage = 'en' | 'ru'; -export type PanelTheme = 'light' | 'dark' | 'system'; - -export interface PanelPreferences { - language: PanelLanguage; - theme: PanelTheme; -} - -const PREFERENCES_KEY = '3proxy-ui-panel-preferences'; - -export const defaultPanelPreferences: PanelPreferences = { - language: 'en', - theme: 'system', -}; - -export function loadPanelPreferences(): PanelPreferences { - try { - const raw = window.localStorage.getItem(PREFERENCES_KEY); - - if (!raw) { - return defaultPanelPreferences; - } - - const parsed = JSON.parse(raw) as Partial; - - return { - language: parsed.language === 'ru' ? 'ru' : 'en', - theme: isPanelTheme(parsed.theme) ? parsed.theme : defaultPanelPreferences.theme, - }; - } catch { - return defaultPanelPreferences; - } -} - -export function savePanelPreferences(preferences: PanelPreferences): void { - window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); -} - -export function applyPanelTheme(theme: PanelTheme): void { - document.documentElement.dataset.theme = resolvePanelTheme(theme); -} - -export function observeSystemTheme(onChange: () => void): () => void { - if (typeof window.matchMedia !== 'function') { - return () => undefined; - } - - const media = window.matchMedia('(prefers-color-scheme: dark)'); - const listener = () => onChange(); - - if (typeof media.addEventListener === 'function') { - media.addEventListener('change', listener); - return () => media.removeEventListener('change', listener); - } - - media.addListener(listener); - return () => media.removeListener(listener); -} - -function resolvePanelTheme(theme: PanelTheme): 'light' | 'dark' { - if (theme === 'light' || theme === 'dark') { - return theme; - } - - if (typeof window.matchMedia === 'function') { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - - return 'light'; -} - -function isPanelTheme(value: unknown): value is PanelTheme { - return value === 'light' || value === 'dark' || value === 'system'; -} diff --git a/src/lib/panelText.ts b/src/lib/panelText.ts deleted file mode 100644 index 71f8669..0000000 --- a/src/lib/panelText.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { ServiceCommand } from '../shared/contracts'; -import type { PanelLanguage, PanelTheme } from './panelPreferences'; - -const text = { - en: { - tabs: { - dashboard: 'Dashboard', - users: 'Users', - settings: 'Settings', - }, - auth: { - title: '3proxy UI', - subtitle: 'Sign in to the control panel.', - login: 'Login', - password: 'Password', - open: 'Open panel', - opening: 'Opening...', - }, - common: { - close: 'Close', - cancel: 'Cancel', - reset: 'Reset', - save: 'Save', - saveSettings: 'Save settings', - signOut: 'Sign out', - addService: 'Add service', - remove: 'Remove', - delete: 'Delete', - deleteUser: 'Delete user', - copy: 'Copy', - copied: 'Copied', - createUser: 'Create user', - create: 'Create', - endpoint: 'Endpoint', - protocol: 'Protocol', - status: 'Status', - version: 'Version', - users: 'Users', - enabled: 'Enabled', - assignable: 'Assignable to users', - unavailable: 'Unavailable', - unknownService: 'Unknown service', - serviceMissing: 'service missing', - optional: 'Optional', - activeUsers: 'Active users', - total: 'Total', - connections: 'Connections', - process: 'Process', - uptime: 'Uptime', - lastEvent: 'Last event', - settingsSaved: 'Settings updated from panel', - left: 'left', - unlimited: 'Unlimited', - exceeded: 'Exceeded', - panelPreferences: 'Panel preferences', - language: 'Panel language', - theme: 'Panel style', - english: 'English', - russian: 'Russian', - light: 'Light', - dark: 'Dark', - system: 'System', - }, - dashboard: { - service: 'Service', - traffic: 'Traffic', - dailyUsage: 'Daily usage', - attention: 'Attention', - start: 'Start', - restart: 'Restart', - }, - users: { - title: 'Users', - accountsInProfile: 'accounts in current profile', - newUser: 'New user', - live: 'live', - nearQuota: 'near quota', - exceeded: 'exceeded', - enableServiceHint: 'Enable an assignable service in Settings before creating new users.', - user: 'User', - endpoint: 'Endpoint', - used: 'Used', - remaining: 'Remaining', - share: 'Share', - proxy: 'Proxy', - actions: 'Actions', - addUser: 'Add user', - username: 'Username', - password: 'Password', - service: 'Service', - quotaMb: 'Quota (MB)', - pause: 'Pause', - resume: 'Resume', - deleteTitle: 'Delete user', - deletePrompt: 'Remove profile', - deletePromptSuffix: '? This action will delete the user entry from the current panel state.', - cancel: 'Cancel', - deleteAction: 'Delete user', - }, - settings: { - title: 'Services', - generatedConfig: 'Generated config', - serviceLabel: 'Service', - serviceType: 'Type', - typeSocks: 'SOCKS5 proxy', - typeProxy: 'HTTP proxy', - typeAdmin: 'Admin interface', - name: 'Name', - port: 'Port', - description: 'Description', - serviceRemoveTitle: 'Delete service', - removeWarningNone: 'Delete this service from the panel configuration?', - removeWarningUsers: 'Delete this service and remove all linked users?', - removeWarningCount: 'Linked users to be removed:', - }, - status: { - live: 'live', - warn: 'warn', - fail: 'fail', - idle: 'idle', - paused: 'paused', - enabled: 'enabled', - disabled: 'disabled', - }, - }, - ru: { - tabs: { - dashboard: 'Панель', - users: 'Пользователи', - settings: 'Настройки', - }, - auth: { - title: '3proxy UI', - subtitle: 'Войдите в панель управления.', - login: 'Логин', - password: 'Пароль', - open: 'Открыть панель', - opening: 'Открываем...', - }, - common: { - close: 'Закрыть', - cancel: 'Отмена', - reset: 'Сбросить', - save: 'Сохранить', - saveSettings: 'Сохранить настройки', - signOut: 'Выйти', - addService: 'Добавить сервис', - remove: 'Удалить', - delete: 'Удалить', - deleteUser: 'Удалить пользователя', - copy: 'Копировать', - copied: 'Скопировано', - createUser: 'Создать пользователя', - create: 'Создать', - endpoint: 'Точка входа', - protocol: 'Протокол', - status: 'Статус', - version: 'Версия', - users: 'Пользователи', - enabled: 'Включен', - assignable: 'Можно назначать пользователям', - unavailable: 'Недоступно', - unknownService: 'Неизвестный сервис', - serviceMissing: 'сервис отсутствует', - optional: 'Необязательно', - activeUsers: 'Активные пользователи', - total: 'Всего', - connections: 'Соединения', - process: 'Процесс', - uptime: 'Время работы', - lastEvent: 'Последнее событие', - settingsSaved: 'Настройки обновлены из панели', - left: 'осталось', - unlimited: 'Без лимита', - exceeded: 'Превышено', - panelPreferences: 'Параметры панели', - language: 'Язык панели', - theme: 'Стиль панели', - english: 'Английский', - russian: 'Русский', - light: 'Светлый', - dark: 'Темный', - system: 'Системный', - }, - dashboard: { - service: 'Сервис', - traffic: 'Трафик', - dailyUsage: 'Дневное использование', - attention: 'Внимание', - start: 'Запустить', - restart: 'Перезапустить', - }, - users: { - title: 'Пользователи', - accountsInProfile: 'аккаунтов в текущем профиле', - newUser: 'Новый пользователь', - live: 'в работе', - nearQuota: 'близко к лимиту', - exceeded: 'превышено', - enableServiceHint: 'Сначала включите назначаемый сервис во вкладке Настройки.', - user: 'Пользователь', - endpoint: 'Точка входа', - used: 'Использовано', - remaining: 'Остаток', - share: 'Доля', - proxy: 'Прокси', - actions: 'Действия', - addUser: 'Добавить пользователя', - username: 'Имя пользователя', - password: 'Пароль', - service: 'Сервис', - quotaMb: 'Лимит (МБ)', - pause: 'Пауза', - resume: 'Возобновить', - deleteTitle: 'Удаление пользователя', - deletePrompt: 'Удалить профиль', - deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.', - cancel: 'Отмена', - deleteAction: 'Удалить пользователя', - }, - settings: { - title: 'Сервисы', - generatedConfig: 'Сгенерированный конфиг', - serviceLabel: 'Сервис', - serviceType: 'Тип', - typeSocks: 'SOCKS5 прокси', - typeProxy: 'HTTP прокси', - typeAdmin: 'Админ-интерфейс', - name: 'Название', - port: 'Порт', - description: 'Описание', - serviceRemoveTitle: 'Удаление сервиса', - removeWarningNone: 'Удалить этот сервис из конфигурации панели?', - removeWarningUsers: 'Удалить этот сервис и всех связанных с ним пользователей?', - removeWarningCount: 'Будут удалены пользователи:', - }, - status: { - live: 'в работе', - warn: 'предупреждение', - fail: 'ошибка', - idle: 'ожидание', - paused: 'пауза', - enabled: 'включен', - disabled: 'выключен', - }, - }, -} as const; - -export function getPanelText(language: PanelLanguage) { - return text[language]; -} - -export function getThemeLabel(language: PanelLanguage, theme: PanelTheme): string { - const t = getPanelText(language); - return theme === 'light' ? t.common.light : theme === 'dark' ? t.common.dark : t.common.system; -} - -export function getServiceTypeLabel(language: PanelLanguage, command: ServiceCommand): string { - const t = getPanelText(language); - if (command === 'socks') { - return t.settings.typeSocks; - } - - if (command === 'proxy') { - return t.settings.typeProxy; - } - - return t.settings.typeAdmin; -}
{text.users.user}{text.users.endpoint}{text.common.status}{text.users.used}{text.users.remaining}{text.users.share}{text.users.proxy}{text.users.actions}UserEndpointStatusUsedRemainingShareProxyActions
- {service?.name ?? text.common.unknownService} + {service?.name ?? 'Unknown service'} {endpoint}
- {text.status[displayStatus]} + {displayStatus} {formatBytes(user.usedBytes)}{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)}{formatQuotaState(user.usedBytes, user.quotaBytes)} {formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)} @@ -504,14 +482,14 @@ function UsersTab({ className="button-secondary button-small" onClick={() => onTogglePause(user.id)} > - {user.paused ? text.users.resume : text.users.pause} + {user.paused ? 'Resume' : 'Pause'}