user settings and breadcrumb fixes
This commit is contained in:
@@ -142,6 +142,8 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea
|
||||
- Query telemetry:
|
||||
- Unified search emits hashed query telemetry (`SHA-256` query hash, intent, domain weights, latency, top domains) via `IUnifiedSearchTelemetrySink`.
|
||||
- Search analytics persistence stores hashed query keys (`SHA-256`, normalized) and pseudonymous user keys (tenant+user hash) in analytics/feedback artifacts.
|
||||
- Self-serve analytics is optional and privacy-preserving: when clients emit `answer_frame`, `reformulation`, or `rescue_action`, persistence stores a tenant-scoped hashed session id plus bounded answer metadata (`answer_status`, `answer_code`) instead of raw prompt history.
|
||||
- Quality metrics surface self-serve gaps as `fallbackAnswerRate`, `clarifyRate`, `insufficientRate`, `reformulationCount`, `rescueActionCount`, and `abandonedFallbackCount`; alerting adds `fallback_loop` and `abandoned_fallback` signals for backlog review.
|
||||
- Free-form feedback comments are redacted at persistence time to avoid storing potential PII in analytics tables.
|
||||
- Server-side search history remains user-facing functionality (raw query for history UX) and is keyed by pseudonymous user hash.
|
||||
- Web fallback behavior: when unified search fails, `UnifiedSearchClient` falls back to legacy AKS (`/v1/advisory-ai/search`) and maps grouped legacy results into unified cards (`diagnostics.mode = legacy-fallback`).
|
||||
@@ -294,22 +296,25 @@ Run the full suite:
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v normal
|
||||
```
|
||||
|
||||
Targeted runs in this project use xUnit v3 / Microsoft.Testing.Platform.
|
||||
Do not use VSTest `dotnet test --filter ...` syntax here; use xUnit pass-through or the built test executable instead.
|
||||
|
||||
Run only the search sprint integration tests:
|
||||
```bash
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
|
||||
--filter "FullyQualifiedName~UnifiedSearchSprintIntegrationTests" -v normal
|
||||
-- --filter-class StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests
|
||||
```
|
||||
|
||||
Run only the FTS recall benchmark:
|
||||
```bash
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
|
||||
--filter "FullyQualifiedName~FtsRecallBenchmarkTests" -v normal
|
||||
-- --filter-class StellaOps.AdvisoryAI.Tests.KnowledgeSearch.FtsRecallBenchmarkTests
|
||||
```
|
||||
|
||||
Run only the semantic recall benchmark:
|
||||
```bash
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
|
||||
--filter "FullyQualifiedName~SemanticRecallBenchmarkTests" -v normal
|
||||
-- --filter-class StellaOps.AdvisoryAI.Tests.KnowledgeSearch.SemanticRecallBenchmarkTests
|
||||
```
|
||||
|
||||
**For live database tests** (e.g., full AKS rebuild + query against real Postgres with pg_trgm/pgvector):
|
||||
@@ -334,8 +339,9 @@ curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \
|
||||
-H "X-StellaOps-Tenant: test-tenant"
|
||||
|
||||
# Run tests with the Live category (requires database)
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
|
||||
--filter "Category=Live" -v normal
|
||||
dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal
|
||||
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
|
||||
-trait "Category=Live" -reporter verbose -noColor
|
||||
```
|
||||
|
||||
### CLI setup in a source checkout
|
||||
@@ -401,6 +407,7 @@ The search sprints added several migrations under `src/AdvisoryAI/StellaOps.Advi
|
||||
| `005_search_feedback.sql` | G10 (110) | `search_feedback` + `search_quality_alerts` tables |
|
||||
| `005_search_analytics.sql` | G6 (106) | `search_events` + `search_history` tables |
|
||||
| `007_multilingual_fts.sql` | G9 (109) | `body_tsv_de`, `body_tsv_fr`, `body_tsv_es`, `body_tsv_ru` tsvector columns + GIN indexes |
|
||||
| `008_search_self_serve_analytics.sql` | AI-SELF-004 | `session_id`, `answer_status`, `answer_code` analytics columns plus self-serve indexes |
|
||||
|
||||
All migrations are idempotent (IF NOT EXISTS guards). They run automatically via `EnsureSchemaAsync()` at service startup.
|
||||
|
||||
|
||||
@@ -63,6 +63,21 @@ flowchart LR
|
||||
- bounded follow-up `questions`
|
||||
- The answer envelope is additive and optional so older clients remain compatible.
|
||||
|
||||
### Telemetry and gap surfacing
|
||||
- Search analytics stays optional at the client layer; queries still work when analytics events are never emitted.
|
||||
- When enabled, the self-serve lane records `answer_frame`, `reformulation`, and `rescue_action` with hashed query keys, hashed tenant-scoped session ids, and bounded answer metadata.
|
||||
- Quality review surfaces:
|
||||
- `GET /v1/advisory-ai/search/quality/metrics`
|
||||
- `GET /v1/advisory-ai/search/quality/alerts`
|
||||
- Current self-serve gap signals:
|
||||
- fallback answer rate
|
||||
- clarify rate
|
||||
- insufficient-evidence rate
|
||||
- reformulation count
|
||||
- rescue-action count
|
||||
- abandoned fallback count
|
||||
- `fallback_loop` and `abandoned_fallback` alerts
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -8,7 +8,8 @@ Runbook for AdvisoryAI unified search setup, operations, troubleshooting, perfor
|
||||
2. Configure `AdvisoryAI:UnifiedSearch` options.
|
||||
3. Ensure model artifact path exists when `VectorEncoderType=onnx`:
|
||||
- default: `models/all-MiniLM-L6-v2.onnx`
|
||||
4. Rebuild index:
|
||||
4. Rebuild indexes in order when verifying live search quality:
|
||||
- `POST /v1/advisory-ai/index/rebuild`
|
||||
- `POST /v1/search/index/rebuild`
|
||||
5. Verify query endpoint:
|
||||
- `POST /v1/search/query` with `X-StellaOps-Tenant` and `advisory-ai:operate` scope.
|
||||
@@ -24,8 +25,11 @@ Runbook for AdvisoryAI unified search setup, operations, troubleshooting, perfor
|
||||
## Monitoring
|
||||
Track per-tenant and global:
|
||||
- Query throughput (`query`, `click`, `zero_result`, `synthesis` events)
|
||||
- Self-serve journey signals (`answer_frame`, `reformulation`, `rescue_action`)
|
||||
- P50/P95/P99 latency for `/v1/search/query`
|
||||
- Zero-result rate
|
||||
- Fallback answer rate, clarify rate, insufficient-evidence rate
|
||||
- Reformulation count, rescue-action count, abandoned fallback count
|
||||
- Synthesis quota denials
|
||||
- Index size and rebuild duration
|
||||
- Active encoder diagnostics (`diagnostics.activeEncoder`)
|
||||
@@ -104,6 +108,7 @@ Example:
|
||||
- Verify tenant header is present.
|
||||
- Verify `UnifiedSearch.Enabled` and tenant flag `Enabled`.
|
||||
- Run index rebuild and check chunk count.
|
||||
- If suggestions also fail, verify both rebuild steps were run in order and re-check with a known live query such as `database connectivity`.
|
||||
|
||||
### Symptom: poor semantic recall
|
||||
- Verify `VectorEncoderType` and active encoder diagnostics.
|
||||
@@ -114,6 +119,12 @@ Example:
|
||||
- Check `SynthesisEnabled` (global + tenant).
|
||||
- Check quota counters and provider configuration.
|
||||
|
||||
### Symptom: search feels self-serve weak
|
||||
- Inspect `GET /v1/advisory-ai/search/quality/metrics?period=7d`.
|
||||
- Watch `fallbackAnswerRate`, `clarifyRate`, `insufficientRate`, `reformulationCount`, `rescueActionCount`, and `abandonedFallbackCount`.
|
||||
- Inspect `GET /v1/advisory-ai/search/quality/alerts` for `fallback_loop` and `abandoned_fallback`.
|
||||
- Treat repeated fallback loops as ranking/context gaps; treat abandoned fallback sessions as UX/product gaps.
|
||||
|
||||
### Symptom: high latency
|
||||
- Check federated backend timeout budget.
|
||||
- Review `EXPLAIN (ANALYZE)` plans.
|
||||
@@ -140,4 +151,11 @@ dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.Advisory
|
||||
# Performance envelope
|
||||
dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj \
|
||||
-- --filter-class StellaOps.AdvisoryAI.Tests.UnifiedSearch.UnifiedSearchPerformanceEnvelopeTests
|
||||
|
||||
# Self-serve telemetry and gap surfacing slice
|
||||
dotnet build src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj -v minimal
|
||||
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
|
||||
-method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_SelfServeMetrics_IncludeFallbackReformulationAndRescueSignals" \
|
||||
-method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_RecoveredFallbackSessions_DoNotCountAsAbandoned" \
|
||||
-reporter verbose -noColor
|
||||
```
|
||||
|
||||
@@ -28,6 +28,30 @@ These tests run entirely in-memory. No Docker, no database, no network. Just `do
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v normal
|
||||
```
|
||||
|
||||
### Targeted runs in this repo
|
||||
This test project uses xUnit v3 on Microsoft.Testing.Platform.
|
||||
Do not rely on VSTest-style `dotnet test --filter ...` syntax here; it is ignored for this project.
|
||||
|
||||
Use one of these targeting patterns instead:
|
||||
|
||||
```bash
|
||||
# Class-level targeting through dotnet test pass-through
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
|
||||
-- --filter-class StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests
|
||||
|
||||
# Build once, then target individual classes, methods, or traits with the xUnit runner
|
||||
dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal
|
||||
|
||||
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
|
||||
-class "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests"
|
||||
|
||||
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
|
||||
-method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_SelfServeMetrics_IncludeFallbackReformulationAndRescueSignals"
|
||||
|
||||
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
|
||||
-trait "Category=Live"
|
||||
```
|
||||
|
||||
### Why no infrastructure is needed
|
||||
All integration tests use `WebApplicationFactory<Program>` with **stubbed** services:
|
||||
- `IKnowledgeSearchService` → `StubKnowledgeSearchService` (returns hardcoded results)
|
||||
@@ -215,8 +239,9 @@ public sealed class KnowledgeSearchLiveTests : IAsyncLifetime
|
||||
Run:
|
||||
```bash
|
||||
export ADVISORYAI_TEST_CONNSTRING="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge"
|
||||
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
|
||||
--filter "Category=Live" -v normal
|
||||
dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal
|
||||
src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.AdvisoryAI.Tests.exe \
|
||||
-trait "Category=Live" -reporter verbose -noColor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -644,22 +644,10 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
* User menu items (profile dropdown)
|
||||
*/
|
||||
export const USER_MENU_ITEMS = [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
route: '/console/profile',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
route: '/settings',
|
||||
icon: 'settings',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Language',
|
||||
route: '/settings/language',
|
||||
route: '/settings/user-preferences',
|
||||
icon: 'settings',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Per-team notification opt-in
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal, effect } from '@angular/core';
|
||||
import { Component, input, output, signal, effect, computed } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@@ -73,12 +73,14 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = {
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="ai-preferences">
|
||||
<header class="ai-preferences__header">
|
||||
<h2 class="ai-preferences__title">AI Assistant Preferences</h2>
|
||||
<p class="ai-preferences__description">
|
||||
Configure how AI assistance appears across StellaOps
|
||||
</p>
|
||||
</header>
|
||||
@if (!embedded()) {
|
||||
<header class="ai-preferences__header">
|
||||
<h2 class="ai-preferences__title">AI Assistant Preferences</h2>
|
||||
<p class="ai-preferences__description">
|
||||
Configure how AI assistance appears across StellaOps
|
||||
</p>
|
||||
</header>
|
||||
}
|
||||
|
||||
<div class="ai-preferences__sections">
|
||||
<!-- Verbosity Section -->
|
||||
@@ -172,17 +174,19 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = {
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ai-preferences__actions">
|
||||
<button class="ai-preferences__button ai-preferences__button--secondary"
|
||||
(click)="onReset()">
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<button class="ai-preferences__button ai-preferences__button--primary"
|
||||
[disabled]="!hasChanges()"
|
||||
(click)="onSave()">
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
@if (!embedded()) {
|
||||
<div class="ai-preferences__actions">
|
||||
<button class="ai-preferences__button ai-preferences__button--secondary"
|
||||
(click)="onReset()">
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<button class="ai-preferences__button ai-preferences__button--primary"
|
||||
[disabled]="!hasChanges()"
|
||||
(click)="onSave()">
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -418,6 +422,11 @@ export class AiPreferencesComponent {
|
||||
*/
|
||||
readonly teams = input<{ teamId: string; teamName: string }[]>([]);
|
||||
|
||||
/**
|
||||
* When true, hides header and action buttons; auto-emits on every change.
|
||||
*/
|
||||
readonly embedded = input(false);
|
||||
|
||||
/**
|
||||
* Preferences changed and saved.
|
||||
*/
|
||||
@@ -495,6 +504,7 @@ export class AiPreferencesComponent {
|
||||
|
||||
onVerbosityChange(value: AiVerbosity): void {
|
||||
this.currentPreferences.update(p => ({ ...p, verbosity: value }));
|
||||
this.autoEmit();
|
||||
}
|
||||
|
||||
onSurfaceChange(key: keyof AiSurfaceSettings, event: Event): void {
|
||||
@@ -503,6 +513,7 @@ export class AiPreferencesComponent {
|
||||
...p,
|
||||
surfaces: { ...p.surfaces, [key]: checked }
|
||||
}));
|
||||
this.autoEmit();
|
||||
}
|
||||
|
||||
onTeamNotificationChange(teamId: string, event: Event): void {
|
||||
@@ -513,6 +524,13 @@ export class AiPreferencesComponent {
|
||||
t.teamId === teamId ? { ...t, enabled } : t
|
||||
)
|
||||
}));
|
||||
this.autoEmit();
|
||||
}
|
||||
|
||||
private autoEmit(): void {
|
||||
if (this.embedded()) {
|
||||
this.save.emit(this.currentPreferences());
|
||||
}
|
||||
}
|
||||
|
||||
onReset(): void {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
title: 'Settings',
|
||||
loadComponent: () =>
|
||||
import('./settings-page.component').then(m => m.SettingsPageComponent),
|
||||
data: { breadcrumb: 'Settings' },
|
||||
data: {},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -104,6 +104,13 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
},
|
||||
{
|
||||
path: 'user-preferences',
|
||||
title: 'User Preferences',
|
||||
loadComponent: () =>
|
||||
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
|
||||
data: { breadcrumb: 'User Preferences' },
|
||||
},
|
||||
{
|
||||
path: 'language',
|
||||
title: 'Language',
|
||||
@@ -114,9 +121,8 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
{
|
||||
path: 'ai-preferences',
|
||||
title: 'AI Preferences',
|
||||
loadComponent: () =>
|
||||
import('./ai-preferences-workbench.component').then(m => m.AiPreferencesWorkbenchComponent),
|
||||
data: { breadcrumb: 'AI Preferences' },
|
||||
redirectTo: 'user-preferences',
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'policy',
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
|
||||
import { ThemeService, ThemeMode } from '../../../core/services/theme.service';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../../core/i18n';
|
||||
import { SidebarPreferenceService } from '../../../layout/app-sidebar/sidebar-preference.service';
|
||||
import { AiPreferencesComponent, type AiPreferences } from '../ai-preferences.component';
|
||||
import { PlainLanguageToggleComponent } from '../../advisory-ai/plain-language-toggle.component';
|
||||
|
||||
type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-preferences-page',
|
||||
standalone: true,
|
||||
imports: [AiPreferencesComponent, PlainLanguageToggleComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="prefs">
|
||||
<h1 class="prefs__title">User Preferences</h1>
|
||||
<p class="prefs__subtitle">Personalize your console experience</p>
|
||||
|
||||
<!-- Appearance -->
|
||||
<section class="prefs__card">
|
||||
<div class="prefs__card-header">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<h2>Appearance</h2>
|
||||
</div>
|
||||
<p class="prefs__description">Choose how the console looks. System mode follows your OS preference.</p>
|
||||
|
||||
<div class="prefs__theme-grid" role="radiogroup" aria-label="Theme selection">
|
||||
@for (opt of themeOptions; track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="prefs__theme-option"
|
||||
[class.prefs__theme-option--active]="themeService.mode() === opt.value"
|
||||
role="radio"
|
||||
[attr.aria-checked]="themeService.mode() === opt.value"
|
||||
(click)="themeService.setMode(opt.value)"
|
||||
>
|
||||
<span class="prefs__theme-icon">
|
||||
@switch (opt.value) {
|
||||
@case ('light') {
|
||||
<svg viewBox="0 0 24 24" width="24" height="24"><circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
@case ('dark') {
|
||||
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
@case ('system') {
|
||||
<svg viewBox="0 0 24 24" width="24" height="24"><rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/><line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="prefs__theme-label">{{ opt.label }}</span>
|
||||
@if (opt.value === 'system') {
|
||||
<span class="prefs__theme-hint">({{ themeService.systemPreference() === 'dark' ? 'Dark' : 'Light' }})</span>
|
||||
}
|
||||
@if (themeService.mode() === opt.value) {
|
||||
<svg class="prefs__check" viewBox="0 0 24 24" width="16" height="16"><polyline points="20 6 9 17 4 12" fill="none" stroke="currentColor" stroke-width="2.5"/></svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<section class="prefs__card">
|
||||
<div class="prefs__card-header">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<h2>Language</h2>
|
||||
</div>
|
||||
<p class="prefs__description">Changes apply immediately. Your preference is synced across devices when signed in.</p>
|
||||
|
||||
<label class="prefs__label" for="locale-select">Preferred language</label>
|
||||
<select
|
||||
id="locale-select"
|
||||
class="prefs__select"
|
||||
[value]="currentLocale()"
|
||||
[disabled]="localeSaving()"
|
||||
(change)="onLocaleSelected($event)"
|
||||
>
|
||||
@for (locale of localeOptions(); track locale) {
|
||||
<option [value]="locale">{{ localeDisplayName(locale) }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (localeStatus(); as msg) {
|
||||
<p class="prefs__status" [class.prefs__status--warn]="localeSaveState() === 'syncFailed'">{{ msg }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Layout -->
|
||||
<section class="prefs__card">
|
||||
<div class="prefs__card-header">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<h2>Layout</h2>
|
||||
</div>
|
||||
<p class="prefs__description">Control the default layout of the console sidebar.</p>
|
||||
|
||||
<div class="prefs__toggle-row">
|
||||
<div class="prefs__toggle-info">
|
||||
<span class="prefs__toggle-label">Sidebar collapsed by default</span>
|
||||
<span class="prefs__toggle-hint">Start with the sidebar minimized to icons only</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
class="prefs__switch"
|
||||
[class.prefs__switch--on]="sidebarCollapsed()"
|
||||
[attr.aria-checked]="sidebarCollapsed()"
|
||||
(click)="sidebarPrefs.toggleSidebar()"
|
||||
>
|
||||
<span class="prefs__switch-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AI Assistant -->
|
||||
<section class="prefs__card">
|
||||
<div class="prefs__card-header">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 17l10 5 10-5" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 12l10 5 10-5" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<h2>AI Assistant</h2>
|
||||
</div>
|
||||
<p class="prefs__description">Configure how AI assistance appears across StellaOps.</p>
|
||||
|
||||
<stella-ai-preferences
|
||||
[initialPreferences]="aiInitialPreferences"
|
||||
[teams]="aiTeams"
|
||||
[embedded]="true"
|
||||
(save)="onAiPreferencesSave($event)"
|
||||
/>
|
||||
|
||||
<div class="prefs__ai-plain-language">
|
||||
<stellaops-plain-language-toggle
|
||||
label="Explain like I'm new"
|
||||
description="Simplify technical jargon in AI guidance"
|
||||
[showBadge]="true"
|
||||
[initialValue]="false"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.prefs {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.prefs__title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.prefs__subtitle {
|
||||
margin: 0 0 1.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.prefs__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.prefs__card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.prefs__card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.prefs__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Theme grid */
|
||||
.prefs__theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.prefs__theme-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0.75rem;
|
||||
border: 2px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.prefs__theme-option:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.prefs__theme-option:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.prefs__theme-option--active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary));
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.prefs__theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.prefs__theme-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.prefs__theme-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.prefs__check {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
/* Language */
|
||||
.prefs__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.prefs__select {
|
||||
width: min(320px, 100%);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
.prefs__select:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.prefs__status {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.prefs__status--warn {
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
/* Toggle / Switch */
|
||||
.prefs__toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.prefs__toggle-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.prefs__toggle-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.prefs__toggle-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.prefs__switch {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: var(--color-surface-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prefs__switch--on {
|
||||
background: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.prefs__switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.15s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.prefs__switch--on .prefs__switch-thumb {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.prefs__switch:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* AI section */
|
||||
.prefs__ai-plain-language {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.prefs__theme-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class UserPreferencesPageComponent {
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
protected readonly sidebarPrefs = inject(SidebarPreferenceService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly localeCatalog = inject(LocaleCatalogService);
|
||||
private readonly localePreference = inject(UserLocalePreferenceService);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
readonly currentLocale = this.i18n.locale;
|
||||
readonly sidebarCollapsed = this.sidebarPrefs.sidebarCollapsed;
|
||||
readonly localeOptions = signal<string[]>([...SUPPORTED_LOCALES]);
|
||||
readonly localeSaving = signal(false);
|
||||
readonly localeSaveState = signal<LocaleSaveState>('idle');
|
||||
|
||||
readonly aiInitialPreferences: AiPreferences = {
|
||||
verbosity: 'standard',
|
||||
surfaces: { showInUi: true, showInPrComments: false, showInNotifications: false },
|
||||
teamNotifications: [
|
||||
{ teamId: 'team-platform', teamName: 'Platform Team', enabled: false },
|
||||
{ teamId: 'team-security', teamName: 'Security Team', enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
readonly aiTeams = [
|
||||
{ teamId: 'team-platform', teamName: 'Platform Team' },
|
||||
{ teamId: 'team-security', teamName: 'Security Team' },
|
||||
];
|
||||
|
||||
readonly themeOptions: Array<{ value: ThemeMode; label: string }> = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'system', label: 'System' },
|
||||
];
|
||||
|
||||
readonly localeStatus = computed(() => {
|
||||
const state = this.localeSaveState();
|
||||
if (state === 'saving') return 'Saving...';
|
||||
if (state === 'syncFailed') return 'Saved locally, but account sync failed.';
|
||||
if (state === 'saved') {
|
||||
return this.authSession.isAuthenticated()
|
||||
? 'Saved to your account.'
|
||||
: 'Saved locally. Sign in to sync across devices.';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.loadLocaleOptions();
|
||||
}
|
||||
|
||||
async onLocaleSelected(event: Event): Promise<void> {
|
||||
const selected = (event.target as HTMLSelectElement | null)?.value?.trim();
|
||||
if (!selected || selected === this.currentLocale()) return;
|
||||
|
||||
this.localeSaving.set(true);
|
||||
this.localeSaveState.set('saving');
|
||||
|
||||
try {
|
||||
await this.i18n.setLocale(selected);
|
||||
|
||||
if (this.authSession.isAuthenticated()) {
|
||||
try {
|
||||
await this.localePreference.setLocaleAsync(selected);
|
||||
this.localeSaveState.set('saved');
|
||||
} catch {
|
||||
this.localeSaveState.set('syncFailed');
|
||||
}
|
||||
} else {
|
||||
this.localeSaveState.set('saved');
|
||||
}
|
||||
} finally {
|
||||
this.localeSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onAiPreferencesSave(_preferences: AiPreferences): void {
|
||||
// Will be wired to a backend persistence endpoint
|
||||
}
|
||||
|
||||
localeDisplayName(locale: string): string {
|
||||
const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`;
|
||||
return this.i18n.tryT(key) ?? locale;
|
||||
}
|
||||
|
||||
private async loadLocaleOptions(): Promise<void> {
|
||||
const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES);
|
||||
this.localeOptions.set([...locales]);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { filter } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
|
||||
import { GlobalSearchComponent } from '../global-search/global-search.component';
|
||||
import { ContextChipsComponent } from '../context-chips/context-chips.component';
|
||||
@@ -89,6 +90,32 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
Context
|
||||
</button>
|
||||
|
||||
<!-- Theme toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__theme-toggle"
|
||||
[attr.aria-label]="'Theme: ' + themeService.modeLabel() + '. Click to cycle.'"
|
||||
[title]="themeService.modeLabel()"
|
||||
(click)="themeService.cycle()"
|
||||
>
|
||||
@if (themeService.mode() === 'dark') {
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
} @else if (themeService.mode() === 'system') {
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- User menu -->
|
||||
<app-user-menu></app-user-menu>
|
||||
</div>
|
||||
@@ -313,6 +340,32 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.topbar__theme-toggle:hover {
|
||||
background: var(--color-nav-hover);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.topbar__theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.topbar__primary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -533,6 +586,7 @@ export class AppTopbarComponent {
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly localePreference = inject(UserLocalePreferenceService);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
|
||||
@@ -198,10 +198,13 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
|
||||
|
||||
const label = child.snapshot.data['breadcrumb'];
|
||||
if (label) {
|
||||
breadcrumbs.push({
|
||||
label,
|
||||
route: url,
|
||||
});
|
||||
const prev = breadcrumbs[breadcrumbs.length - 1];
|
||||
if (!prev || prev.label !== label || prev.route !== url) {
|
||||
breadcrumbs.push({
|
||||
label,
|
||||
route: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildBreadcrumbs(child, url, breadcrumbs);
|
||||
|
||||
@@ -204,114 +204,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Theme Toggle
|
||||
// =============================================================================
|
||||
|
||||
.user-menu__theme {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.user-menu__theme-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.user-menu__theme-options {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: 2px;
|
||||
background-color: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.user-menu__theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||
color var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Locale Selector
|
||||
// =============================================================================
|
||||
|
||||
.user-menu__locale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.user-menu__locale-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.user-menu__locale-select {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.02em;
|
||||
min-width: 90px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.user-menu__trigger,
|
||||
.user-menu__item,
|
||||
.user-menu__theme-btn,
|
||||
.user-menu__chevron {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Component, inject, HostListener, signal, computed, effect, DestroyRef } from '@angular/core';
|
||||
import { Component, inject, HostListener, signal } from '@angular/core';
|
||||
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { ThemeService } from '../../../core/services/theme.service';
|
||||
import { NavigationService } from '../../../core/navigation';
|
||||
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../../core/i18n';
|
||||
|
||||
/**
|
||||
* User menu dropdown component.
|
||||
@@ -87,75 +84,6 @@ import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePrefere
|
||||
|
||||
<div class="user-menu__divider"></div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div class="user-menu__theme">
|
||||
<span class="user-menu__theme-label">Theme</span>
|
||||
<div class="user-menu__theme-options" role="radiogroup" aria-label="Theme selection">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu__theme-btn"
|
||||
[class.user-menu__theme-btn--active]="themeService.mode() === 'light'"
|
||||
(click)="themeService.setMode('light')"
|
||||
role="radio"
|
||||
[attr.aria-checked]="themeService.mode() === 'light'"
|
||||
aria-label="Light theme"
|
||||
title="Light"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu__theme-btn"
|
||||
[class.user-menu__theme-btn--active]="themeService.mode() === 'dark'"
|
||||
(click)="themeService.setMode('dark')"
|
||||
role="radio"
|
||||
[attr.aria-checked]="themeService.mode() === 'dark'"
|
||||
aria-label="Dark theme"
|
||||
title="Dark"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu__theme-btn"
|
||||
[class.user-menu__theme-btn--active]="themeService.mode() === 'system'"
|
||||
(click)="themeService.setMode('system')"
|
||||
role="radio"
|
||||
[attr.aria-checked]="themeService.mode() === 'system'"
|
||||
aria-label="System theme"
|
||||
title="System"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locale Selector -->
|
||||
<div class="user-menu__locale">
|
||||
<span class="user-menu__locale-label">{{ localeLabel() }}</span>
|
||||
<select
|
||||
class="user-menu__locale-select"
|
||||
[value]="currentLocale()"
|
||||
[attr.aria-label]="'Language selector. Current: ' + currentLocale()"
|
||||
(change)="onLocaleSelected($event)"
|
||||
>
|
||||
@for (locale of localeOptions(); track locale) {
|
||||
<option [value]="locale">{{ localeDisplayName(locale) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider"></div>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,63 +108,9 @@ import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePrefere
|
||||
})
|
||||
export class UserMenuComponent {
|
||||
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
protected readonly navService = inject(NavigationService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly localeCatalog = inject(LocaleCatalogService);
|
||||
private readonly localePreference = inject(UserLocalePreferenceService);
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly menuOpen = signal(false);
|
||||
readonly currentLocale = this.i18n.locale;
|
||||
readonly localeLabel = computed(() => this.i18n.tryT('ui.locale.label') ?? 'Language');
|
||||
private readonly availableLocales = signal<string[]>([...SUPPORTED_LOCALES]);
|
||||
private readonly localeCatalogSyncAttempted = signal(false);
|
||||
readonly localeOptions = computed(() => {
|
||||
const current = this.currentLocale();
|
||||
const options = [...this.availableLocales()];
|
||||
if (current && !options.includes(current)) {
|
||||
options.unshift(current);
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const authenticated = this.sessionStore.isAuthenticated();
|
||||
if (!authenticated) {
|
||||
this.localeCatalogSyncAttempted.set(false);
|
||||
this.availableLocales.set([...SUPPORTED_LOCALES]);
|
||||
return;
|
||||
}
|
||||
if (!this.localeCatalogSyncAttempted()) {
|
||||
this.localeCatalogSyncAttempted.set(true);
|
||||
void this.loadLocaleCatalog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onLocaleSelected(event: Event): Promise<void> {
|
||||
const selectedLocale = (event.target as HTMLSelectElement | null)?.value?.trim();
|
||||
if (!selectedLocale || selectedLocale === this.currentLocale()) {
|
||||
return;
|
||||
}
|
||||
await this.i18n.setLocale(selectedLocale);
|
||||
if (this.sessionStore.isAuthenticated()) {
|
||||
void this.localePreference.setLocaleAsync(selectedLocale).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
localeDisplayName(locale: string): string {
|
||||
const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`;
|
||||
return this.i18n.tryT(key) ?? locale;
|
||||
}
|
||||
|
||||
private async loadLocaleCatalog(): Promise<void> {
|
||||
const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES);
|
||||
this.availableLocales.set([...locales]);
|
||||
}
|
||||
|
||||
protected readonly displayName = () => {
|
||||
const user = this.authService.user();
|
||||
|
||||
Reference in New Issue
Block a user