feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped

This commit is contained in:
2026-06-05 17:13:03 +02:00
parent 8ecfc9a1fe
commit ee293d7daf
301 changed files with 8247 additions and 2218 deletions

View File

@@ -1,7 +1,7 @@
@if (server()) {
<div class="max-w-2xl space-y-3">
@if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
<p class="text-sm text-muted-foreground text-center py-8">{{ 'settings.bans.empty' | translate }}</p>
} @else {
@for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
@@ -10,15 +10,15 @@
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
{{ ban.displayName || 'Unknown User' }}
{{ ban.displayName || ('settings.bans.unknownUser' | translate) }}
</p>
@if (ban.reason) {
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
<p class="text-xs text-muted-foreground truncate">{{ 'settings.bans.reason' | translate: { reason: ban.reason } }}</p>
}
@if (ban.expiresAt) {
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.bans.expires' | translate: { date: formatExpiry(ban.expiresAt) } }}</p>
} @else {
<p class="text-xs text-destructive">Permanent</p>
<p class="text-xs text-destructive">{{ 'settings.bans.permanent' | translate }}</p>
}
</div>
@if (isAdmin()) {
@@ -38,5 +38,5 @@
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">{{ 'settings.bans.selectServer' | translate }}</div>
}

View File

@@ -17,11 +17,12 @@ import { Room, BanEntry } from '../../../../shared-kernel';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-bans-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideX

View File

@@ -7,10 +7,10 @@
name="lucideDatabase"
class="h-5 w-5"
/>
<h4 class="text-base font-semibold text-foreground">Local data</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
</div>
<p class="mt-2 text-sm text-muted-foreground">
Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.
{{ 'settings.data.localData.description' | translate }}
</p>
</div>
@@ -33,14 +33,14 @@
@if (!isElectron) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Data management is only available in the packaged Electron desktop app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
</section>
} @else {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Current data folder</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
{{ dataPath() || 'Resolving data folder...' }}
{{ dataPath() || ('settings.data.currentFolder.resolving' | translate) }}
</p>
</div>
@@ -54,15 +54,15 @@
name="lucideFolderOpen"
class="h-4 w-4"
/>
{{ busyAction() === 'open' ? 'Opening...' : 'Open folder' }}
{{ busyAction() === 'open' ? ('settings.data.opening' | translate) : ('settings.data.openFolder' | translate) }}
</button>
</section>
<section class="grid gap-4 md:grid-cols-2">
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Export data</h5>
<p class="mt-1 text-sm text-muted-foreground">Create a portable .dat archive that can be imported on another client.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.export.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.export.description' | translate }}</p>
</div>
<button
@@ -75,14 +75,14 @@
name="lucideDownload"
class="h-4 w-4"
/>
{{ busyAction() === 'export' ? 'Exporting...' : 'Export data' }}
{{ busyAction() === 'export' ? ('settings.data.export.exporting' | translate) : ('settings.data.export.button' | translate) }}
</button>
</div>
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Import all data</h5>
<p class="mt-1 text-sm text-muted-foreground">Restore a .dat archive. Existing local data is moved to a backup folder first.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.import.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.import.description' | translate }}</p>
</div>
<button
@@ -95,15 +95,15 @@
name="lucideUpload"
class="h-4 w-4"
/>
{{ busyAction() === 'import' ? 'Importing...' : 'Import data' }}
{{ busyAction() === 'import' ? ('settings.data.import.importing' | translate) : ('settings.data.import.button' | translate) }}
</button>
</div>
</section>
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Erase user data</h5>
<p class="mt-1 text-sm text-muted-foreground">Remove local app data from this device and recreate an empty database.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.description' | translate }}</p>
</div>
<button
@@ -116,7 +116,7 @@
name="lucideTrash2"
class="h-4 w-4"
/>
{{ busyAction() === 'erase' ? 'Erasing...' : 'Erase user data' }}
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
</button>
</section>

View File

@@ -16,13 +16,14 @@ import {
} from '@ng-icons/lucide';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
@Component({
selector: 'app-data-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideDatabase,
@@ -37,6 +38,7 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
})
export class DataSettingsComponent {
private readonly electron = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.electron.isAvailable;
readonly dataPath = signal<string | null>(null);
@@ -53,7 +55,9 @@ export class DataSettingsComponent {
await this.runAction('open', async () => {
const opened = await this.electron.requireApi().openCurrentDataFolder();
this.statusMessage.set(opened ? 'Opened the current data folder.' : 'Could not open the data folder.');
this.statusMessage.set(opened
? this.appI18n.instant('settings.data.messages.openedFolder')
: this.appI18n.instant('settings.data.messages.couldNotOpenFolder'));
});
}
@@ -62,16 +66,18 @@ export class DataSettingsComponent {
const result = await this.electron.requireApi().exportUserData();
if (result.cancelled) {
this.statusMessage.set('Export cancelled.');
this.statusMessage.set(this.appI18n.instant('settings.data.messages.exportCancelled'));
return;
}
this.statusMessage.set(result.filePath ? `Exported data to ${result.filePath}.` : 'Exported data.');
this.statusMessage.set(result.filePath
? this.appI18n.instant('settings.data.messages.exportedTo', { path: result.filePath })
: this.appI18n.instant('settings.data.messages.exported'));
});
}
async importData(): Promise<void> {
if (!window.confirm('Importing data replaces the current local data. Existing data will be moved to a backup folder first. Continue?')) {
if (!window.confirm(this.appI18n.instant('settings.data.import.confirm'))) {
return;
}
@@ -79,19 +85,19 @@ export class DataSettingsComponent {
const result = await this.electron.requireApi().importUserData();
if (result.cancelled) {
this.statusMessage.set('Import cancelled.');
this.statusMessage.set(this.appI18n.instant('settings.data.messages.importCancelled'));
return;
}
this.restartRequired.set(result.restartRequired);
this.statusMessage.set(result.backupPath
? `Imported data. Previous data was backed up to ${result.backupPath}.`
: 'Imported data.');
? this.appI18n.instant('settings.data.messages.importedWithBackup', { path: result.backupPath })
: this.appI18n.instant('settings.data.messages.imported'));
});
}
async eraseData(): Promise<void> {
if (!window.confirm('Erase all local MetoYou data on this device? This cannot be undone.')) {
if (!window.confirm(this.appI18n.instant('settings.data.erase.confirm'))) {
return;
}
@@ -99,7 +105,7 @@ export class DataSettingsComponent {
const result = await this.electron.requireApi().eraseUserData();
this.restartRequired.set(result.restartRequired);
this.statusMessage.set('Local data erased. Restart the app to finish resetting the session.');
this.statusMessage.set(this.appI18n.instant('settings.data.messages.erased'));
await this.loadDataPath();
});
}
@@ -130,7 +136,7 @@ export class DataSettingsComponent {
try {
await operation();
} catch (error) {
this.errorMessage.set(error instanceof Error ? error.message : 'Data operation failed.');
this.errorMessage.set(error instanceof Error ? error.message : this.appI18n.instant('settings.data.messages.operationFailed'));
} finally {
this.busyAction.set(null);
}

View File

@@ -10,9 +10,9 @@
</div>
<div>
<h4 class="text-sm font-semibold text-foreground">App-wide debugging</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.debugging.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.
{{ 'settings.debugging.description' | translate }}
</p>
</div>
</div>
@@ -39,11 +39,11 @@
name="lucideMemoryStick"
class="h-4 w-4"
/>
<span class="text-sm">Process RAM</span>
<span class="text-sm">{{ 'settings.debugging.processRam' | translate }}</span>
</div>
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">Live total working set from Electron app metrics. Updates every 2 seconds.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p>
</section>
}
@@ -54,10 +54,10 @@
name="lucideClock3"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Captured events</span>
<span class="text-xs font-medium uppercase tracking-wide">{{ 'settings.debugging.capturedEvents' | translate }}</span>
</div>
<p class="mt-3 text-2xl font-semibold text-foreground">{{ entryCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Last update: {{ lastUpdatedLabel() }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.lastUpdate' | translate: { label: lastUpdatedLabel() } }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
@@ -66,10 +66,10 @@
name="lucideCircleAlert"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Errors</span>
<span class="text-xs font-medium uppercase tracking-wide">{{ 'settings.debugging.errors' | translate }}</span>
</div>
<p class="mt-3 text-2xl font-semibold text-destructive">{{ errorCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Unhandled runtime failures and rejected promises.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.errorsHint' | translate }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
@@ -78,19 +78,19 @@
name="lucideTriangleAlert"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Warnings</span>
<span class="text-xs font-medium uppercase tracking-wide">{{ 'settings.debugging.warnings' | translate }}</span>
</div>
<p class="mt-3 text-2xl font-semibold text-yellow-400">{{ warningCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Navigation cancellations, offline events, and other warnings.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.warningsHint' | translate }}</p>
</div>
</section>
<section class="rounded-lg border border-border bg-card/40 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h5 class="text-sm font-semibold text-foreground">Floating debug console</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.debugging.console.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
When debugging is enabled, a bug icon appears in the app so you can open the docked console without blocking the rest of the UI.
{{ 'settings.debugging.console.description' | translate }}
</p>
</div>
@@ -101,7 +101,7 @@
[disabled]="!enabled()"
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ isConsoleOpen() ? 'Console open' : 'Open console' }}
{{ isConsoleOpen() ? ('settings.debugging.console.openActive' | translate) : ('settings.debugging.console.open' | translate) }}
</button>
<button

View File

@@ -24,13 +24,14 @@ import { DebuggingService } from '../../../../core/services/debugging.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
import { PlatformService } from '../../../../core/platform';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
const APP_METRICS_POLL_INTERVAL_MS = 2_000;
@Component({
selector: 'app-debugging-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideBug,
@@ -48,6 +49,7 @@ export class DebuggingSettingsComponent {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.platform.isElectron;
readonly ramLabel = signal<string | null>(null);
@@ -69,7 +71,7 @@ export class DebuggingSettingsComponent {
readonly lastUpdatedLabel = computed(() => {
const lastEntry = this.debugging.entries().at(-1);
return lastEntry ? lastEntry.timeLabel : 'No logs yet';
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
});
constructor() {

View File

@@ -5,15 +5,15 @@
name="lucidePower"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Application</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.general.application' | translate }}</h4>
</div>
<div class="space-y-3">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Reopen last chat on launch</p>
<p class="text-xs text-muted-foreground">Open the same server and text channel the next time MetoYou starts.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.reopenLastChat.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.reopenLastChat.description' | translate }}</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
@@ -22,7 +22,7 @@
[checked]="reopenLastViewedChat()"
(change)="onReopenLastViewedChatChange($event)"
id="general-reopen-last-chat-toggle"
aria-label="Toggle reopen last chat on launch"
[attr.aria-label]="'settings.general.reopenLastChat.aria' | translate"
class="sr-only peer"
/>
<div
@@ -38,12 +38,12 @@
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.autoStart.label' | translate }}</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.autoStart.description' | translate }}</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.autoStart.desktopOnly' | translate }}</p>
}
</div>
@@ -58,7 +58,7 @@
[disabled]="!isElectron || savingAutoStart()"
(change)="onAutoStartChange($event)"
id="general-auto-start-toggle"
aria-label="Toggle launch on startup"
[attr.aria-label]="'settings.general.autoStart.aria' | translate"
class="sr-only peer"
/>
<div
@@ -74,12 +74,12 @@
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Minimize to tray on close</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.closeToTray.label' | translate }}</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Keep MetoYou running in the tray when you click the X button</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.closeToTray.description' | translate }}</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.autoStart.desktopOnly' | translate }}</p>
}
</div>
@@ -94,7 +94,7 @@
[disabled]="!isElectron || savingCloseToTray()"
(change)="onCloseToTrayChange($event)"
id="general-close-to-tray-toggle"
aria-label="Toggle minimize to tray on close"
[attr.aria-label]="'settings.general.closeToTray.aria' | translate"
class="sr-only peer"
/>
<div
@@ -107,13 +107,13 @@
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Experimental VLC.js playback</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.experimentalVlc.label' | translate }}</p>
@if (experimentalMedia.vlcJsRuntimeStatus() === 'checking') {
<p class="text-xs text-muted-foreground">Checking for a bundled VLC.js runtime...</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.experimentalVlc.checking' | translate }}</p>
} @else if (experimentalMedia.vlcJsRuntimeAvailable()) {
<p class="text-xs text-muted-foreground">Offer a manual player for unsupported downloaded audio and video files.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.experimentalVlc.available' | translate }}</p>
} @else {
<p class="text-xs text-muted-foreground">No VLC.js runtime is bundled. Unsupported desktop media can be opened in the system player.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.experimentalVlc.unavailable' | translate }}</p>
}
</div>
@@ -128,7 +128,7 @@
[disabled]="!experimentalMedia.vlcJsRuntimeAvailable()"
(change)="onExperimentalVlcPlaybackChange($event)"
id="general-experimental-vlc-playback-toggle"
aria-label="Toggle experimental VLC.js playback"
[attr.aria-label]="'settings.general.experimentalVlc.aria' | translate"
class="sr-only peer"
/>
<div
@@ -143,24 +143,23 @@
@if (isElectron) {
<section>
<div class="flex items-center gap-2 mb-3">
<h4 class="text-sm font-semibold text-foreground">Game detection</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.general.gameDetection.title' | translate }}</h4>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<p class="text-xs text-muted-foreground">
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide apps that get mistakenly
identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively against the executable name without its extension.
{{ 'settings.general.gameDetection.description' | translate }}
</p>
<div class="flex items-center gap-2">
<input
type="text"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Process name (e.g. spotify)"
[placeholder]="'settings.general.gameDetection.processPlaceholder' | translate"
[value]="ignoredProcessDraft()"
(input)="onIgnoredProcessDraftChange($event)"
(keydown.enter)="addIgnoredProcess()"
aria-label="Process name to ignore"
[attr.aria-label]="'settings.general.gameDetection.processAria' | translate"
/>
<button
type="button"
@@ -168,12 +167,12 @@
[disabled]="savingIgnoredGameProcesses() || !ignoredProcessDraft().trim()"
(click)="addIgnoredProcess()"
>
Add
{{ 'settings.general.gameDetection.add' | translate }}
</button>
</div>
@if (ignoredGameProcesses().length === 0) {
<p class="text-xs text-muted-foreground italic">No ignored processes yet.</p>
<p class="text-xs text-muted-foreground italic">{{ 'settings.general.gameDetection.empty' | translate }}</p>
} @else {
<ul class="flex flex-wrap gap-2">
@for (entry of ignoredGameProcesses(); track entry) {
@@ -184,7 +183,7 @@
class="text-muted-foreground hover:text-foreground"
[disabled]="savingIgnoredGameProcesses()"
(click)="removeIgnoredProcess(entry)"
[attr.aria-label]="'Remove ' + entry + ' from ignore list'"
[attr.aria-label]="'settings.general.gameDetection.removeProcessAria' | translate: { name: entry }"
>
×
</button>

View File

@@ -13,11 +13,12 @@ import { loadGeneralSettingsFromStorage, saveGeneralSettingsToStorage } from '..
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-general-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucidePower

View File

@@ -5,7 +5,7 @@
name="lucideShield"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">ICE Servers (STUN / TURN)</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.network.ice.title' | translate }}</h4>
</div>
<button
type="button"
@@ -17,13 +17,12 @@
name="lucideRotateCcw"
class="w-3.5 h-3.5"
/>
Restore Defaults
{{ 'settings.network.ice.restoreDefaults' | translate }}
</button>
</div>
<p class="text-xs text-muted-foreground mb-3">
ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have
priority.
{{ 'settings.network.ice.description' | translate }}
</p>
<!-- ICE Server List -->
@@ -52,7 +51,7 @@
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ entry.urls }}</p>
@if (entry.type === 'turn' && entry.username) {
<p class="text-[10px] text-muted-foreground truncate">User: {{ entry.username }}</p>
<p class="text-[10px] text-muted-foreground truncate">{{ 'settings.network.ice.turnUser' | translate: { username: entry.username } }}</p>
}
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
@@ -61,7 +60,7 @@
(click)="moveUp(i)"
[disabled]="i === 0"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move up (higher priority)"
[title]="'settings.network.ice.moveUp' | translate"
>
<ng-icon
name="lucideArrowUp"
@@ -73,7 +72,7 @@
(click)="moveDown(i)"
[disabled]="i === entries().length - 1"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move down (lower priority)"
[title]="'settings.network.ice.moveDown' | translate"
>
<ng-icon
name="lucideArrowDown"
@@ -84,7 +83,7 @@
type="button"
(click)="removeEntry(entry.id)"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove"
[title]="'settings.network.ice.moveDown' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -95,13 +94,13 @@
</div>
}
@if (entries().length === 0) {
<p class="text-xs text-muted-foreground italic py-2">No ICE servers configured. P2P connections may fail across networks.</p>
<p class="text-xs text-muted-foreground italic py-2">{{ 'settings.network.ice.empty' | translate }}</p>
}
</div>
<!-- Add New ICE Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add ICE Server</h4>
<h4 class="text-xs font-medium text-foreground mb-2">{{ 'settings.network.ice.addTitle' | translate }}</h4>
<div class="space-y-1.5">
<div class="flex gap-2">
<select
@@ -116,7 +115,7 @@
type="text"
[(ngModel)]="newUrl"
data-testid="ice-url-input"
[placeholder]="newType === 'stun' ? 'stun:stun.example.com:19302' : 'turn:turn.example.com:3478'"
[placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -126,14 +125,14 @@
type="text"
[(ngModel)]="newUsername"
data-testid="ice-username-input"
placeholder="Username"
[placeholder]="'settings.network.ice.username' | translate"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="password"
[(ngModel)]="newCredential"
data-testid="ice-credential-input"
placeholder="Credential"
[placeholder]="'settings.network.ice.credential' | translate"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -150,7 +149,7 @@
name="lucidePlus"
class="w-3.5 h-3.5"
/>
Add Server
{{ 'settings.network.ice.addServer' | translate }}
</button>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import {
} from '@ng-icons/lucide';
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-ice-server-settings',
@@ -27,7 +28,8 @@ import { IceServerSettingsService, IceServerEntry } from '../../../../infrastruc
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -43,6 +45,7 @@ import { IceServerSettingsService, IceServerEntry } from '../../../../infrastruc
})
export class IceServerSettingsComponent {
private iceSettings = inject(IceServerSettingsService);
private readonly appI18n = inject(AppI18nService);
entries = this.iceSettings.entries;
@@ -58,29 +61,33 @@ export class IceServerSettingsComponent {
const url = this.newUrl.trim();
if (!url) {
this.addError.set('URL is required');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.urlRequired'));
return;
}
const prefix = this.newType === 'stun' ? 'stun:' : 'turn';
if (!url.startsWith(prefix) && !url.startsWith('turns:')) {
this.addError.set(`URL must start with ${this.newType === 'stun' ? 'stun:' : 'turn: or turns:'}`);
this.addError.set(this.appI18n.instant(
this.newType === 'stun'
? 'settings.network.ice.errors.urlPrefixStun'
: 'settings.network.ice.errors.urlPrefixTurn'
));
return;
}
if (this.newType === 'turn' && !this.newUsername.trim()) {
this.addError.set('Username is required for TURN servers');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.usernameRequired'));
return;
}
if (this.newType === 'turn' && !this.newCredential.trim()) {
this.addError.set('Credential is required for TURN servers');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.credentialRequired'));
return;
}
if (this.entries().some((entry) => entry.urls === url)) {
this.addError.set('This URL already exists');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.duplicateUrl'));
return;
}

View File

@@ -2,7 +2,7 @@
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Local HTTP API</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.localApi.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Expose your client to local automation tools and scripts. Authentication is verified against your signaling server, and access is off by
default.
@@ -17,13 +17,13 @@
@if (!isElectron) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">The local API is only available in the packaged Electron desktop app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.localApi.desktopOnly' | translate }}</p>
</section>
} @else {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Server</h5>
<p class="mt-1 text-sm text-muted-foreground">Enable to start a local HTTP server. By default it only listens on the loopback interface.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.localApi.server.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.localApi.server.description' | translate }}</p>
</div>
<label class="flex items-start gap-3">
@@ -35,8 +35,8 @@
(change)="toggleEnabled($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Run local API server</span>
<span class="mt-1 block text-xs text-muted-foreground">Start the HTTP server on this machine.</span>
<span class="font-medium">{{ 'settings.localApi.server.run' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ 'settings.localApi.server.runHint' | translate }}</span>
</span>
</label>
@@ -49,8 +49,8 @@
(change)="toggleDocusaurus($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Serve Docusaurus documentation at <code>/docusaurus</code></span>
<span class="mt-1 block text-xs text-muted-foreground"> Hosts the built app and plugin documentation from local desktop resources. </span>
<span class="font-medium">{{ 'settings.localApi.server.docusaurus' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ 'settings.localApi.server.docusaurusHint' | translate }}</span>
</span>
</label>
@@ -63,16 +63,18 @@
(change)="toggleExposeOnLan($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Allow connections from your network</span>
<span class="font-medium">{{ 'settings.localApi.server.exposeLan' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">
Bind to all interfaces (0.0.0.0). Other devices on your LAN will be able to reach the API. Only enable this on networks you trust.
{{ 'settings.localApi.server.exposeLanHint' | translate }}
</span>
</span>
</label>
<div class="grid gap-3 md:grid-cols-[200px_1fr_auto] md:items-end">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Port</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.localApi.server.port' | translate
}}</span>
<input
type="number"
min="1"
@@ -84,7 +86,7 @@
/>
</label>
<p class="text-xs text-muted-foreground">Change the listening port if 17878 is in use. Press save to apply.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.localApi.server.portHint' | translate }}</p>
<button
type="button"
@@ -99,17 +101,16 @@
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Authentication</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.localApi.auth.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Bearer tokens are issued only after a username/password is verified against one of the signaling servers below. Add the full URL (including
<code>https://</code>) of every signaling server you trust.
{{ 'settings.localApi.auth.description' | translate }}
</p>
</div>
<textarea
rows="4"
spellcheck="false"
placeholder="https://signaling.example.com"
[placeholder]="'settings.localApi.auth.placeholder' | translate"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="allowedServersText()"
[disabled]="busy()"
@@ -130,9 +131,9 @@
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Documentation</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.localApi.docs.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Browse the API in a privacy-respecting locally hosted Scalar reference. No telemetry, no AI, no remote network calls.
{{ 'settings.localApi.docs.description' | translate }}
</p>
</div>
@@ -145,9 +146,9 @@
(change)="toggleScalar($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Serve Scalar documentation at <code>/docs</code></span>
<span class="font-medium">{{ 'settings.localApi.docs.scalar' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">
Loads from local app resources only. The OpenAPI document is always available at <code>/api/openapi.json</code>.
{{ 'settings.localApi.docs.scalarHint' | translate }}
</span>
</span>
</label>
@@ -183,7 +184,7 @@
@if (status().baseUrl) {
<p class="text-xs text-muted-foreground">
Listening at <code class="rounded bg-secondary px-2 py-1">{{ status().baseUrl }}</code>
{{ 'settings.localApi.docs.listeningAt' | translate }} <code class="rounded bg-secondary px-2 py-1">{{ status().baseUrl }}</code>
</p>
}
</section>

View File

@@ -16,15 +16,17 @@ import type {
LocalApiSettings,
LocalApiSnapshot
} from '../../../../core/platform/electron/electron-api.models';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-local-api-settings',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, ...APP_TRANSLATE_IMPORTS],
templateUrl: './local-api-settings.component.html'
})
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
private readonly bridge = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.bridge.isAvailable;
@@ -60,14 +62,18 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
switch (snapshot.status) {
case 'running':
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
return this.appI18n.instant('settings.localApi.status.running', {
url: snapshot.baseUrl ?? this.appI18n.instant('settings.localApi.status.unknown')
});
case 'starting':
return 'Starting...';
return this.appI18n.instant('settings.localApi.status.starting');
case 'error':
return `Error: ${snapshot.error ?? 'unknown error'}`;
return this.appI18n.instant('settings.localApi.status.error', {
message: snapshot.error ?? this.appI18n.instant('settings.localApi.status.unknown')
});
case 'stopped':
default:
return 'Stopped';
return this.appI18n.instant('settings.localApi.status.stopped');
}
});
@@ -163,7 +169,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
const parsed = Number(this.portText());
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
this.errorMessage.set('Port must be an integer between 1 and 65535');
this.errorMessage.set(this.appI18n.instant('settings.localApi.errors.invalidPort'));
return;
}
@@ -198,7 +204,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
const result = await api.openLocalApiDocs();
if (result && !result.opened) {
this.errorMessage.set(result.reason ?? 'Could not open documentation');
this.errorMessage.set(result.reason ?? this.appI18n.instant('settings.localApi.errors.couldNotOpenDocs'));
} else {
this.errorMessage.set(null);
}
@@ -213,7 +219,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
const result = await api.openDocusaurusDocs();
if (result && !result.opened) {
this.errorMessage.set(result.reason ?? 'Could not open documentation');
this.errorMessage.set(result.reason ?? this.appI18n.instant('settings.localApi.errors.couldNotOpenDocs'));
} else {
this.errorMessage.set(null);
await this.refresh();
@@ -263,7 +269,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
await this.refreshStatus();
} catch (error) {
this.errorMessage.set((error as Error).message ?? 'Failed to update settings');
this.errorMessage.set((error as Error).message ?? this.appI18n.instant('settings.localApi.errors.updateFailed'));
} finally {
this.busy.set(false);
}

View File

@@ -1,7 +1,7 @@
@if (server()) {
<div class="space-y-3 max-w-3xl">
@if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
<p class="text-sm text-muted-foreground text-center py-8">{{ 'settings.members.empty' | translate }}</p>
} @else {
@for (member of members(); track member.oderId || member.id) {
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
@@ -16,7 +16,7 @@
{{ member.displayName }}
</p>
@if (member.isOnline) {
<span class="rounded bg-emerald-500/20 px-1 py-0.5 text-[10px] text-emerald-400">Online</span>
<span class="rounded bg-emerald-500/20 px-1 py-0.5 text-[10px] text-emerald-400">{{ 'settings.members.online' | translate }}</span>
}
<span class="rounded bg-primary/10 px-1 py-0.5 text-[10px] text-primary">{{ member.displayRoleName }}</span>
</div>
@@ -28,7 +28,7 @@
type="button"
(click)="kickMember(member)"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Kick"
[title]="'settings.members.kick' | translate"
>
<ng-icon
name="lucideUserX"
@@ -41,7 +41,7 @@
type="button"
(click)="banMember(member)"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Ban"
[title]="'settings.members.ban' | translate"
>
<ng-icon
name="lucideBan"
@@ -54,7 +54,7 @@
@if (assignableRoles().length > 0 && canChangeRoles(member)) {
<div class="space-y-2 border-t border-border/50 pt-3">
<p class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Assigned Roles</p>
<p class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{ 'settings.members.assignedRoles' | translate }}</p>
<div class="flex flex-wrap gap-2">
@for (role of assignableRoles(); track role.id) {
<label class="flex items-center gap-2 rounded-full border border-border bg-background/70 px-3 py-1 text-xs text-foreground">
@@ -75,7 +75,7 @@
</div>
} @else if (assignableRoles().length > 0) {
<p class="border-t border-border/50 pt-3 text-xs text-muted-foreground">
You can view this member's roles, but you do not have permission to change them.
{{ 'settings.members.readOnlyRoles' | translate }}
</p>
}
</div>
@@ -83,5 +83,5 @@
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">{{ 'settings.members.selectServer' | translate }}</div>
}

View File

@@ -29,6 +29,7 @@ import {
normalizeRoomAccessControl,
setRoleAssignmentsForMember
} from '../../../../domains/access-control';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
interface ServerMemberView extends RoomMember {
assignedRoleIds: string[];
@@ -43,7 +44,8 @@ interface ServerMemberView extends RoomMember {
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent
UserAvatarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -55,6 +57,7 @@ interface ServerMemberView extends RoomMember {
})
export class MembersSettingsComponent {
private store = inject(Store);
private readonly i18n = inject(AppI18nService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
@@ -93,7 +96,7 @@ export class MembersSettingsComponent {
return {
...member,
assignedRoleIds: getRoleIdsForMember(room, member),
displayRoleName: getDisplayRoleName(room, member),
displayRoleName: getDisplayRoleName(room, member, (key) => this.i18n.instant(key)),
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
displayName: liveUser?.displayName || member.displayName,
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')

View File

@@ -7,7 +7,7 @@
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.network.serverEndpoints.title' | translate }}</h4>
</div>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
@@ -16,7 +16,7 @@
(click)="restoreDefaultServers()"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Restore Defaults
{{ 'settings.network.serverEndpoints.restoreDefaults' | translate }}
</button>
}
<button
@@ -30,13 +30,13 @@
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
{{ 'settings.network.serverEndpoints.testAll' | translate }}
</button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-3">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
{{ 'settings.network.serverEndpoints.descriptionModal' | translate }}
</p>
<!-- Server List -->
@@ -61,7 +61,9 @@
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">{{
'settings.network.serverEndpoints.active' | translate
}}</span>
}
</div>
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
@@ -69,7 +71,7 @@
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
<p class="text-[10px] text-destructive">{{ 'settings.network.serverEndpoints.incompatible' | translate }}</p>
}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
@@ -78,7 +80,7 @@
type="button"
(click)="setActiveServer(server.id)"
class="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Activate"
[title]="'settings.network.serverEndpoints.activate' | translate"
>
<ng-icon
name="lucideCheck"
@@ -91,7 +93,7 @@
type="button"
(click)="deactivateServer(server.id)"
class="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Deactivate"
[title]="'settings.network.serverEndpoints.deactivate' | translate"
>
<ng-icon
name="lucideX"
@@ -104,7 +106,7 @@
type="button"
(click)="removeServer(server.id)"
class="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove"
[title]="'settings.network.serverEndpoints.removeShort' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -119,19 +121,19 @@
<!-- Add New Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add New Server</h4>
<h4 class="text-xs font-medium text-foreground mb-2">{{ 'settings.network.serverEndpoints.addNew' | translate }}</h4>
<div class="flex gap-2">
<div class="flex-1 space-y-1.5">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -160,13 +162,13 @@
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.network.connection.titleShort' | translate }}</h4>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Auto-reconnect</p>
<p class="text-xs text-muted-foreground">Reconnect when connection is lost</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.network.connection.autoReconnect.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.network.connection.autoReconnect.descriptionShort' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -182,8 +184,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Search all servers</p>
<p class="text-xs text-muted-foreground">Search across all server directories</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.network.connection.searchAllServers.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.network.connection.searchAllServers.descriptionShort' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input

View File

@@ -21,6 +21,7 @@ import {
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-network-settings',
@@ -29,7 +30,8 @@ import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-se
CommonModule,
FormsModule,
NgIcon,
IceServerSettingsComponent
IceServerSettingsComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -46,6 +48,7 @@ import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-se
})
export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryFacade);
private readonly appI18n = inject(AppI18nService);
servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers;
@@ -69,12 +72,12 @@ export class NetworkSettingsComponent {
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.invalidUrl'));
return;
}
if (this.servers().some((serverEntry) => serverEntry.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.duplicateUrl'));
return;
}

View File

@@ -6,7 +6,7 @@
role permissions.
</p>
@if (!canManageRoles()) {
<p class="mt-2 text-xs text-muted-foreground">You can inspect this server's access model, but only members with Manage Roles can edit it.</p>
<p class="mt-2 text-xs text-muted-foreground">{{ 'settings.permissions.readOnly' | translate }}</p>
}
</div>
@@ -14,8 +14,8 @@
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
<div class="flex items-center justify-between gap-2">
<div>
<p class="text-sm font-medium text-foreground">Roles</p>
<p class="text-xs text-muted-foreground">Higher roles appear first.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.roles.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.permissions.roles.hint' | translate }}</p>
</div>
@if (canManageRoles()) {
<button
@@ -27,7 +27,7 @@
name="lucidePlus"
class="h-3.5 w-3.5"
/>
<span>Role</span>
<span>{{ 'settings.permissions.roles.add' | translate }}</span>
</button>
}
</div>
@@ -49,7 +49,9 @@
></span>
<span class="min-w-0 flex-1 truncate text-sm text-foreground">{{ role.name }}</span>
@if (role.isSystem) {
<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.16em] text-primary">System</span>
<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.16em] text-primary">{{
'settings.permissions.roles.system' | translate
}}</span>
}
</button>
}
@@ -60,8 +62,8 @@
<div class="rounded-lg bg-secondary/50 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Sets the minimum delay between messages for everyone in the server.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.slowMode.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.permissions.slowMode.description' | translate }}</p>
</div>
<select
[ngModel]="slowModeValue(room.slowModeInterval)"
@@ -69,12 +71,12 @@
[disabled]="!canManageServer()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="120">2 minutes</option>
<option value="0">{{ 'settings.permissions.slowMode.off' | translate }}</option>
<option value="5">{{ 'settings.permissions.slowMode.5s' | translate }}</option>
<option value="10">{{ 'settings.permissions.slowMode.10s' | translate }}</option>
<option value="30">{{ 'settings.permissions.slowMode.30s' | translate }}</option>
<option value="60">{{ 'settings.permissions.slowMode.1m' | translate }}</option>
<option value="120">{{ 'settings.permissions.slowMode.2m' | translate }}</option>
</select>
</div>
</div>
@@ -91,17 +93,21 @@
<p class="text-sm font-medium text-foreground">{{ role.name }}</p>
</div>
<p class="mt-1 text-xs text-muted-foreground">
Edit the role metadata here, then tune its global permissions and per-channel overrides below.
{{ 'settings.permissions.roles.editHint' | translate }}
</p>
</div>
@if (role.isSystem) {
<span class="rounded bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-primary">Protected role</span>
<span class="rounded bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-primary">{{
'settings.permissions.roles.protected' | translate
}}</span>
}
</div>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr),8rem]">
<label class="space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Role Name</span>
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{
'settings.permissions.roles.name' | translate
}}</span>
<input
type="text"
[ngModel]="roleName"
@@ -112,7 +118,9 @@
</label>
<label class="space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Color</span>
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{
'settings.permissions.roles.color' | translate
}}</span>
<input
type="color"
[ngModel]="roleColor"
@@ -134,7 +142,7 @@
name="lucideCheck"
class="h-4 w-4"
/>
<span>Save Role</span>
<span>{{ 'settings.permissions.roles.save' | translate }}</span>
</button>
<button
@@ -147,7 +155,7 @@
name="lucideArrowUp"
class="h-4 w-4"
/>
<span>Move Up</span>
<span>{{ 'settings.permissions.roles.moveUp' | translate }}</span>
</button>
<button
@@ -160,7 +168,7 @@
name="lucideArrowDown"
class="h-4 w-4"
/>
<span>Move Down</span>
<span>{{ 'settings.permissions.roles.moveDown' | translate }}</span>
</button>
@if (!role.isSystem) {
@@ -174,26 +182,26 @@
name="lucideTrash2"
class="h-4 w-4"
/>
<span>Delete</span>
<span>{{ 'settings.permissions.roles.delete' | translate }}</span>
</button>
}
</div>
@if (role.isSystem) {
<p class="text-xs text-muted-foreground">
System roles can still have their permissions tuned, but their name, color, and membership in the base hierarchy stay fixed.
{{ 'settings.permissions.roles.systemHint' | translate }}
</p>
}
</div>
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Base Permissions</p>
<p class="text-xs text-muted-foreground">These defaults apply everywhere unless a channel override changes them.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.basePermissions.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.permissions.basePermissions.description' | translate }}</p>
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
@for (permission of permissionDefinitions(); track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
@@ -207,7 +215,7 @@
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
{{ permissionStateLabel(state) }}
</option>
}
</select>
@@ -218,18 +226,20 @@
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Channel Overrides</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.channelOverrides.title' | translate }}</p>
<p class="text-xs text-muted-foreground">
Override the selected role inside a specific channel without changing the server-wide default.
{{ 'settings.permissions.channelOverrides.description' | translate }}
</p>
</div>
@if (channels().length === 0) {
<p class="text-sm text-muted-foreground">This server has no channels yet.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.permissions.channelOverrides.noChannels' | translate }}</p>
} @else {
<div class="flex items-center gap-3">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Channel</span>
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{
'settings.permissions.channelOverrides.channel' | translate
}}</span>
<select
[ngModel]="selectedChannelKey"
(ngModelChange)="selectChannel($event)"
@@ -243,7 +253,7 @@
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
@for (permission of permissionDefinitions(); track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
@@ -257,7 +267,7 @@
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
{{ permissionStateLabel(state) }}
</option>
}
</select>
@@ -271,5 +281,5 @@
</div>
</div>
} @else {
<div class="flex h-40 items-center justify-center text-sm text-muted-foreground">Select a server from the sidebar to manage</div>
<div class="flex h-40 items-center justify-center text-sm text-muted-foreground">{{ 'settings.permissions.selectServer' | translate }}</div>
}

View File

@@ -38,6 +38,7 @@ import {
sortRolesForDisplay,
withUpdatedRole
} from '../../../../domains/access-control';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
function upsertRoleChannelOverride(
overrides: readonly ChannelPermissionOverride[] | undefined,
@@ -73,7 +74,8 @@ function upsertRoleChannelOverride(
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -88,11 +90,18 @@ function upsertRoleChannelOverride(
})
export class PermissionsSettingsComponent {
private store = inject(Store);
private readonly appI18n = inject(AppI18nService);
server = input<Room | null>(null);
isAdmin = input(false);
currentUser = this.store.selectSignal(selectCurrentUser);
permissionDefinitions = ROOM_PERMISSION_DEFINITIONS;
permissionDefinitions = computed(() =>
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
key: definition.key,
label: this.appI18n.instant(`permissions.${definition.key}.label`),
description: this.appI18n.instant(`permissions.${definition.key}.description`)
}))
);
permissionStates: PermissionState[] = [
'inherit',
'allow',
@@ -195,7 +204,7 @@ export class PermissionsSettingsComponent {
if (!room || !this.canManageRoles())
return;
const role = createCustomRoomRole('New Role', room.roles ?? []);
const role = createCustomRoomRole(this.appI18n.instant('settings.permissions.newRole'), room.roles ?? []);
this.selectedRoleKey = role.id;
this.roleName = role.name;
@@ -233,6 +242,10 @@ export class PermissionsSettingsComponent {
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
}
permissionStateLabel(state: PermissionState): string {
return this.appI18n.instant(`settings.permissions.states.${state}`);
}
slowModeValue(interval: number | undefined): string {
return String(interval ?? 0);
}

View File

@@ -1,9 +1,9 @@
@if (serverData()) {
<div class="max-w-2xl space-y-5">
<section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
<h4 class="text-sm font-semibold text-foreground mb-3">{{ 'settings.server.title' | translate }}</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
<p class="text-xs text-muted-foreground mb-3">{{ 'settings.server.readOnly' | translate }}</p>
}
<div class="space-y-4">
<div class="rounded-lg border border-border bg-secondary/40 p-4">
@@ -24,8 +24,8 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">Server Image</p>
<p class="text-xs text-muted-foreground">Synced to members and shown in server discovery.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.image.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.image.description' | translate }}</p>
@if (iconError()) {
<p class="mt-1 text-xs text-destructive">{{ iconError() }}</p>
}
@@ -36,8 +36,8 @@
<label
for="server-icon-upload"
class="grid h-9 w-9 cursor-pointer place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Upload image"
aria-label="Upload server image"
[title]="'settings.server.image.upload' | translate"
[attr.aria-label]="'settings.server.image.uploadAria' | translate"
>
<ng-icon
name="lucideUpload"
@@ -56,8 +56,8 @@
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Remove image"
aria-label="Remove server image"
[title]="'settings.server.image.remove' | translate"
[attr.aria-label]="'settings.server.image.removeAria' | translate"
(click)="removeServerIcon()"
>
<ng-icon
@@ -75,7 +75,7 @@
<label
for="room-name"
class="block text-xs font-medium text-muted-foreground mb-1"
>Room Name</label
>{{ 'settings.server.roomName' | translate }}</label
>
<input
type="text"
@@ -91,7 +91,7 @@
<label
for="room-description"
class="block text-xs font-medium text-muted-foreground mb-1"
>Description</label
>{{ 'settings.server.description' | translate }}</label
>
<textarea
[(ngModel)]="roomDescription"
@@ -106,8 +106,8 @@
@if (isAdmin()) {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.private.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.private.description' | translate }}</p>
</div>
<button
(click)="togglePrivate()"
@@ -134,10 +134,12 @@
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.private.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.private.description' | translate }}</p>
</div>
<span class="text-sm text-muted-foreground">{{ isPrivate() ? 'Yes' : 'No' }}</span>
<span class="text-sm text-muted-foreground">{{
isPrivate() ? ('settings.server.private.yes' | translate) : ('settings.server.private.no' | translate)
}}</span>
</div>
}
<div>
@@ -145,7 +147,7 @@
for="room-max-users"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Users (0 = unlimited)
{{ 'settings.server.maxUsers' | translate }}
</label>
<input
type="number"
@@ -163,12 +165,12 @@
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.password.title' | translate }}</p>
<p class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Joined members stay whitelisted until they are kicked or banned.
{{ 'settings.server.password.whitelisted' | translate }}
} @else {
Add an optional password so new members need it to join.
{{ 'settings.server.password.addOptional' | translate }}
}
</p>
</div>
@@ -194,11 +196,11 @@
<div class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Password protection is currently enabled.
{{ 'settings.server.password.enabled' | translate }}
} @else if (hasPassword() && passwordAction() === 'remove') {
Password protection will be removed when you save.
{{ 'settings.server.password.willRemove' | translate }}
} @else {
Password protection is currently disabled.
{{ 'settings.server.password.disabled' | translate }}
}
</div>
@@ -207,7 +209,7 @@
for="room-password"
class="block text-xs font-medium text-muted-foreground mb-1"
>
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
{{ hasPassword() ? ('settings.server.password.setNew' | translate) : ('settings.server.password.set' | translate) }}
</label>
<input
type="password"
@@ -215,11 +217,15 @@
[ngModel]="roomPassword"
(ngModelChange)="onPasswordInput($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
[placeholder]="
hasPassword()
? ('settings.server.password.keepCurrentPlaceholder' | translate)
: ('settings.server.password.optionalPlaceholder' | translate)
"
/>
@if (passwordAction() === 'update') {
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
<p class="mt-2 text-xs text-muted-foreground">{{ 'settings.server.password.willReplace' | translate }}</p>
}
@if (passwordError()) {
@@ -230,10 +236,12 @@
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.password.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.password.viewerHint' | translate }}</p>
</div>
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
<span class="text-sm text-muted-foreground">{{
hasPassword() ? ('settings.server.password.enabledShort' | translate) : ('settings.server.password.disabledShort' | translate)
}}</span>
</div>
}
</div>
@@ -251,13 +259,13 @@
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
{{ saveSuccess() === 'server' ? ('settings.server.saved' | translate) : ('settings.server.save' | translate) }}
</button>
@if (canDeleteServer()) {
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<h4 class="text-sm font-medium text-destructive mb-3">{{ 'settings.server.dangerZone' | translate }}</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
@@ -277,16 +285,16 @@
<!-- Delete Confirmation (sub-modal) -->
@if (showDeleteConfirm()) {
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
[title]="'settings.server.deleteConfirm.title' | translate"
[confirmLabel]="'settings.server.deleteConfirm.confirm' | translate"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
<p>{{ 'settings.server.deleteConfirm.message' | translate }}</p>
</app-confirm-dialog>
}
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">{{ 'settings.server.selectServer' | translate }}</div>
}

View File

@@ -25,6 +25,7 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-server-settings',
@@ -33,7 +34,8 @@ import { ServerIconImageService } from '../../../../domains/server-directory/inf
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent
ConfirmDialogComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -52,6 +54,7 @@ export class ServerSettingsComponent {
private store = inject(Store);
private modal = inject(SettingsModalService);
private serverIconImages = inject(ServerIconImageService);
private readonly appI18n = inject(AppI18nService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
@@ -209,7 +212,7 @@ export class ServerSettingsComponent {
this.showSaveSuccess('icon');
} catch (error) {
this.iconError.set(error instanceof Error ? error.message : 'Could not read that image.');
this.iconError.set(error instanceof Error ? error.message : this.appI18n.instant('settings.server.image.readError'));
}
}

View File

@@ -10,7 +10,7 @@
(keydown.space)="onBackdropClick()"
role="button"
tabindex="0"
aria-label="Close settings"
[attr.aria-label]="'settings.closeAria' | translate"
></div>
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
@@ -41,13 +41,13 @@
id="settings-modal-title"
class="text-lg font-semibold text-foreground"
>
Settings
{{ 'settings.title' | translate }}
</h2>
@if (isMobile()) {
<button
(click)="close()"
type="button"
aria-label="Close settings"
[attr.aria-label]="'settings.closeAria' | translate"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
@@ -60,8 +60,10 @@
<div class="flex-1 overflow-y-auto py-2">
<!-- Global section -->
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">General</p>
@for (page of globalPages; track page.id) {
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.sections.general' | translate }}
</p>
@for (page of globalPages(); track page.id) {
<button
(click)="navigate(page.id)"
type="button"
@@ -84,7 +86,9 @@
<!-- Server section -->
@if (manageableRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border">
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Server</p>
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.sections.server' | translate }}
</p>
<!-- Server selector -->
<div class="px-3 pb-2">
@@ -93,7 +97,7 @@
[value]="selectedServerId() || ''"
(change)="onServerSelect($event)"
>
<option value="">Select a server...</option>
<option value="">{{ 'settings.selectServer' | translate }}</option>
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
@@ -101,7 +105,7 @@
</div>
@if (selectedServerId() && canAccessSelectedServer()) {
@for (page of serverPages; track page.id) {
@for (page of serverPages(); track page.id) {
<button
(click)="navigate(page.id)"
type="button"
@@ -131,7 +135,7 @@
(click)="openThirdPartyLicenses()"
class="text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4"
>
Third-party licenses
{{ 'settings.thirdPartyLicenses.link' | translate }}
</button>
</div>
</nav>
@@ -151,7 +155,7 @@
<button
(click)="backToMenu()"
type="button"
aria-label="Back to settings menu"
[attr.aria-label]="'settings.backToMenuAria' | translate"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
@@ -163,49 +167,49 @@
<h3 class="truncate text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('general') {
General
{{ 'settings.pages.general' | translate }}
}
@case ('plugins') {
Client Plugins
{{ 'settings.pages.clientPlugins' | translate }}
}
@case ('network') {
Network
{{ 'settings.pages.network' | translate }}
}
@case ('theme') {
Theme Studio
{{ 'settings.pages.theme' | translate }}
}
@case ('notifications') {
Notifications
{{ 'settings.pages.notifications' | translate }}
}
@case ('voice') {
Voice & Audio
{{ 'settings.pages.voice' | translate }}
}
@case ('updates') {
Updates
{{ 'settings.pages.updates' | translate }}
}
@case ('localApi') {
Local API
{{ 'settings.pages.localApi' | translate }}
}
@case ('data') {
Data
{{ 'settings.pages.data' | translate }}
}
@case ('debugging') {
Debugging
{{ 'settings.pages.debugging' | translate }}
}
@case ('server') {
Server Settings
{{ 'settings.pages.server' | translate }}
}
@case ('serverPlugins') {
Server Plugins
{{ 'settings.pages.serverPlugins' | translate }}
}
@case ('members') {
Members
{{ 'settings.pages.members' | translate }}
}
@case ('bans') {
Bans
{{ 'settings.pages.bans' | translate }}
}
@case ('permissions') {
Permissions
{{ 'settings.pages.permissions' | translate }}
}
}
</h3>
@@ -214,7 +218,7 @@
<button
(click)="close()"
type="button"
aria-label="Close settings"
[attr.aria-label]="'settings.closeAria' | translate"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
<ng-icon
@@ -248,17 +252,17 @@
<div class="max-w-3xl space-y-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Active Theme</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{{ 'settings.theme.activeTheme' | translate }}</p>
<h4 class="mt-2 text-lg font-semibold text-foreground">{{ activeThemeName() }}</h4>
<p class="mt-2 max-w-2xl text-sm text-muted-foreground">
Launch Theme Studio to edit the live draft, inspect themeable regions, or switch to a saved theme.
{{ 'settings.theme.description' | translate }}
</p>
</div>
@if (themeStudioMinimized()) {
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary"
>Minimized</span
>
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{
'settings.theme.minimized' | translate
}}</span>
}
</div>
@@ -268,7 +272,7 @@
for="settings-saved-theme-select"
class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"
>
Saved Theme
{{ 'settings.theme.savedTheme' | translate }}
</label>
<div class="flex flex-wrap gap-2">
@@ -279,7 +283,11 @@
[disabled]="savedThemesBusy() && savedThemes().length === 0"
(change)="onSavedThemeSelect($event)"
>
<option value="">{{ savedThemes().length > 0 ? 'Choose saved theme' : 'No saved themes' }}</option>
<option value="">
{{
savedThemes().length > 0 ? ('settings.theme.chooseSavedTheme' | translate) : ('settings.theme.noSavedThemes' | translate)
}}
</option>
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
<option
[value]="savedTheme.fileName"
@@ -296,7 +304,7 @@
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
Edit In Studio
{{ 'settings.theme.editInStudio' | translate }}
</button>
</div>
</div>
@@ -308,7 +316,7 @@
(click)="openThemeStudio()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
{{ themeStudioMinimized() ? 'Re-open Theme Studio' : 'Open Theme Studio' }}
{{ themeStudioMinimized() ? ('settings.theme.reopenStudio' | translate) : ('settings.theme.openStudio' | translate) }}
</button>
<button
@@ -316,7 +324,7 @@
(click)="restoreDefaultTheme()"
class="inline-flex items-center rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
>
Restore Default
{{ 'settings.theme.restoreDefault' | translate }}
</button>
</div>
</div>
@@ -338,7 +346,7 @@
<app-data-settings />
} @loading {
<section class="rounded-lg border border-border bg-card/60 p-5">
<p class="text-sm text-muted-foreground">Loading data settings...</p>
<p class="text-sm text-muted-foreground">{{ 'settings.dataLoading' | translate }}</p>
</section>
}
}
@@ -362,10 +370,12 @@
/>
} @else {
<section class="rounded-lg border border-border bg-card p-5">
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.serverPlugins.title' | translate }}</h4>
<p class="mt-2 text-sm text-muted-foreground">
Server plugin installs and activation are shown for the currently open chat server. Select or open
{{ selectedServer()?.name || 'this server' }} in the app, then return here.
{{
'settings.serverPlugins.description'
| translate: { serverName: selectedServer()?.name || ('settings.serverPlugins.thisServer' | translate) }
}}
</p>
</section>
}
@@ -402,7 +412,7 @@
(keydown.space)="closeThirdPartyLicenses()"
role="button"
tabindex="0"
aria-label="Close third-party licenses"
[attr.aria-label]="'settings.thirdPartyLicenses.closeAria' | translate"
></div>
<div class="pointer-events-none absolute inset-0 z-[11] flex justify-center p-4 sm:p-6">
@@ -411,15 +421,15 @@
>
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h4 class="text-base font-semibold text-foreground">Third-party licenses</h4>
<p class="mt-1 text-sm text-muted-foreground">License information for bundled third-party libraries used by the app.</p>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.thirdPartyLicenses.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.thirdPartyLicenses.description' | translate }}</p>
</div>
<button
type="button"
(click)="closeThirdPartyLicenses()"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close third-party licenses"
[attr.aria-label]="'settings.thirdPartyLicenses.closeAria' | translate"
>
<ng-icon
name="lucideX"
@@ -443,12 +453,14 @@
rel="noopener noreferrer"
class="text-xs font-medium text-primary hover:underline underline-offset-4"
>
View license
{{ 'settings.thirdPartyLicenses.viewLicense' | translate }}
</a>
</div>
<div class="mt-4 rounded-md bg-background/80 px-3 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Packages</p>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.thirdPartyLicenses.packages' | translate }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
@for (packageName of license.packages; track packageName) {
<span class="rounded-full border border-border bg-card px-2.5 py-1 text-[11px] font-medium leading-4 text-foreground">
@@ -462,7 +474,9 @@
}
<div class="mt-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">License text</p>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.thirdPartyLicenses.licenseText' | translate }}
</p>
<pre
class="mt-2 whitespace-pre-wrap break-words rounded-md border border-border/70 bg-card px-3 py-3 text-[11px] leading-5 text-muted-foreground"
>{{ license.text }}</pre

View File

@@ -57,6 +57,7 @@ import {
ThemeNodeDirective,
ThemeService
} from '../../../domains/theme';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
@Component({
selector: 'app-settings-modal',
@@ -78,7 +79,8 @@ import {
MembersSettingsComponent,
BansSettingsComponent,
PermissionsSettingsComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -107,6 +109,7 @@ export class SettingsModalComponent {
private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null;
@@ -138,25 +141,25 @@ export class SettingsModalComponent {
savedThemesBusy = this.themeLibrary.isBusy;
selectedSavedTheme = this.themeLibrary.selectedEntry;
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general', label: 'General', icon: 'lucideSettings' },
{ id: 'plugins', label: 'Client plugins', icon: 'lucidePackage' },
{ id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
{ id: 'localApi', label: 'Local API', icon: 'lucideTerminal' },
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
{ id: 'serverPlugins', label: 'Server plugins', icon: 'lucidePackage' },
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
];
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
{ id: 'general', label: this.appI18n.instant('settings.nav.general'), icon: 'lucideSettings' },
{ id: 'plugins', label: this.appI18n.instant('settings.nav.plugins'), icon: 'lucidePackage' },
{ id: 'theme', label: this.appI18n.instant('settings.nav.theme'), icon: 'lucidePalette' },
{ id: 'network', label: this.appI18n.instant('settings.nav.network'), icon: 'lucideGlobe' },
{ id: 'notifications', label: this.appI18n.instant('settings.nav.notifications'), icon: 'lucideBell' },
{ id: 'voice', label: this.appI18n.instant('settings.nav.voice'), icon: 'lucideAudioLines' },
{ id: 'updates', label: this.appI18n.instant('settings.nav.updates'), icon: 'lucideDownload' },
{ id: 'localApi', label: this.appI18n.instant('settings.nav.localApi'), icon: 'lucideTerminal' },
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
]);
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
{ id: 'members', label: this.appI18n.instant('settings.nav.members'), icon: 'lucideUsers' },
{ id: 'bans', label: this.appI18n.instant('settings.nav.bans'), icon: 'lucideBan' },
{ id: 'permissions', label: this.appI18n.instant('settings.nav.permissions'), icon: 'lucideShield' }
]);
manageableRooms = computed<Room[]>(() => {
const user = this.currentUser();

View File

@@ -2,9 +2,9 @@
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Desktop app updates</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.updates.desktop.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart.
{{ 'settings.updates.desktop.description' | translate }}
</p>
</div>
@@ -18,9 +18,9 @@
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Mobile app updates</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.updates.mobile.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Check the Play Store or App Store for newer native builds. Android can install in-app updates when Google Play allows it.
{{ 'settings.updates.mobile.description' | translate }}
</p>
</div>
@@ -32,33 +32,33 @@
@if (!mobileState().isSupported) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Store updates are only available in the packaged Android or iOS app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.updates.mobile.unsupported' | translate }}</p>
</section>
} @else {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.installed' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().currentVersion }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Store version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().availableVersion || 'Unknown' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.storeVersion' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().availableVersion || ('settings.updates.unknown' | translate) }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.lastChecked' | translate }}</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ mobileState().lastCheckedAt ? (mobileState().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
{{ mobileState().lastCheckedAt ? (mobileState().lastCheckedAt | date: 'medium') : ('settings.updates.notCheckedYet' | translate) }}
</p>
</div>
</section>
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.updates.status' | translate }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ mobileState().statusMessage || 'Waiting for the first store update check.' }}
{{ mobileState().statusMessage || ('settings.updates.waitingMobile' | translate) }}
</p>
</div>
@@ -107,66 +107,72 @@
@if (!isElectron && !isCapacitor) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app or native mobile app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.updates.unsupported' | translate }}</p>
</section>
}
@if (isElectron) {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.installed' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().currentVersion }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Latest in manifest</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || 'Unknown' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.latestInManifest' | translate }}
</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || ('settings.updates.unknown' | translate) }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Target version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || 'Automatic' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.targetVersion' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || ('settings.updates.automatic' | translate) }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.lastChecked' | translate }}</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : ('settings.updates.notCheckedYet' | translate) }}
</p>
</div>
</section>
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Update policy</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.updates.policy.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.
{{ 'settings.updates.policy.description' | translate }}
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Mode</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.updates.policy.mode' | translate
}}</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="state().autoUpdateMode"
(change)="onModeChange($event)"
>
<option value="auto">Newest release</option>
<option value="version">Specific version</option>
<option value="off">Turn off auto updates</option>
<option value="auto">{{ 'settings.updates.policy.modeAuto' | translate }}</option>
<option value="version">{{ 'settings.updates.policy.modeVersion' | translate }}</option>
<option value="off">{{ 'settings.updates.policy.modeOff' | translate }}</option>
</select>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Pinned version</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.updates.policy.pinnedVersion' | translate
}}</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="state().autoUpdateMode !== 'version' || state().availableVersions.length === 0"
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release...</option>
<option value="">{{ 'settings.updates.policy.chooseRelease' | translate }}</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}
@@ -175,9 +181,9 @@
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.updates.status' | translate }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ state().statusMessage || 'Waiting for release information from the active server.' }}
{{ state().statusMessage || ('settings.updates.waitingDesktop' | translate) }}
</p>
</div>
@@ -204,7 +210,7 @@
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Manifest URL priority</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.updates.manifest.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is
invalid.
@@ -213,24 +219,30 @@
<div class="rounded-lg border border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
<p class="font-medium text-foreground">
{{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }}
{{
isUsingConnectedServerDefaults()
? ('settings.updates.manifest.usingDefaults' | translate)
: ('settings.updates.manifest.usingSaved' | translate)
}}
</p>
<p class="mt-1">When this list is empty, the app automatically uses manifest URLs reported by your configured servers.</p>
<p class="mt-1">{{ 'settings.updates.manifest.emptyHint' | translate }}</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Manifest URLs</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.updates.manifest.urlsLabel' | translate
}}</span>
<textarea
rows="6"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="manifestUrlsText()"
(input)="onManifestUrlsInput($event)"
placeholder="https://example.com/releases/latest/download/release-manifest.json"
[placeholder]="'settings.updates.manifest.placeholder' | translate"
></textarea>
</label>
@if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) {
<p class="text-sm text-muted-foreground">None of your configured servers currently report a manifest URL.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.updates.manifest.noServerManifest' | translate }}</p>
}
<div class="flex flex-wrap gap-3">
@@ -254,25 +266,31 @@
@if (state().serverBlocked) {
<section class="rounded-lg border border-red-500/30 bg-red-500/10 p-5">
<h5 class="text-sm font-semibold text-foreground">Server update required</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.updates.serverBlocked.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ state().serverBlockMessage }}</p>
<div class="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-1">{{ state().serverVersion || 'Not reported' }}</p>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.serverBlocked.connectedServer' | translate }}
</p>
<p class="mt-1">{{ state().serverVersion || ('settings.updates.serverBlocked.notReported' | translate) }}</p>
</div>
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-1">{{ state().minimumServerVersion || 'Unknown' }}</p>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.serverBlocked.requiredMinimum' | translate }}
</p>
<p class="mt-1">{{ state().minimumServerVersion || ('settings.updates.unknown' | translate) }}</p>
</div>
</div>
</section>
}
<section class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Resolved manifest URL</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.resolvedManifest.title' | translate }}
</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || ('settings.updates.resolvedManifest.empty' | translate) }}</p>
</section>
}
</div>

View File

@@ -7,7 +7,8 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service';
import { MobileAppUpdateService, getMobileUpdateStatusLabel } from '../../../../infrastructure/mobile';
import { MobileAppUpdateService, type MobileUpdateStatus } from '../../../../infrastructure/mobile';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
type AutoUpdateMode = 'auto' | 'off' | 'version';
type DesktopUpdateStatus =
@@ -26,10 +27,11 @@ type DesktopUpdateStatus =
@Component({
selector: 'app-updates-settings',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ...APP_TRANSLATE_IMPORTS],
templateUrl: './updates-settings.component.html'
})
export class UpdatesSettingsComponent {
private readonly appI18n = inject(AppI18nService);
readonly desktopUpdates = inject(DesktopAppUpdateService);
readonly mobileUpdates = inject(MobileAppUpdateService);
readonly isElectron = this.desktopUpdates.isElectron;
@@ -37,7 +39,7 @@ export class UpdatesSettingsComponent {
readonly state = this.desktopUpdates.state;
readonly mobileState = this.mobileUpdates.state;
readonly mobileStatusLabel = computed(() =>
getMobileUpdateStatusLabel(this.mobileState().status)
this.getMobileStatusLabel(this.mobileState().status)
);
readonly hasPendingManifestUrlChanges = signal(false);
readonly manifestUrlsText = signal('');
@@ -146,29 +148,34 @@ export class UpdatesSettingsComponent {
}
private getStatusLabel(status: DesktopUpdateStatus): string {
switch (status) {
case 'checking':
return 'Checking';
case 'downloading':
return 'Downloading';
case 'restart-required':
return 'Restart required';
case 'up-to-date':
return 'Up to date';
case 'disabled':
return 'Disabled';
case 'unsupported':
return 'Unsupported';
case 'no-manifest':
return 'Manifest missing';
case 'target-unavailable':
return 'Version unavailable';
case 'target-older-than-installed':
return 'Pinned below current';
case 'error':
return 'Error';
default:
return 'Idle';
}
const keyMap: Record<DesktopUpdateStatus, string> = {
idle: 'settings.updates.statusLabels.idle',
checking: 'settings.updates.statusLabels.checking',
downloading: 'settings.updates.statusLabels.downloading',
'restart-required': 'settings.updates.statusLabels.restartRequired',
'up-to-date': 'settings.updates.statusLabels.upToDate',
disabled: 'settings.updates.statusLabels.disabled',
unsupported: 'settings.updates.statusLabels.unsupported',
'no-manifest': 'settings.updates.statusLabels.manifestMissing',
'target-unavailable': 'settings.updates.statusLabels.versionUnavailable',
'target-older-than-installed': 'settings.updates.statusLabels.pinnedBelowCurrent',
error: 'settings.updates.statusLabels.error'
};
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
}
private getMobileStatusLabel(status: MobileUpdateStatus): string {
const keyMap: Record<string, string> = {
idle: 'settings.updates.mobileStatusLabels.idle',
checking: 'settings.updates.mobileStatusLabels.checking',
downloading: 'settings.updates.mobileStatusLabels.downloading',
'up-to-date': 'settings.updates.mobileStatusLabels.upToDate',
'update-available': 'settings.updates.mobileStatusLabels.updateAvailable',
unsupported: 'settings.updates.mobileStatusLabels.unsupported',
error: 'settings.updates.mobileStatusLabels.error'
};
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
}
}

View File

@@ -6,14 +6,14 @@
name="lucideMic"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
<h4 class="text-sm font-semibold text-foreground">'settings.voice.devices.title' | translate</h4>
</div>
<div class="space-y-3">
<div>
<label
for="input-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Microphone</label
>{{ 'settings.voice.devices.microphone' | translate }}</label
>
<select
(change)="onInputDeviceChange($event)"
@@ -25,7 +25,7 @@
[value]="device.deviceId"
[selected]="device.deviceId === selectedInputDevice()"
>
{{ device.label || 'Microphone ' + $index }}
{{ device.label || ('settings.voice.devices.microphoneFallback' | translate: { index: $index }) }}
</option>
}
</select>
@@ -34,7 +34,7 @@
<label
for="output-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Speaker</label
>{{ 'settings.voice.devices.speaker' | translate }}</label
>
<select
(change)="onOutputDeviceChange($event)"
@@ -46,7 +46,7 @@
[value]="device.deviceId"
[selected]="device.deviceId === selectedOutputDevice()"
>
{{ device.label || 'Speaker ' + $index }}
{{ device.label || ('settings.voice.devices.speakerFallback' | translate: { index: $index }) }}
</option>
}
</select>
@@ -61,7 +61,7 @@
name="lucideHeadphones"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.volume.title' | translate }}</h4>
</div>
<div class="space-y-3">
<div>
@@ -69,7 +69,7 @@
for="input-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Input Volume: {{ inputVolume() }}%
{{ 'settings.voice.volume.input' | translate: { value: inputVolume() } }}
</label>
<input
type="range"
@@ -86,7 +86,7 @@
for="output-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Output Volume: {{ outputVolume() }}%
{{ 'settings.voice.volume.output' | translate: { value: outputVolume() } }}
</label>
<input
type="range"
@@ -103,7 +103,7 @@
for="notification-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
{{ 'settings.voice.volume.notification' | translate: { value: notificationVolumePercent() } }}
</label>
<div class="flex items-center gap-2">
<input
@@ -120,12 +120,12 @@
(click)="previewNotificationSound()"
type="button"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors flex-shrink-0"
title="Preview notification sound"
[title]="'settings.voice.volume.previewAria' | translate"
>
Test
</button>
</div>
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave &amp; notification sounds</p>
<p class="text-[10px] text-muted-foreground/60 mt-1">{{ 'settings.voice.volume.notificationHint' | translate }}</p>
</div>
</div>
</section>
@@ -137,14 +137,14 @@
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.quality.title' | translate }}</h4>
</div>
<div class="space-y-3">
<div>
<label
for="latency-profile-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Latency Profile</label
>{{ 'settings.voice.quality.latencyProfile' | translate }}</label
>
<select
(change)="onLatencyProfileChange($event)"
@@ -176,7 +176,7 @@
for="audio-bitrate-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Audio Bitrate: {{ audioBitrate() }} kbps
{{ 'settings.voice.quality.audioBitrate' | translate: { value: audioBitrate() } }}
</label>
<input
type="range"
@@ -193,14 +193,14 @@
<label
for="screen-share-quality-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Screen share quality</label
>{{ 'settings.voice.quality.screenShareQuality' | translate }}</label
>
<select
(change)="onScreenShareQualityChange($event)"
id="screen-share-quality-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (option of screenShareQualityOptions; track option.id) {
@for (option of screenShareQualityOptions(); track option.id) {
<option
[value]="option.id"
[selected]="screenShareQuality() === option.id"
@@ -215,8 +215,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Ask before screen sharing</p>
<p class="text-xs text-muted-foreground">Let the user confirm quality before each new screen share</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.quality.askScreenShare.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.quality.askScreenShare.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -224,7 +224,7 @@
[checked]="askScreenShareQuality()"
(change)="onAskScreenShareQualityChange($event)"
id="ask-screen-share-quality-toggle"
aria-label="Toggle screen share quality prompt"
[attr.aria-label]="'settings.voice.quality.askScreenShare.aria' | translate"
class="sr-only peer"
/>
<div
@@ -234,8 +234,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Noise reduction</p>
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.quality.noiseReduction.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.quality.noiseReduction.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -243,7 +243,7 @@
[checked]="noiseReduction()"
(change)="onNoiseReductionChange()"
id="noise-reduction-toggle"
aria-label="Toggle noise reduction"
[attr.aria-label]="'settings.voice.quality.noiseReduction.aria' | translate"
class="sr-only peer"
/>
<div
@@ -253,8 +253,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Screen share system audio</p>
<p class="text-xs text-muted-foreground">Share other computer audio while filtering MeToYou audio when supported</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.quality.systemAudio.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.quality.systemAudio.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -262,7 +262,7 @@
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
id="system-audio-toggle"
aria-label="Toggle system audio in screen share"
[attr.aria-label]="'settings.voice.quality.systemAudio.aria' | translate"
class="sr-only peer"
/>
<div
@@ -271,7 +271,7 @@
</label>
</div>
<p class="text-[10px] text-muted-foreground/60 -mt-1">
Your microphone stays on the normal voice channel. The shared screen audio should only contain desktop sound.
{{ 'settings.voice.quality.systemAudio.hint' | translate }}
</p>
</div>
</section>
@@ -283,13 +283,13 @@
name="lucideCpu"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Desktop Performance</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.desktopPerformance.title' | translate }}</h4>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Hardware acceleration</p>
<p class="text-xs text-muted-foreground">Use GPU acceleration for rendering and WebRTC when available</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.desktopPerformance.hardwareAcceleration.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.desktopPerformance.hardwareAcceleration.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -297,7 +297,7 @@
[checked]="hardwareAcceleration()"
(change)="onHardwareAccelerationChange($event)"
id="hardware-acceleration-toggle"
aria-label="Toggle hardware acceleration"
[attr.aria-label]="'settings.voice.desktopPerformance.hardwareAcceleration.aria' | translate"
class="sr-only peer"
/>
<div
@@ -309,8 +309,8 @@
@if (hardwareAccelerationRestartRequired()) {
<div class="rounded-lg border border-primary/30 bg-primary/10 p-3 flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">Restart required</p>
<p class="text-xs text-muted-foreground">Restart MeToYou to apply the new hardware acceleration setting.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.desktopPerformance.restartRequired.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.desktopPerformance.restartRequired.description' | translate }}</p>
</div>
<button
type="button"

View File

@@ -20,10 +20,12 @@ import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
import { screenShareQualityI18nKey } from '../../../../shared-kernel';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/platform';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
interface AudioDevice {
deviceId: string;
@@ -36,7 +38,8 @@ interface AudioDevice {
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -54,9 +57,16 @@ export class VoiceSettingsComponent {
private voicePlayback = inject(VoicePlaybackService);
private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService);
private readonly appI18n = inject(AppI18nService);
readonly audioService = inject(NotificationAudioService);
readonly isElectron = this.platform.isElectron;
readonly screenShareQualityOptions = SCREEN_SHARE_QUALITY_OPTIONS;
readonly screenShareQualityOptions = computed(() =>
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
id: option.id,
label: this.appI18n.instant(screenShareQualityI18nKey(option.id, 'label')),
description: this.appI18n.instant(screenShareQualityI18nKey(option.id, 'description'))
}))
);
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
@@ -73,7 +83,10 @@ export class VoiceSettingsComponent {
hardwareAcceleration = signal(true);
hardwareAccelerationRestartRequired = signal(false);
readonly selectedScreenShareQualityDescription = computed(
() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
);
readonly notificationVolumePercent = computed(() =>
String(Math.round(this.audioService.notificationVolume() * 100))
);
constructor() {

View File

@@ -4,7 +4,7 @@
type="button"
(click)="goBack()"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Go back"
[title]="'settings.standalone.goBack' | translate"
>
<ng-icon
name="lucideArrowLeft"
@@ -15,7 +15,7 @@
name="lucideSettings"
class="w-6 h-6 text-primary"
/>
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
<h1 class="text-2xl font-bold text-foreground">{{ 'settings.title' | translate }}</h1>
</div>
<button
@@ -27,7 +27,7 @@
name="lucidePackage"
class="h-4 w-4"
/>
Plugin Store
{{ 'settings.standalone.pluginStore' | translate }}
</button>
<!-- Server Endpoints Section -->
@@ -38,7 +38,7 @@
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
<h2 class="text-lg font-semibold text-foreground">{{ 'settings.network.serverEndpoints.title' | translate }}</h2>
</div>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
@@ -67,7 +67,7 @@
</div>
<p class="text-sm text-muted-foreground mb-4">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
{{ 'settings.network.serverEndpoints.description' | translate }}
</p>
<!-- Server List -->
@@ -96,7 +96,9 @@
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">{{
'settings.network.serverEndpoints.active' | translate
}}</span>
}
</div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@@ -104,7 +106,7 @@
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
<p class="text-xs text-destructive">{{ 'settings.network.serverEndpoints.incompatible' | translate }}</p>
}
</div>
@@ -115,7 +117,7 @@
type="button"
(click)="setActiveServer(server.id)"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Activate"
[title]="'settings.network.serverEndpoints.activate' | translate"
>
<ng-icon
name="lucideCheck"
@@ -128,7 +130,7 @@
type="button"
(click)="deactivateServer(server.id)"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Deactivate"
[title]="'settings.network.serverEndpoints.deactivate' | translate"
>
<ng-icon
name="lucideX"
@@ -141,7 +143,7 @@
type="button"
(click)="removeServer(server.id)"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove server"
[title]="'settings.network.serverEndpoints.remove' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -156,19 +158,19 @@
<!-- Add New Server -->
<div class="border-t border-border pt-4">
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
<h3 class="text-sm font-medium text-foreground mb-3">{{ 'settings.network.serverEndpoints.addNew' | translate }}</h3>
<div class="flex gap-3">
<div class="flex-1 space-y-2">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name (e.g., My Server)"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -197,14 +199,14 @@
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
<h2 class="text-lg font-semibold text-foreground">{{ 'settings.network.connection.title' | translate }}</h2>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
<p class="font-medium text-foreground">{{ 'settings.network.connection.autoReconnect.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.network.connection.autoReconnect.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -221,8 +223,8 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
<p class="font-medium text-foreground">{{ 'settings.network.connection.searchAllServers.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.network.connection.searchAllServers.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -246,7 +248,7 @@
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
<h2 class="text-lg font-semibold text-foreground">{{ 'settings.voice.standalone.title' | translate }}</h2>
</div>
<div class="space-y-4">
@@ -254,8 +256,8 @@
<div>
<div class="flex items-center justify-between mb-2">
<div>
<p class="font-medium text-foreground">Notification volume</p>
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
<p class="font-medium text-foreground">{{ 'settings.voice.standalone.notificationVolume.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.voice.standalone.notificationVolume.description' | translate }}</p>
</div>
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
@@ -275,7 +277,7 @@
type="button"
(click)="previewNotificationSound()"
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
title="Preview sound"
[title]="'settings.voice.volume.previewTitle' | translate"
>
Test
</button>
@@ -284,8 +286,8 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Noise reduction</p>
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
<p class="font-medium text-foreground">{{ 'settings.voice.quality.noiseReduction.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.voice.quality.noiseReduction.descriptionLong' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input

View File

@@ -28,6 +28,7 @@ import { ServerDirectoryFacade } from '../../domains/server-directory';
import { VoiceConnectionFacade } from '../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
@Component({
selector: 'app-settings',
@@ -35,7 +36,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -61,6 +63,7 @@ export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private router = inject(Router);
private readonly appI18n = inject(AppI18nService);
audioService = inject(NotificationAudioService);
servers = this.serverDirectory.servers;
@@ -91,13 +94,13 @@ export class SettingsComponent implements OnInit {
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.invalidUrl'));
return;
}
// Check for duplicates
if (this.servers().some((server) => server.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.duplicateUrl'));
return;
}