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
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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user