diff --git a/docs/PLAN.md b/docs/PLAN.md index 49c39da..d24da5a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -31,3 +31,4 @@ 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 35180df..088c5fa 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -29,6 +29,8 @@ 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 5c4005a..359ff38 100644 --- a/server/app.test.ts +++ b/server/app.test.ts @@ -153,6 +153,24 @@ 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 58c125b..9b89ae7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -129,8 +129,23 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) app.put('/api/system', async (request, response, next) => { try { const state = await store.read(); - state.system = validateSystemInput(request.body as Partial, state.userRecords); - state.service.lastEvent = 'System configuration updated from panel'; + 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'; 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 bd396eb..dc29511 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -11,6 +11,7 @@ async function loginIntoPanel(user: ReturnType) { beforeEach(() => { window.sessionStorage.clear(); + window.localStorage.clear(); }); describe('App login gate', () => { @@ -50,6 +51,23 @@ 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(); @@ -107,26 +125,43 @@ describe('App login gate', () => { expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument(); }); - it('saves system settings from the system tab and applies them to the local fallback state', async () => { + it('saves service settings from the settings 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: /system/i })); - - await user.clear(screen.getByLabelText(/public host/i)); - await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net'); + await user.click(screen.getByRole('button', { name: /settings/i })); const firstPortInput = screen.getAllByLabelText(/port/i)[0]; await user.clear(firstPortInput); await user.type(firstPortInput, '1180'); - 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: /save settings/i })); await user.click(screen.getByRole('button', { name: /users/i })); - expect(screen.getAllByText(/ops-gateway\.example\.net:1180/i).length).toBeGreaterThan(0); + 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(); }); }); diff --git a/src/App.tsx b/src/App.tsx index 64deec2..ff6facb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,12 @@ 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, @@ -22,10 +23,10 @@ import { quotaMbToBytes, validateCreateUserInput } from './shared/validation'; type TabId = 'dashboard' | 'users' | 'system'; -const tabs: Array<{ id: TabId; label: string }> = [ - { id: 'dashboard', label: 'Dashboard' }, - { id: 'users', label: 'Users' }, - { id: 'system', label: 'System' }, +const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> = [ + { id: 'dashboard', textKey: 'dashboard' }, + { id: 'users', textKey: 'users' }, + { id: 'system', textKey: 'settings' }, ]; const SESSION_KEY = '3proxy-ui-panel-session'; @@ -36,7 +37,14 @@ interface StoredSession { expiresAt: string; } -function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) => Promise }) { +function LoginGate({ + onUnlock, + preferences, +}: { + onUnlock: (login: string, password: string) => Promise; + preferences: PanelPreferences; +}) { + const text = getPanelText(preferences.language); const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -60,12 +68,12 @@ function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) =
-

3proxy UI

-

Sign in to the control panel.

+

{text.auth.title}

+

{text.auth.subtitle}

{error ?

{error}

: null}
@@ -100,12 +108,15 @@ 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 ?? ''); @@ -156,22 +167,22 @@ function AddUserModal({ onClick={stopPropagation} >
-

Add user

+

{text.users.addUser}

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

{error}

: null}
- +
@@ -209,11 +220,14 @@ 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') { @@ -240,18 +254,18 @@ function ConfirmDeleteModal({ onClick={stopPropagation} >
-

Delete user

+

{text.users.deleteTitle}

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

@@ -262,62 +276,65 @@ 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 (
-

Service

- {snapshot.service.status} +

{text.dashboard.service}

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

Traffic

+

{text.dashboard.traffic}

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

Daily usage

+

{text.dashboard.dailyUsage}

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

Attention

+

{text.dashboard.attention}

{snapshot.attention.map((item) => ( @@ -365,12 +382,15 @@ 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); @@ -395,35 +415,37 @@ function UsersTab({
-

Users

-

{snapshot.userRecords.length} accounts in current profile

+

{text.users.title}

+

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

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

Enable an assignable service in System before creating new users.

+

{text.users.enableServiceHint}

) : null}
- - - - - - - - + + + + + + + + @@ -431,7 +453,7 @@ function UsersTab({ const service = servicesById.get(user.serviceId); const endpoint = service ? `${snapshot.system.publicHost}:${service.port}` - : 'service missing'; + : text.common.serviceMissing; const proxyLink = service ? buildProxyLink( user.username, @@ -453,17 +475,17 @@ function UsersTab({ - + @@ -508,6 +530,7 @@ function UsersTab({ services={assignableServices} onClose={() => setIsModalOpen(false)} onCreate={onCreateUser} + preferences={preferences} /> ) : null} @@ -519,6 +542,7 @@ function UsersTab({ await onDeleteUser(deleteTarget.id); setDeleteTargetId(null); }} + preferences={preferences} /> ) : null} @@ -526,9 +550,24 @@ 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(); @@ -605,7 +644,7 @@ export default function App() { }, [session]); if (!session) { - return ; + return ; } const mutateSnapshot = async ( @@ -760,6 +799,7 @@ export default function App() { } if (!payload) { + const nextServiceIds = new Set(input.services.map((service) => service.id)); setSnapshot((current) => withDerivedSnapshot({ ...current, @@ -767,6 +807,7 @@ 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, @@ -788,15 +829,15 @@ export default function App() {
- Status - {snapshot.service.status} + {text.common.status} + {text.status[snapshot.service.status]}
- Version + {text.common.version} {snapshot.service.versionLabel}
- Users + {text.common.users} {snapshot.users.total}
@@ -817,21 +858,31 @@ export default function App() { className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} onClick={() => setActiveTab(tab.id)} > - {tab.label} + {text.tabs[tab.textKey]} ))} - {activeTab === 'dashboard' ? : null} + {activeTab === 'dashboard' ? ( + + ) : null} {activeTab === 'users' ? ( + ) : null} + {activeTab === 'system' ? ( + ) : null} - {activeTab === 'system' ? : null} ); } @@ -864,6 +915,26 @@ 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 e060cb5..e68064a 100644 --- a/src/SystemTab.tsx +++ b/src/SystemTab.tsx @@ -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; } -export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { +export default function SystemTab({ + snapshot, + preferences, + onPreferencesChange, + 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, @@ -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 ( -
-
-
-
-

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.common.panelPreferences}

+
+
+ + +
+
-
-
-

Services

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

{service.id}

-
- -
-
- - - + {text.common.remove} + +
+
+ + - -
-
- - -
-
- ))} -
- {error ?

{error}

: null} -
- - -
-
+ {text.settings.serviceType} + + + + +
+ + +
+
+ ))} + + {error ?

{error}

: null} +
+ + +
+ -
-
-

Generated config

-
-
{snapshot.system.previewConfig}
-
-
- +
+
+

{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} + ); } diff --git a/src/app.css b/src/app.css index 774c2f2..37b6a0a 100644 --- a/src/app.css +++ b/src/app.css @@ -22,6 +22,23 @@ --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; } @@ -372,13 +389,13 @@ button, .usage-bar { height: 8px; border-radius: 999px; - background: #eceff3; + background: color-mix(in srgb, var(--border) 70%, transparent); overflow: hidden; } .usage-bar div { height: 100%; - background: #94a3b8; + background: color-mix(in srgb, var(--accent) 55%, var(--muted)); } .event-row, @@ -608,8 +625,8 @@ pre { overflow: auto; border: 1px solid var(--border); border-radius: 10px; - background: #fbfbfc; - color: #1f2937; + background: color-mix(in srgb, var(--surface-muted) 80%, var(--surface)); + color: var(--text); font: 13px/1.55 Consolas, "Courier New", monospace; } diff --git a/src/lib/panelPreferences.ts b/src/lib/panelPreferences.ts new file mode 100644 index 0000000..2b4e6f5 --- /dev/null +++ b/src/lib/panelPreferences.ts @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..71f8669 --- /dev/null +++ b/src/lib/panelText.ts @@ -0,0 +1,269 @@ +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; +}
UserEndpointStatusUsedRemainingShareProxyActions{text.users.user}{text.users.endpoint}{text.common.status}{text.users.used}{text.users.remaining}{text.users.share}{text.users.proxy}{text.users.actions}
- {service?.name ?? 'Unknown service'} + {service?.name ?? text.common.unknownService} {endpoint}
- {displayStatus} + {text.status[displayStatus]} {formatBytes(user.usedBytes)}{formatQuotaState(user.usedBytes, user.quotaBytes)}{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)} {formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)} @@ -482,14 +504,14 @@ function UsersTab({ className="button-secondary button-small" onClick={() => onTogglePause(user.id)} > - {user.paused ? 'Resume' : 'Pause'} + {user.paused ? text.users.resume : text.users.pause}