mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Add persisted locale preference
This commit is contained in:
parent
2a33c3a09a
commit
afed9fe2ca
8 changed files with 339 additions and 25 deletions
|
|
@ -177,12 +177,15 @@ work extends shared components instead of creating new local variants.
|
|||
|
||||
Frontend localization is a shared primitive boundary. Locale support must flow
|
||||
through typed message catalogs with an English fallback and explicit seed
|
||||
locale coverage rather than page-local string switches. Customer-facing shell,
|
||||
navigation, settings, first-run, empty-state, and alert copy may be localized,
|
||||
but machine-facing values must remain stable: commands, environment variables,
|
||||
API fields, config keys, log lines, error codes, hostnames, resource names, and
|
||||
vendor object names stay untranslated unless the owning runtime contract
|
||||
explicitly says otherwise.
|
||||
locale coverage rather than page-local string switches. The active app locale
|
||||
is a shared user preference initialized from stored or browser language and
|
||||
exposed through Settings > General; individual surfaces must consume that
|
||||
shared preference instead of creating local language toggles. Customer-facing
|
||||
shell, navigation, settings, first-run, empty-state, and alert copy may be
|
||||
localized, but machine-facing values must remain stable: commands, environment
|
||||
variables, API fields, config keys, log lines, error codes, hostnames, resource
|
||||
names, and vendor object names stay untranslated unless the owning runtime
|
||||
contract explicitly says otherwise.
|
||||
|
||||
Alert thresholds consume the shared FilterBar primitive and route state, while
|
||||
the alerts subsystem owns the resource data and platform-specific threshold
|
||||
|
|
|
|||
|
|
@ -8,8 +8,17 @@ import type { TelemetryPreviewResponse } from '@/api/settings';
|
|||
import { DockerRuntimeSettingsCard } from './DockerRuntimeSettingsCard';
|
||||
import Sun from 'lucide-solid/icons/sun';
|
||||
import Moon from 'lucide-solid/icons/moon';
|
||||
import Languages from 'lucide-solid/icons/languages';
|
||||
import Thermometer from 'lucide-solid/icons/thermometer';
|
||||
import Maximize2 from 'lucide-solid/icons/maximize-2';
|
||||
import {
|
||||
activeLocale,
|
||||
setLocalePreference,
|
||||
SUPPORTED_LOCALE_LABELS,
|
||||
SUPPORTED_LOCALES,
|
||||
t,
|
||||
type SupportedLocale,
|
||||
} from '@/i18n';
|
||||
import { temperatureStore } from '@/utils/temperature';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
import {
|
||||
|
|
@ -21,12 +30,18 @@ import { PRIVACY_DOC_URL } from '@/utils/docsLinks';
|
|||
|
||||
import Laptop from 'lucide-solid/icons/laptop';
|
||||
|
||||
const THEME_PREFERENCE_OPTIONS: FilterOption<'light' | 'dark' | 'system'>[] = [
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Laptop },
|
||||
const getThemePreferenceOptions = (): FilterOption<'light' | 'dark' | 'system'>[] => [
|
||||
{ value: 'light', label: t('settings.general.theme.option.light'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.general.theme.option.dark'), icon: Moon },
|
||||
{ value: 'system', label: t('settings.general.theme.option.system'), icon: Laptop },
|
||||
];
|
||||
|
||||
const getLocalePreferenceOptions = (): FilterOption<SupportedLocale>[] =>
|
||||
SUPPORTED_LOCALES.map((locale) => ({
|
||||
value: locale,
|
||||
label: SUPPORTED_LOCALE_LABELS[locale],
|
||||
}));
|
||||
|
||||
const TEMPERATURE_UNIT_OPTIONS: FilterOption<'celsius' | 'fahrenheit'>[] = [
|
||||
{ value: 'celsius', label: 'Celsius' },
|
||||
{ value: 'fahrenheit', label: 'Fahrenheit' },
|
||||
|
|
@ -84,7 +99,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Appearance Card */}
|
||||
<SettingsPanel title="Appearance" noPadding bodyClass="divide-y divide-border">
|
||||
<SettingsPanel
|
||||
title={t('settings.general.appearance.title')}
|
||||
noPadding
|
||||
bodyClass="divide-y divide-border"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{/* Animated theme icon */}
|
||||
|
|
@ -103,21 +122,48 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-muted min-w-0">
|
||||
<p class="font-medium text-base-content truncate">Theme preference</p>
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{t('settings.general.theme.title')}
|
||||
</p>
|
||||
<p class="text-xs text-muted line-clamp-2">
|
||||
Choose light, dark, or sync with your system theme.
|
||||
{t('settings.general.theme.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FilterButtonGroup
|
||||
class="w-full sm:w-auto sm:shrink-0 max-w-full"
|
||||
options={THEME_PREFERENCE_OPTIONS}
|
||||
options={getThemePreferenceOptions()}
|
||||
value={props.themePreference()}
|
||||
onChange={props.setThemePreference}
|
||||
variant="settings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
|
||||
<Languages class="w-5 h-5 text-slate-500" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="text-sm text-muted min-w-0">
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{t('settings.general.language.title')}
|
||||
</p>
|
||||
<p class="text-xs text-muted line-clamp-2">
|
||||
{t('settings.general.language.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FilterButtonGroup
|
||||
class="w-full sm:w-auto sm:shrink-0 max-w-full"
|
||||
options={getLocalePreferenceOptions()}
|
||||
value={activeLocale()}
|
||||
onChange={setLocalePreference}
|
||||
variant="settings"
|
||||
ariaLabel={t('settings.general.language.ariaLabel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Temperature Unit Selector */}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
|
|
@ -125,9 +171,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||
<Thermometer class="w-5 h-5" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="text-sm text-muted min-w-0">
|
||||
<p class="font-medium text-base-content truncate">Temperature unit</p>
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{t('settings.general.temperature.title')}
|
||||
</p>
|
||||
<p class="text-xs text-muted line-clamp-2">
|
||||
Display temperatures in Celsius or Fahrenheit
|
||||
{t('settings.general.temperature.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,9 +195,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||
<Maximize2 class="w-5 h-5 text-slate-500" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="text-sm text-muted min-w-0">
|
||||
<p class="font-medium text-base-content truncate">Full-width mode</p>
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{t('settings.general.fullWidth.title')}
|
||||
</p>
|
||||
<p class="text-xs text-muted line-clamp-2">
|
||||
Expand content to use all available screen width on large monitors
|
||||
{t('settings.general.fullWidth.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_LOCALE, getActiveLocale, setActiveLocale } from '@/i18n';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { GeneralSettingsPanel, type GeneralSettingsPanelProps } from '../GeneralSettingsPanel';
|
||||
|
||||
function renderGeneralSettingsPanel() {
|
||||
const [themePreference, setThemePreference] = createSignal<'light' | 'dark' | 'system'>('system');
|
||||
const [pvePollingInterval, setPVEPollingInterval] = createSignal(30);
|
||||
const [pvePollingSelection, setPVEPollingSelection] = createSignal<number | 'custom'>(30);
|
||||
const [pvePollingCustomSeconds, setPVEPollingCustomSeconds] = createSignal(30);
|
||||
const [, setHasUnsavedChanges] = createSignal(false);
|
||||
const [telemetryEnabled] = createSignal(true);
|
||||
const [disableDockerUpdateActions] = createSignal(false);
|
||||
|
||||
const props: GeneralSettingsPanelProps = {
|
||||
darkMode: () => false,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
pvePollingInterval,
|
||||
setPVEPollingInterval,
|
||||
pvePollingSelection,
|
||||
setPVEPollingSelection,
|
||||
pvePollingCustomSeconds,
|
||||
setPVEPollingCustomSeconds,
|
||||
pvePollingEnvLocked: () => false,
|
||||
setHasUnsavedChanges,
|
||||
telemetryEnabled,
|
||||
telemetryEnabledLocked: () => false,
|
||||
savingTelemetry: () => false,
|
||||
handleTelemetryEnabledChange: vi.fn(),
|
||||
telemetryPreview: () => null,
|
||||
telemetryPreviewEnabled: telemetryEnabled,
|
||||
telemetryPreviewPayload: () => '',
|
||||
loadingTelemetryPreview: () => false,
|
||||
resettingTelemetryInstallID: () => false,
|
||||
handleLoadTelemetryPreview: vi.fn(),
|
||||
handleCopyTelemetryPreview: vi.fn(),
|
||||
handleResetTelemetryInstallID: vi.fn(),
|
||||
disableDockerUpdateActions,
|
||||
disableDockerUpdateActionsLocked: () => false,
|
||||
savingDockerUpdateActions: () => false,
|
||||
handleDisableDockerUpdateActionsChange: vi.fn(),
|
||||
};
|
||||
|
||||
const result = render(() => <GeneralSettingsPanel {...props} />);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('GeneralSettingsPanel localization', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE);
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE);
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
it('surfaces the shared locale preference in Appearance settings', () => {
|
||||
renderGeneralSettingsPanel();
|
||||
|
||||
expect(screen.getByText('Appearance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Language')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Commands, resource names, and API fields stay unchanged/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'English' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Deutsch' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Español' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists locale selection and immediately re-renders localized settings copy', () => {
|
||||
renderGeneralSettingsPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deutsch' }));
|
||||
|
||||
expect(getActiveLocale()).toBe('de');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de');
|
||||
expect(screen.getByText('Darstellung')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sprache')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Deutsch' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
|
|
@ -5,15 +5,21 @@ import {
|
|||
I18N_MESSAGES,
|
||||
NEXT_LOCALIZATION_LOCALES,
|
||||
SUPPORTED_LOCALES,
|
||||
detectBrowserLocale,
|
||||
getActiveLocale,
|
||||
getInitialLocalePreference,
|
||||
getStoredLocalePreference,
|
||||
normalizeLocale,
|
||||
setActiveLocale,
|
||||
setLocalePreference,
|
||||
t,
|
||||
type I18nMessageKey,
|
||||
} from '@/i18n';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
|
||||
describe('i18n foundation', () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE);
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
|
|
@ -30,6 +36,39 @@ describe('i18n foundation', () => {
|
|||
expect(t('settings.shell.navigationTitle')).toBe('Ajustes');
|
||||
});
|
||||
|
||||
it('detects initial locale from stored preference before browser language', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCALE_PREFERENCE, 'es-MX');
|
||||
|
||||
expect(getStoredLocalePreference(localStorage)).toBe('es');
|
||||
expect(
|
||||
getInitialLocalePreference({
|
||||
storage: localStorage,
|
||||
browserLocales: ['de-DE'],
|
||||
}),
|
||||
).toBe('es');
|
||||
});
|
||||
|
||||
it('uses the first supported browser locale when no preference is stored', () => {
|
||||
localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE);
|
||||
|
||||
expect(detectBrowserLocale(['fr-FR', 'es-ES', 'de-DE'])).toBe('es');
|
||||
expect(
|
||||
getInitialLocalePreference({
|
||||
storage: localStorage,
|
||||
browserLocales: ['fr-FR', 'de-AT'],
|
||||
}),
|
||||
).toBe('de');
|
||||
});
|
||||
|
||||
it('persists explicit locale preferences separately from in-memory locale changes', () => {
|
||||
expect(setLocalePreference('de-DE', localStorage)).toBe('de');
|
||||
expect(getActiveLocale()).toBe('de');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de');
|
||||
|
||||
expect(setActiveLocale('es')).toBe('es');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de');
|
||||
});
|
||||
|
||||
it('falls back to the English catalog for unsupported locales and interpolates params', () => {
|
||||
expect(t('settings.shell.searchEmpty', { query: 'alerts' }, 'pt-BR')).toBe(
|
||||
'No settings found for "alerts"',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,84 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { DEFAULT_LOCALE, normalizeLocale, type SupportedLocale } from './locales';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
normalizeLocale,
|
||||
resolveSupportedLocale,
|
||||
type SupportedLocale,
|
||||
} from './locales';
|
||||
import { EN_MESSAGES, I18N_MESSAGES, type I18nMessageKey } from './messages';
|
||||
|
||||
type MessageParams = Record<string, string | number | boolean | null | undefined>;
|
||||
type LocalePreferenceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
const [activeLocaleSignal, setActiveLocaleSignal] = createSignal<SupportedLocale>(DEFAULT_LOCALE);
|
||||
function getDefaultStorage(): LocalePreferenceStorage | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeGetLocalePreference(storage: LocalePreferenceStorage | null): string | null {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
return storage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetLocalePreference(
|
||||
locale: SupportedLocale,
|
||||
storage: LocalePreferenceStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(STORAGE_KEYS.LOCALE_PREFERENCE, locale);
|
||||
} catch {
|
||||
// Locale selection remains active in-memory when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function getNavigatorLocaleCandidates(): string[] {
|
||||
if (typeof navigator === 'undefined') return [];
|
||||
const candidates = Array.isArray(navigator.languages) ? [...navigator.languages] : [];
|
||||
if (navigator.language) {
|
||||
candidates.push(navigator.language);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function getStoredLocalePreference(
|
||||
storage: LocalePreferenceStorage | null = getDefaultStorage(),
|
||||
): SupportedLocale | null {
|
||||
return resolveSupportedLocale(safeGetLocalePreference(storage));
|
||||
}
|
||||
|
||||
export function detectBrowserLocale(
|
||||
candidates: readonly string[] = getNavigatorLocaleCandidates(),
|
||||
) {
|
||||
for (const candidate of candidates) {
|
||||
const locale = resolveSupportedLocale(candidate);
|
||||
if (locale) return locale;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function getInitialLocalePreference({
|
||||
storage = getDefaultStorage(),
|
||||
browserLocales,
|
||||
}: {
|
||||
storage?: LocalePreferenceStorage | null;
|
||||
browserLocales?: readonly string[];
|
||||
} = {}): SupportedLocale {
|
||||
return getStoredLocalePreference(storage) ?? detectBrowserLocale(browserLocales);
|
||||
}
|
||||
|
||||
const [activeLocaleSignal, setActiveLocaleSignal] = createSignal<SupportedLocale>(
|
||||
getInitialLocalePreference(),
|
||||
);
|
||||
|
||||
export const activeLocale = activeLocaleSignal;
|
||||
|
||||
|
|
@ -18,6 +92,15 @@ export function setActiveLocale(locale: string | null | undefined): SupportedLoc
|
|||
return normalized;
|
||||
}
|
||||
|
||||
export function setLocalePreference(
|
||||
locale: string | null | undefined,
|
||||
storage: LocalePreferenceStorage | null = getDefaultStorage(),
|
||||
): SupportedLocale {
|
||||
const normalized = setActiveLocale(locale);
|
||||
safeSetLocalePreference(normalized, storage);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function translateMessage(
|
||||
key: I18nMessageKey,
|
||||
params: MessageParams = {},
|
||||
|
|
@ -43,5 +126,6 @@ export {
|
|||
SUPPORTED_LOCALES,
|
||||
isSupportedLocale,
|
||||
normalizeLocale,
|
||||
resolveSupportedLocale,
|
||||
type SupportedLocale,
|
||||
} from './locales';
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ export const NEXT_LOCALIZATION_LOCALES = ['fr', 'pt-BR', 'ja', 'zh-Hans', 'ko']
|
|||
|
||||
export const SUPPORTED_LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||
en: 'English',
|
||||
de: 'German',
|
||||
es: 'Spanish',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
};
|
||||
|
||||
const SUPPORTED_LOCALE_SET = new Set<string>(SUPPORTED_LOCALES);
|
||||
|
|
@ -39,12 +39,16 @@ export function isSupportedLocale(value: string): value is SupportedLocale {
|
|||
return SUPPORTED_LOCALE_SET.has(value);
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
export function resolveSupportedLocale(value: string | null | undefined): SupportedLocale | null {
|
||||
const normalized = value?.trim().replace('_', '-').toLowerCase();
|
||||
if (!normalized) return DEFAULT_LOCALE;
|
||||
if (!normalized) return null;
|
||||
if (isSupportedLocale(normalized)) return normalized;
|
||||
if (LOCALE_ALIASES[normalized]) return LOCALE_ALIASES[normalized];
|
||||
|
||||
const baseLocale = normalized.split('-')[0] ?? '';
|
||||
return isSupportedLocale(baseLocale) ? baseLocale : DEFAULT_LOCALE;
|
||||
return isSupportedLocale(baseLocale) ? baseLocale : null;
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return resolveSupportedLocale(value) ?? DEFAULT_LOCALE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import type { SupportedLocale } from './locales';
|
||||
|
||||
export const EN_MESSAGES = {
|
||||
'settings.general.appearance.title': 'Appearance',
|
||||
'settings.general.fullWidth.description':
|
||||
'Expand content to use all available screen width on large monitors.',
|
||||
'settings.general.fullWidth.title': 'Full-width mode',
|
||||
'settings.general.language.ariaLabel': 'App language',
|
||||
'settings.general.language.description':
|
||||
'Use this language for the app shell. Commands, resource names, and API fields stay unchanged.',
|
||||
'settings.general.language.title': 'Language',
|
||||
'settings.general.temperature.description': 'Display temperatures in Celsius or Fahrenheit.',
|
||||
'settings.general.temperature.title': 'Temperature unit',
|
||||
'settings.general.theme.description': 'Choose light, dark, or sync with your system theme.',
|
||||
'settings.general.theme.option.dark': 'Dark',
|
||||
'settings.general.theme.option.light': 'Light',
|
||||
'settings.general.theme.option.system': 'System',
|
||||
'settings.general.theme.title': 'Theme preference',
|
||||
'settings.header.api.description':
|
||||
'Generate and manage scoped Pulse tokens for agents, automation, and external integrations.',
|
||||
'settings.header.api.title': 'API Access',
|
||||
|
|
@ -133,6 +148,21 @@ export const I18N_MESSAGES: Record<SupportedLocale, Record<I18nMessageKey, strin
|
|||
en: EN_MESSAGES,
|
||||
de: {
|
||||
...EN_MESSAGES,
|
||||
'settings.general.appearance.title': 'Darstellung',
|
||||
'settings.general.fullWidth.description':
|
||||
'Nutzen Sie auf grossen Monitoren die gesamte verfuegbare Bildschirmbreite.',
|
||||
'settings.general.fullWidth.title': 'Volle Breite',
|
||||
'settings.general.language.ariaLabel': 'App-Sprache',
|
||||
'settings.general.language.description':
|
||||
'Verwenden Sie diese Sprache fuer die App-Oberflaeche. Befehle, Ressourcennamen und API-Felder bleiben unveraendert.',
|
||||
'settings.general.language.title': 'Sprache',
|
||||
'settings.general.temperature.description': 'Temperaturen in Celsius oder Fahrenheit anzeigen.',
|
||||
'settings.general.temperature.title': 'Temperatureinheit',
|
||||
'settings.general.theme.description': 'Waehlen Sie hell, dunkel oder die Systemeinstellung.',
|
||||
'settings.general.theme.option.dark': 'Dunkel',
|
||||
'settings.general.theme.option.light': 'Hell',
|
||||
'settings.general.theme.option.system': 'System',
|
||||
'settings.general.theme.title': 'Designpraeferenz',
|
||||
'settings.header.api.description':
|
||||
'Erstellen und verwalten Sie begrenzte Pulse-Tokens fuer Agents, Automatisierung und externe Integrationen.',
|
||||
'settings.header.api.title': 'API-Zugriff',
|
||||
|
|
@ -262,6 +292,22 @@ export const I18N_MESSAGES: Record<SupportedLocale, Record<I18nMessageKey, strin
|
|||
},
|
||||
es: {
|
||||
...EN_MESSAGES,
|
||||
'settings.general.appearance.title': 'Apariencia',
|
||||
'settings.general.fullWidth.description':
|
||||
'Expande el contenido para usar todo el ancho disponible en monitores grandes.',
|
||||
'settings.general.fullWidth.title': 'Modo de ancho completo',
|
||||
'settings.general.language.ariaLabel': 'Idioma de la app',
|
||||
'settings.general.language.description':
|
||||
'Usa este idioma para la interfaz de la app. Los comandos, nombres de recursos y campos API no cambian.',
|
||||
'settings.general.language.title': 'Idioma',
|
||||
'settings.general.temperature.description': 'Muestra temperaturas en Celsius o Fahrenheit.',
|
||||
'settings.general.temperature.title': 'Unidad de temperatura',
|
||||
'settings.general.theme.description':
|
||||
'Elige claro, oscuro o sincroniza con el tema del sistema.',
|
||||
'settings.general.theme.option.dark': 'Oscuro',
|
||||
'settings.general.theme.option.light': 'Claro',
|
||||
'settings.general.theme.option.system': 'Sistema',
|
||||
'settings.general.theme.title': 'Preferencia de tema',
|
||||
'settings.header.api.description':
|
||||
'Genera y administra tokens de Pulse con alcance limitado para agentes, automatización e integraciones externas.',
|
||||
'settings.header.api.title': 'Acceso API',
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ export const STORAGE_KEYS = {
|
|||
|
||||
// UI preferences
|
||||
THEME_PREFERENCE: 'pulseThemePreference',
|
||||
LOCALE_PREFERENCE: 'pulseLocalePreference',
|
||||
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
|
||||
FULL_WIDTH_MODE: 'fullWidthMode',
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue