From c02c0cdde92e06804035733582afc8aef37b05c9 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Fri, 10 Jul 2026 01:14:30 -0700 Subject: [PATCH] Refactors admin page design to move each 'facet' of admin into its own component. --- .../admin/admin-community-tab.component.html | 36 + .../admin/admin-community-tab.component.scss | 2 + .../admin/admin-community-tab.component.ts | 45 ++ src/app/admin/admin-events-tab.component.html | 36 + src/app/admin/admin-events-tab.component.scss | 2 + src/app/admin/admin-events-tab.component.ts | 45 ++ .../admin/admin-history-tab.component.html | 36 + .../admin/admin-history-tab.component.scss | 2 + src/app/admin/admin-history-tab.component.ts | 45 ++ .../admin-mobile-builds-tab.component.html | 218 ++++++ .../admin-mobile-builds-tab.component.scss | 200 +++++ .../admin-mobile-builds-tab.component.ts | 277 +++++++ src/app/admin/admin-shows-tab.component.html | 42 + src/app/admin/admin-shows-tab.component.scss | 2 + src/app/admin/admin-shows-tab.component.ts | 45 ++ .../admin/admin-station-tab.component.html | 59 ++ .../admin/admin-station-tab.component.scss | 2 + src/app/admin/admin-station-tab.component.ts | 27 + src/app/admin/admin-team-tab.component.html | 36 + src/app/admin/admin-team-tab.component.scss | 2 + src/app/admin/admin-team-tab.component.ts | 45 ++ src/app/admin/admin-theme-tab.component.html | 53 ++ src/app/admin/admin-theme-tab.component.scss | 192 +++++ src/app/admin/admin-theme-tab.component.ts | 178 +++++ .../admin-underwriters-tab.component.html | 44 ++ .../admin-underwriters-tab.component.scss | 9 + .../admin/admin-underwriters-tab.component.ts | 45 ++ src/app/admin/admin.component.html | 633 +-------------- src/app/admin/admin.component.scss | 721 ++++-------------- src/app/admin/admin.component.ts | 648 ++-------------- 30 files changed, 1970 insertions(+), 1757 deletions(-) create mode 100644 src/app/admin/admin-community-tab.component.html create mode 100644 src/app/admin/admin-community-tab.component.scss create mode 100644 src/app/admin/admin-community-tab.component.ts create mode 100644 src/app/admin/admin-events-tab.component.html create mode 100644 src/app/admin/admin-events-tab.component.scss create mode 100644 src/app/admin/admin-events-tab.component.ts create mode 100644 src/app/admin/admin-history-tab.component.html create mode 100644 src/app/admin/admin-history-tab.component.scss create mode 100644 src/app/admin/admin-history-tab.component.ts create mode 100644 src/app/admin/admin-mobile-builds-tab.component.html create mode 100644 src/app/admin/admin-mobile-builds-tab.component.scss create mode 100644 src/app/admin/admin-mobile-builds-tab.component.ts create mode 100644 src/app/admin/admin-shows-tab.component.html create mode 100644 src/app/admin/admin-shows-tab.component.scss create mode 100644 src/app/admin/admin-shows-tab.component.ts create mode 100644 src/app/admin/admin-station-tab.component.html create mode 100644 src/app/admin/admin-station-tab.component.scss create mode 100644 src/app/admin/admin-station-tab.component.ts create mode 100644 src/app/admin/admin-team-tab.component.html create mode 100644 src/app/admin/admin-team-tab.component.scss create mode 100644 src/app/admin/admin-team-tab.component.ts create mode 100644 src/app/admin/admin-theme-tab.component.html create mode 100644 src/app/admin/admin-theme-tab.component.scss create mode 100644 src/app/admin/admin-theme-tab.component.ts create mode 100644 src/app/admin/admin-underwriters-tab.component.html create mode 100644 src/app/admin/admin-underwriters-tab.component.scss create mode 100644 src/app/admin/admin-underwriters-tab.component.ts diff --git a/src/app/admin/admin-community-tab.component.html b/src/app/admin/admin-community-tab.component.html new file mode 100644 index 0000000..36b34de --- /dev/null +++ b/src/app/admin/admin-community-tab.component.html @@ -0,0 +1,36 @@ +
+
+

Community Highlights ({{ (highlights$ | async)?.length }})

+ +
+ + + + + + + + + + + + + @for (highlight of highlights$ | async; track highlight.id) { + + + + + + + + } @empty { + + + + } + +
IconTitleOrderActiveActions
{{ highlight.icon }}{{ highlight.title }}{{ highlight.display_order }}{{ highlight.active ? 'Yes' : 'No' }} + + +
No highlights yet. Click "Add Highlight" to create one.
+
diff --git a/src/app/admin/admin-community-tab.component.scss b/src/app/admin/admin-community-tab.component.scss new file mode 100644 index 0000000..de65d9b --- /dev/null +++ b/src/app/admin/admin-community-tab.component.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; diff --git a/src/app/admin/admin-community-tab.component.ts b/src/app/admin/admin-community-tab.component.ts new file mode 100644 index 0000000..e436284 --- /dev/null +++ b/src/app/admin/admin-community-tab.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { CommunityHighlight } from '../interfaces/community-highlight'; +import { CommunityHighlightService } from '../services/community-highlight.service'; + +@Component({ + selector: 'app-admin-community-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-community-tab.component.html', + styleUrl: './admin-community-tab.component.scss', +}) +export class AdminCommunityTabComponent { + private communityHighlightService = inject(CommunityHighlightService); + + readonly openForm = output(); + readonly editItem = output(); + readonly deleteItem = output(); + + readonly highlights$ = this.communityHighlightService.getCommunityHighlights(); + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + await firstValueFrom(this.communityHighlightService.getCommunityHighlights()); + } + + onAdd(): void { + this.openForm.emit(); + } + + onEdit(highlight: CommunityHighlight): void { + this.editItem.emit(highlight); + } + + async onDelete(id: number): Promise { + if (!confirm('Delete this community highlight?')) return; + await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id)); + await this.load(); + } +} diff --git a/src/app/admin/admin-events-tab.component.html b/src/app/admin/admin-events-tab.component.html new file mode 100644 index 0000000..676ebe0 --- /dev/null +++ b/src/app/admin/admin-events-tab.component.html @@ -0,0 +1,36 @@ +
+
+

Events ({{ (events$ | async)?.length }})

+ +
+ + + + + + + + + + + + + @for (event of events$ | async; track event.id) { + + + + + + + + } @empty { + + + + } + +
DateTitleLocationActiveActions
{{ event.date }}{{ event.title }}{{ event.location }}{{ event.active ? 'Yes' : 'No' }} + + +
No events yet. Click "Add Event" to create one.
+
diff --git a/src/app/admin/admin-events-tab.component.scss b/src/app/admin/admin-events-tab.component.scss new file mode 100644 index 0000000..de65d9b --- /dev/null +++ b/src/app/admin/admin-events-tab.component.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; diff --git a/src/app/admin/admin-events-tab.component.ts b/src/app/admin/admin-events-tab.component.ts new file mode 100644 index 0000000..377bccb --- /dev/null +++ b/src/app/admin/admin-events-tab.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { Event as StationEvent } from '../interfaces/event'; +import { EventService } from '../services/event.service'; + +@Component({ + selector: 'app-admin-events-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-events-tab.component.html', + styleUrl: './admin-events-tab.component.scss', +}) +export class AdminEventsTabComponent { + private eventService = inject(EventService); + + readonly openForm = output(); + readonly editItem = output(); + readonly deleteItem = output(); + + readonly events$ = this.eventService.getEvents(); + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + await firstValueFrom(this.eventService.getEvents()); + } + + onAdd(): void { + this.openForm.emit(); + } + + onEdit(event: StationEvent): void { + this.editItem.emit(event); + } + + async onDelete(id: number): Promise { + if (!confirm('Delete this event?')) return; + await firstValueFrom(this.eventService.deleteEvent(id)); + await this.load(); + } +} diff --git a/src/app/admin/admin-history-tab.component.html b/src/app/admin/admin-history-tab.component.html new file mode 100644 index 0000000..0b57864 --- /dev/null +++ b/src/app/admin/admin-history-tab.component.html @@ -0,0 +1,36 @@ +
+
+

History Timeline ({{ (entries$ | async)?.length }})

+ +
+ + + + + + + + + + + + + @for (entry of entries$ | async; track entry.id) { + + + + + + + + } @empty { + + + + } + +
YearTitleOrderActiveActions
{{ entry.year }}{{ entry.title }}{{ entry.display_order }}{{ entry.active ? 'Yes' : 'No' }} + + +
No history entries yet. Click "Add Entry" to create one.
+
diff --git a/src/app/admin/admin-history-tab.component.scss b/src/app/admin/admin-history-tab.component.scss new file mode 100644 index 0000000..de65d9b --- /dev/null +++ b/src/app/admin/admin-history-tab.component.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; diff --git a/src/app/admin/admin-history-tab.component.ts b/src/app/admin/admin-history-tab.component.ts new file mode 100644 index 0000000..33c929c --- /dev/null +++ b/src/app/admin/admin-history-tab.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { HistoryEntry } from '../interfaces/history-entry'; +import { HistoryEntryService } from '../services/history-entry.service'; + +@Component({ + selector: 'app-admin-history-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-history-tab.component.html', + styleUrl: './admin-history-tab.component.scss', +}) +export class AdminHistoryTabComponent { + private historyEntryService = inject(HistoryEntryService); + + readonly openForm = output(); + readonly editItem = output(); + readonly deleteItem = output(); + + readonly entries$ = this.historyEntryService.getHistoryEntries(); + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + await firstValueFrom(this.historyEntryService.getHistoryEntries()); + } + + onAdd(): void { + this.openForm.emit(); + } + + onEdit(entry: HistoryEntry): void { + this.editItem.emit(entry); + } + + async onDelete(id: number): Promise { + if (!confirm('Delete this history entry?')) return; + await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id)); + await this.load(); + } +} diff --git a/src/app/admin/admin-mobile-builds-tab.component.html b/src/app/admin/admin-mobile-builds-tab.component.html new file mode 100644 index 0000000..1f5a02a --- /dev/null +++ b/src/app/admin/admin-mobile-builds-tab.component.html @@ -0,0 +1,218 @@ +
+
+

Mobile Build Orchestration

+ +
+ +
+
+

Derived Defaults

+ @if (mobileBuildDefaults; as defaults) { +
+
Tenant: {{ defaults.tenant_key }}
+
App Name: {{ defaults.app_name }}
+
Support Email: {{ defaults.support_email }}
+
Website: {{ defaults.website }}
+
Stream URL: {{ defaults.stream_url || '(not set)' }}
+
+ } @else { +

Defaults unavailable. Check station config.

+ } +
+ +
+

Theme Export

+ @if (themeJsonStatus$ | async; as status) { + @if (status.exists) { +
+
theme.json — ready
+
Path: {{ status.path }}
+
Generated: {{ status.generated_at }}
+
Size: {{ status.size }} bytes
+
+ } @else { +
+

theme.json not found at {{ status.path }}

+

Regenerate to create it from the current station config.

+
+ } + + } @else { + @if (themeJsonLoading$ | async) { +

Loading...

+ } + } +
+ +
+

Create Build Request

+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +

Required Files

+
+ + +
+ +
+ + +
+ + @if (mobileBuildStatusMessage) { +

{{ mobileBuildStatusMessage }}

+ } + @if (mobileBuildErrorMessage) { +

{{ mobileBuildErrorMessage }}

+ } + + +
+
+ +
+

Build Requests

+ @if (mobileBuilds$ | async; as builds) { + + + + + + + + + + + + + @for (build of builds; track build.id) { + + + + + + + + + } @empty { + + + + } + +
IDPlatformsVersionStatusPreflightActions
#{{ build.id }} + {{ build.platform_android ? 'Android' : '' }} + {{ build.platform_android && build.platform_ios ? ' + ' : '' }} + {{ build.platform_ios ? 'iOS' : '' }} + {{ build.version_name }} ({{ build.build_number }}){{ build.status }}{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }} + +
No build requests yet.
+ } +
+ + @if (selectedMobileBuild; as build) { +
+
+

Selected Request #{{ build.id }}

+
+ + + +
+
+ + @if (mobileBuildPreflightIssues.length > 0) { +
+ Preflight issues: +
    + @for (issue of mobileBuildPreflightIssues; track issue) { +
  • {{ issue }}
  • + } +
+
+ } + + @if (build.error_message) { +

Error: {{ build.error_message }}

+ } + +

Artifacts

+
+ @for (artifact of build.artifacts; track artifact.id) { +
+
+ {{ artifact.file_name }} +
{{ artifact.platform }} • {{ artifact.artifact_type }} • {{ + artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}
+
+ +
+ } @empty { +

No artifacts available yet.

+ } +
+ +

Build Log

+
{{ build.build_log || 'No logs yet.' }}
+
+ } +
diff --git a/src/app/admin/admin-mobile-builds-tab.component.scss b/src/app/admin/admin-mobile-builds-tab.component.scss new file mode 100644 index 0000000..0aed536 --- /dev/null +++ b/src/app/admin/admin-mobile-builds-tab.component.scss @@ -0,0 +1,200 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.mobile-builds-panel { + display: grid; + gap: $spacing-lg; +} + +.mobile-build-grid { + display: grid; + grid-template-columns: 1fr 2fr; + gap: $spacing-md; +} + +.build-card { + background: $neutral-white; + border: 1px solid $neutral-light; + border-radius: $radius-md; + padding: $spacing-md; + overflow: hidden; + + h3, + h4 { + color: $primary-blue; + margin-top: 0; + } +} + +.derived-list { + display: grid; + gap: $spacing-xs; + font-size: 0.92rem; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: $spacing-sm; + margin-bottom: $spacing-sm; + + label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.85rem; + color: $neutral-dark; + font-weight: 600; + } + + input[type='text'], + input[type='file'] { + border: 1px solid $neutral-light; + border-radius: $radius-sm; + padding: $spacing-xs $spacing-sm; + font-size: 0.88rem; + } +} + +.platform-row { + grid-template-columns: auto auto; + + label { + flex-direction: row; + align-items: center; + font-weight: 600; + } +} + +.status { + border-radius: $radius-sm; + padding: $spacing-sm; + margin: $spacing-sm 0; + font-size: 0.9rem; + + &.success { + background: rgba($success-green, 0.1); + color: $success-green; + } + + &.error { + background: rgba($danger-red, 0.1); + color: $danger-red; + } + + ul { + margin: $spacing-xs 0 0 $spacing-md; + } +} + +.selected-build { + background: rgba($primary-blue, 0.06); +} + +.tab-toolbar.compact { + margin-bottom: $spacing-sm; +} + +.action-group { + display: flex; + gap: $spacing-xs; +} + +.artifact-list { + display: grid; + gap: $spacing-sm; + margin-bottom: $spacing-md; +} + +.artifact-row { + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid $neutral-light; + border-radius: $radius-sm; + padding: $spacing-sm; +} + +.artifact-meta { + font-size: 0.78rem; + color: $neutral-medium; +} + +.build-log { + background: $neutral-black; + color: $neutral-white; + border-radius: $radius-sm; + padding: $spacing-sm; + max-height: 280px; + overflow-x: auto; + overflow-y: auto; + font-size: 0.8rem; + width: 100%; + + pre { + margin: 0; + white-space: pre; + } +} + +.theme-status-ok, +.theme-status-missing { + padding: $spacing-sm $spacing-md; + border-radius: $radius-sm; + margin-bottom: $spacing-sm; + font-size: 0.88rem; + color: $neutral-dark; +} + +.theme-status-ok { + background: rgba($success-green, 0.08); + border: 1px solid rgba($success-green, 0.25); + display: grid; + gap: 2px; +} + +.theme-status-missing p { + margin: 0 0 $spacing-xs; + + &:last-child { + margin-bottom: 0; + } +} + +.theme-status-missing { + background: rgba($accent-orange, 0.08); + border: 1px solid rgba($accent-orange, 0.25); +} + +.btn-outline { + background: transparent; + border: 2px solid $primary-blue; + color: $primary-blue; + border-radius: $radius-md; + padding: $spacing-xs $spacing-md; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background $transition-fast, color $transition-fast; + margin-top: $spacing-sm; + + &:hover:not(:disabled) { + background: $primary-blue; + color: $neutral-white; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +@include responsive(md) { + .mobile-build-grid { + grid-template-columns: 1fr; + } + + .action-group { + flex-wrap: wrap; + } +} diff --git a/src/app/admin/admin-mobile-builds-tab.component.ts b/src/app/admin/admin-mobile-builds-tab.component.ts new file mode 100644 index 0000000..2028983 --- /dev/null +++ b/src/app/admin/admin-mobile-builds-tab.component.ts @@ -0,0 +1,277 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; + +import { + MobileBuildArtifact, + MobileBuildDefaults, + MobileBuildRequest, + ThemeStatus, +} from '../interfaces/mobile-build'; +import { MobileBuildService } from '../services/mobile-build.service'; +import { getAppConfig } from '../services/app-config.service'; + +@Component({ + selector: 'app-admin-mobile-builds-tab', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-mobile-builds-tab.component.html', + styleUrl: './admin-mobile-builds-tab.component.scss', +}) +export class AdminMobileBuildsTabComponent implements OnInit { + private mobileBuildService = inject(MobileBuildService); + + readonly mobileBuilds$ = new BehaviorSubject([]); + readonly themeJsonStatus$ = new BehaviorSubject(null); + readonly themeJsonLoading$ = new BehaviorSubject(false); + + mobileBuildDefaults: MobileBuildDefaults | null = null; + selectedMobileBuildId: number | null = null; + selectedMobileBuild: MobileBuildRequest | null = null; + mobileBuildStatusMessage = ''; + mobileBuildErrorMessage = ''; + mobileBuildPreflightIssues: string[] = []; + mobileBuildBusy = false; + + buildAndroid = true; + buildIos = true; + androidApplicationId = ''; + iosBundleId = ''; + buildVersionName = '1.0.0'; + buildNumber = '1'; + buildReleaseProfile = 'release'; + + androidKeystoreFile: File | null = null; + androidDescriptorFile: File | null = null; + iosCertificateFile: File | null = null; + iosProfileFile: File | null = null; + + ngOnInit(): void { + this.loadThemeJsonStatus(); + this.reloadMobileBuildDefaults(); + this.reloadMobileBuilds(); + } + + async loadThemeJsonStatus(): Promise { + try { + this.themeJsonLoading$.next(true); + this.themeJsonStatus$.next(await firstValueFrom( + this.mobileBuildService.getThemeStatus(), + )); + } catch { + this.themeJsonStatus$.next(null); + } finally { + this.themeJsonLoading$.next(false); + } + } + + async regenerateThemeJson(): Promise { + try { + this.themeJsonLoading$.next(true); + await firstValueFrom(this.mobileBuildService.regenerateTheme()); + await this.loadThemeJsonStatus(); + } catch (err) { + this.mobileBuildErrorMessage = this.getErrorMessage( + err, + 'Failed to regenerate theme.json.', + ); + } finally { + this.themeJsonLoading$.next(false); + } + } + + async reloadMobileBuildDefaults(): Promise { + try { + this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults()); + } catch { + this.mobileBuildDefaults = null; + } + } + + async reloadMobileBuilds(): Promise { + const data = await firstValueFrom(this.mobileBuildService.listRequests()); + this.mobileBuilds$.next(data); + + if (this.selectedMobileBuildId !== null) { + const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null; + this.selectedMobileBuild = selected; + if (!selected) { + this.selectedMobileBuildId = null; + } + } + } + + onMobileBuildSelected(buildId: number): void { + this.selectedMobileBuildId = buildId; + const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null; + this.selectedMobileBuild = selected; + this.mobileBuildPreflightIssues = selected?.preflight_report + ? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0) + : []; + } + + onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void { + const input = event.target as HTMLInputElement; + const file = input.files && input.files.length > 0 ? input.files[0] : null; + + if (target === 'android-keystore') this.androidKeystoreFile = file; + if (target === 'android-descriptor') this.androidDescriptorFile = file; + if (target === 'ios-certificate') this.iosCertificateFile = file; + if (target === 'ios-profile') this.iosProfileFile = file; + } + + private getErrorMessage(err: unknown, fallback: string): string { + if (typeof err === 'object' && err !== null) { + const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string }; + const detail = maybeErr.error?.detail; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) return detail.join(' | '); + if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message; + if (typeof maybeErr.message === 'string') return maybeErr.message; + } + return fallback; + } + + private resetMobileBuildMessages(): void { + this.mobileBuildStatusMessage = ''; + this.mobileBuildErrorMessage = ''; + } + + private validateBuildInputs(): string | null { + if (!this.buildAndroid && !this.buildIos) { + return 'Select at least one platform.'; + } + + if (this.buildAndroid && !this.androidApplicationId.trim()) { + return 'Android application ID is required.'; + } + + if (this.buildIos && !this.iosBundleId.trim()) { + return 'iOS bundle ID is required.'; + } + + if (!this.buildVersionName.trim() || !this.buildNumber.trim()) { + return 'Version name and build number are required.'; + } + + if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) { + return 'Android requires both keystore and descriptor files.'; + } + + if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) { + return 'iOS requires both certificate and provisioning profile files.'; + } + + return null; + } + + async createAndUploadMobileBuildRequest(): Promise { + this.resetMobileBuildMessages(); + this.mobileBuildPreflightIssues = []; + const inputError = this.validateBuildInputs(); + if (inputError) { + this.mobileBuildErrorMessage = inputError; + return; + } + + this.mobileBuildBusy = true; + try { + const request = await firstValueFrom(this.mobileBuildService.createRequest({ + platform_android: this.buildAndroid, + platform_ios: this.buildIos, + android_application_id: this.androidApplicationId.trim() || undefined, + ios_bundle_id: this.iosBundleId.trim() || undefined, + version_name: this.buildVersionName.trim(), + build_number: this.buildNumber.trim(), + release_profile: this.buildReleaseProfile.trim() || 'release', + })); + + if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) { + await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile)); + await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile)); + } + + if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) { + await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile)); + await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile)); + } + + this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`; + await this.reloadMobileBuilds(); + this.onMobileBuildSelected(request.id); + } catch (err) { + this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.'); + } finally { + this.mobileBuildBusy = false; + } + } + + async runPreflightForSelectedBuild(): Promise { + if (!this.selectedMobileBuildId) return; + + this.resetMobileBuildMessages(); + this.mobileBuildBusy = true; + try { + const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId)); + this.mobileBuildPreflightIssues = result.issues; + this.mobileBuildStatusMessage = result.passed + ? 'Preflight passed. Build can be triggered.' + : 'Preflight failed. Resolve the listed issues.'; + await this.reloadMobileBuilds(); + this.onMobileBuildSelected(this.selectedMobileBuildId); + } catch (err) { + this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.'); + } finally { + this.mobileBuildBusy = false; + } + } + + async triggerSelectedBuild(): Promise { + if (!this.selectedMobileBuildId) return; + + this.resetMobileBuildMessages(); + this.mobileBuildBusy = true; + try { + const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId)); + this.mobileBuildStatusMessage = `Build #${request.id} queued.`; + await this.reloadMobileBuilds(); + this.onMobileBuildSelected(this.selectedMobileBuildId); + } catch (err) { + this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.'); + } finally { + this.mobileBuildBusy = false; + } + } + + async refreshSelectedBuild(): Promise { + if (!this.selectedMobileBuildId) return; + await this.reloadMobileBuilds(); + this.onMobileBuildSelected(this.selectedMobileBuildId); + } + + getArtifactDownloadHref(path: string): string { + return `${getAppConfig().apiBaseUrl}${path}`; + } + + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const size = bytes / Math.pow(1024, i); + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`; + } + + downloadArtifact(artifact: MobileBuildArtifact): void { + this.mobileBuildService + .getDownloadUrl(artifact.id) + .subscribe({ + next: (res) => { + window.open(res.download_url, '_blank'); + }, + error: (err) => { + console.error('Failed to get download URL:', err); + }, + }); + } +} diff --git a/src/app/admin/admin-shows-tab.component.html b/src/app/admin/admin-shows-tab.component.html new file mode 100644 index 0000000..def1ba8 --- /dev/null +++ b/src/app/admin/admin-shows-tab.component.html @@ -0,0 +1,42 @@ +
+
+

Shows ({{ (shows$ | async)?.length }})

+ +
+ + + + + + + + + + + + + + @for (show of shows$ | async; track show.id) { + + + + + + + + + } @empty { + + + + } + +
OrderTitleHostGenreSlotsActions
{{ show.display_order }}{{ show.title }}{{ show.host }}{{ show.genre }} + @for (slot of show.schedules; track slot.id) { + {{ slot.day_label.charAt(0) }} {{ slot.time }} + } + + + +
No shows yet. Click "Add Show" to create one.
+
diff --git a/src/app/admin/admin-shows-tab.component.scss b/src/app/admin/admin-shows-tab.component.scss new file mode 100644 index 0000000..de65d9b --- /dev/null +++ b/src/app/admin/admin-shows-tab.component.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; diff --git a/src/app/admin/admin-shows-tab.component.ts b/src/app/admin/admin-shows-tab.component.ts new file mode 100644 index 0000000..f4f398e --- /dev/null +++ b/src/app/admin/admin-shows-tab.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { Show } from '../interfaces/show'; +import { ShowService } from '../services/show.service'; + +@Component({ + selector: 'app-admin-shows-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-shows-tab.component.html', + styleUrl: './admin-shows-tab.component.scss', +}) +export class AdminShowsTabComponent { + private showService = inject(ShowService); + + readonly openForm = output(); + readonly editItem = output(); + readonly deleteItem = output(); + + readonly shows$ = this.showService.getShows(); + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + await firstValueFrom(this.showService.getShows()); + } + + onAdd(): void { + this.openForm.emit(); + } + + onEdit(show: Show): void { + this.editItem.emit(show); + } + + async onDelete(id: number): Promise { + if (!confirm('Delete this show?')) return; + await firstValueFrom(this.showService.deleteShow(id)); + await this.load(); + } +} diff --git a/src/app/admin/admin-station-tab.component.html b/src/app/admin/admin-station-tab.component.html new file mode 100644 index 0000000..571f627 --- /dev/null +++ b/src/app/admin/admin-station-tab.component.html @@ -0,0 +1,59 @@ +
+
+

Station Configuration

+ +
+ + @if (config(); as config) { +
+
+ Callsign + {{ config.callsign }} +
+
+ Name + {{ config.name_primary }} {{ config.name_secondary }} +
+
+ Tagline + {{ config.tagline }} +
+
+ Frequency + {{ config.frequency }} +
+
+ Founded + {{ config.founded_year }} +
+
+ Nonprofit + {{ config.nonprofit_type }} — EIN {{ config.ein }} +
+
+ Email + {{ config.email }} +
+
+ Phone + {{ config.phone }} +
+
+ Website + {{ config.website }} +
+
+ Donation URL + {{ config.donation_url || '(not set)' }} +
+
+ Play Store URL + {{ config.play_store_url || '(not set)' }} +
+
+ App Store Embed + {{ config.app_store_embed_html ? 'Configured' : '(not set)' }} +
+
+ } +
diff --git a/src/app/admin/admin-station-tab.component.scss b/src/app/admin/admin-station-tab.component.scss new file mode 100644 index 0000000..de65d9b --- /dev/null +++ b/src/app/admin/admin-station-tab.component.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; diff --git a/src/app/admin/admin-station-tab.component.ts b/src/app/admin/admin-station-tab.component.ts new file mode 100644 index 0000000..ff18b35 --- /dev/null +++ b/src/app/admin/admin-station-tab.component.ts @@ -0,0 +1,27 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { StationConfigService } from '../services/station-config.service'; + +@Component({ + selector: 'app-admin-station-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-station-tab.component.html', + styleUrl: './admin-station-tab.component.scss', +}) +export class AdminStationTabComponent { + private stationConfigService = inject(StationConfigService); + + readonly openForm = output(); + + readonly config = this.stationConfigService.config; + + ngOnInit(): void { + this.stationConfigService.load(); + } + + onEdit(): void { + this.openForm.emit(); + } +} diff --git a/src/app/admin/admin-team-tab.component.html b/src/app/admin/admin-team-tab.component.html new file mode 100644 index 0000000..cf77d18 --- /dev/null +++ b/src/app/admin/admin-team-tab.component.html @@ -0,0 +1,36 @@ +
+
+

Team Members ({{ (members$ | async)?.length }})

+ +
+ + + + + + + + + + + + + @for (member of members$ | async; track member.id) { + + + + + + + + } @empty { + + + + } + +
NameTitleOrderActiveActions
{{ member.name }}{{ member.title }}{{ member.display_order }}{{ member.active ? 'Yes' : 'No' }} + + +
No team members yet. Click "Add Member" to create one.
+
diff --git a/src/app/admin/admin-team-tab.component.scss b/src/app/admin/admin-team-tab.component.scss new file mode 100644 index 0000000..de65d9b --- /dev/null +++ b/src/app/admin/admin-team-tab.component.scss @@ -0,0 +1,2 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; diff --git a/src/app/admin/admin-team-tab.component.ts b/src/app/admin/admin-team-tab.component.ts new file mode 100644 index 0000000..cbc41b7 --- /dev/null +++ b/src/app/admin/admin-team-tab.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { TeamMember } from '../interfaces/team-member'; +import { TeamMemberService } from '../services/team-member.service'; + +@Component({ + selector: 'app-admin-team-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-team-tab.component.html', + styleUrl: './admin-team-tab.component.scss', +}) +export class AdminTeamTabComponent { + private teamMemberService = inject(TeamMemberService); + + readonly openForm = output(); + readonly editItem = output(); + readonly deleteItem = output(); + + readonly members$ = this.teamMemberService.getTeamMembers(); + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + await firstValueFrom(this.teamMemberService.getTeamMembers()); + } + + onAdd(): void { + this.openForm.emit(); + } + + onEdit(member: TeamMember): void { + this.editItem.emit(member); + } + + async onDelete(id: number): Promise { + if (!confirm('Delete this team member?')) return; + await firstValueFrom(this.teamMemberService.deleteTeamMember(id)); + await this.load(); + } +} diff --git a/src/app/admin/admin-theme-tab.component.html b/src/app/admin/admin-theme-tab.component.html new file mode 100644 index 0000000..72a4b07 --- /dev/null +++ b/src/app/admin/admin-theme-tab.component.html @@ -0,0 +1,53 @@ +
+
+

Color Theme

+
+ +
+
+ Harmony: + {{ getHarmonyLabel() }} +
+ +
+ + @if (themeSaveSuccess) { +
{{ themeSaveSuccess }}
+ } + @if (themeSaveError) { +
{{ themeSaveError }}
+ } + +
+ @for (role of colorRoles; track role.field) { +
+
+ +
+
+ {{ role.label }} + {{ themeColors[role.field] }} +
+
+ @if (hasHarmonyIssue(role.field)) { + + } @else if (role.group !== 'neutral') { + + } +
+
+ } +
+ +
+ + + +
+
diff --git a/src/app/admin/admin-theme-tab.component.scss b/src/app/admin/admin-theme-tab.component.scss new file mode 100644 index 0000000..fe63895 --- /dev/null +++ b/src/app/admin/admin-theme-tab.component.scss @@ -0,0 +1,192 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.theme-panel { + .theme-harmony-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-md; + background: rgba($primary-blue, 0.05); + border-radius: $radius-md; + margin-bottom: $spacing-lg; + flex-wrap: wrap; + gap: $spacing-sm; + + .harmony-info { + display: flex; + align-items: center; + gap: $spacing-sm; + + .harmony-label { + font-weight: 600; + color: $neutral-medium; + font-size: 0.9rem; + } + + .harmony-rule { + color: $primary-blue; + font-weight: 600; + font-size: 0.95rem; + } + } + + .auto-variants-toggle { + display: flex; + align-items: center; + gap: $spacing-sm; + font-size: 0.875rem; + color: $neutral-dark; + cursor: pointer; + + input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + } + } + } + + .theme-message { + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + font-size: 0.875rem; + margin-bottom: $spacing-md; + text-align: center; + + &.theme-success { + background: rgba($success-green, 0.1); + color: $success-green; + } + + &.theme-error { + background: rgba($danger-red, 0.1); + color: $danger-red; + } + } +} + +.color-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: $spacing-md; + margin-bottom: $spacing-xl; +} + +.color-card { + display: flex; + align-items: center; + gap: $spacing-sm; + padding: $spacing-sm $spacing-md; + background: $neutral-white; + border: 2px solid $neutral-light; + border-radius: $radius-md; + transition: border-color $transition-fast, box-shadow $transition-fast; + + &.has-issue { + border-color: #e6a817; + box-shadow: 0 0 0 1px rgba(230, 168, 23, 0.3); + } + + &:hover { + border-color: $primary-blue-muted; + } +} + +.color-swatch { + position: relative; + width: 40px; + height: 40px; + border-radius: $radius-sm; + flex-shrink: 0; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + transition: transform $transition-fast; + + &:hover { + transform: scale(1.1); + } + + input[type="color"] { + position: absolute; + inset: 0; + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; + border: none; + padding: 0; + background: none; + } +} + +.color-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + .color-name { + font-size: 0.85rem; + font-weight: 600; + color: $neutral-dark; + white-space: nowrap; + } + + .color-hex { + font-size: 0.75rem; + color: $neutral-medium; + font-family: var(--font-mono, monospace); + } +} + +.color-status { + font-size: 1.1rem; + flex-shrink: 0; + width: 24px; + text-align: center; + + .harmony-ok { + color: $success-green; + } + + .harmony-warn { + color: #e6a817; + } +} + +.theme-actions { + display: flex; + justify-content: flex-end; + gap: $spacing-sm; + flex-wrap: wrap; + padding-top: $spacing-md; + border-top: 1px solid $neutral-light; +} + +.btn-save-theme { + @include button-style($primary-blue, $neutral-white, $primary-blue-light); + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.btn-outline-theme { + background: transparent; + border: 2px solid $primary-blue; + color: $primary-blue; + border-radius: $radius-md; + padding: $spacing-sm $spacing-lg; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background $transition-fast, color $transition-fast; + + &:hover { + background: $primary-blue; + color: $neutral-white; + } +} diff --git a/src/app/admin/admin-theme-tab.component.ts b/src/app/admin/admin-theme-tab.component.ts new file mode 100644 index 0000000..6ea975b --- /dev/null +++ b/src/app/admin/admin-theme-tab.component.ts @@ -0,0 +1,178 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { StationConfigService } from '../services/station-config.service'; +import { ThemeService } from '../services/theme.service'; +import { + COLOR_ROLES, + HARMONY_RULE_LABELS, + detectHarmonyRule, + validatePalette, + autoFixPalette, + generateLight, + generateDark, + generateMuted, + HarmonyRule, + ColorValidation, +} from '../utils/color-harmony'; + +interface ThemeColorState { + color_primary: string; + color_primary_light: string; + color_primary_dark: string; + color_primary_muted: string; + color_accent: string; + color_accent_light: string; + color_accent_dark: string; + color_accent_muted: string; + color_background: string; + color_text: string; + color_success: string; + color_danger: string; + color_info: string; + color_white: string; + color_light: string; + color_medium: string; + color_black: string; + [key: string]: string; +} + +@Component({ + selector: 'app-admin-theme-tab', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-theme-tab.component.html', + styleUrl: './admin-theme-tab.component.scss', +}) +export class AdminThemeTabComponent { + private stationConfigService = inject(StationConfigService); + private themeService = inject(ThemeService); + + readonly colorRoles = COLOR_ROLES; + + themeColors: ThemeColorState = {} as ThemeColorState; + themeHarmonyRule: HarmonyRule = 'none'; + themeValidation: { [key: string]: ColorValidation } = {}; + themeAutoVariants = true; + themeSaving = false; + themeSaveSuccess = ''; + themeSaveError = ''; + + readonly themeDefaults: ThemeColorState = { + color_primary: '#1a3a5c', + color_primary_light: '#2a5a8c', + color_primary_dark: '#0e2440', + color_primary_muted: '#3a6a9c', + color_accent: '#e87a2e', + color_accent_light: '#f09a4e', + color_accent_dark: '#c85a1e', + color_accent_muted: '#f0a86a', + color_background: '#faf6f0', + color_text: '#3a3632', + color_success: '#4a9e4f', + color_danger: '#c83030', + color_info: '#3a8abf', + color_white: '#ffffff', + color_light: '#f0ebe3', + color_medium: '#8a8580', + color_black: '#1a1816', + }; + + ngOnInit(): void { + this.loadThemeColors(); + } + + loadThemeColors(): void { + const config = this.stationConfigService.config(); + this.themeColors = { + color_primary: config.color_primary || this.themeDefaults.color_primary, + color_primary_light: config.color_primary_light || this.themeDefaults.color_primary_light, + color_primary_dark: config.color_primary_dark || this.themeDefaults.color_primary_dark, + color_primary_muted: config.color_primary_muted || this.themeDefaults.color_primary_muted, + color_accent: config.color_accent || this.themeDefaults.color_accent, + color_accent_light: config.color_accent_light || this.themeDefaults.color_accent_light, + color_accent_dark: config.color_accent_dark || this.themeDefaults.color_accent_dark, + color_accent_muted: config.color_accent_muted || this.themeDefaults.color_accent_muted, + color_background: config.color_background || this.themeDefaults.color_background, + color_text: config.color_text || this.themeDefaults.color_text, + color_success: config.color_success || this.themeDefaults.color_success, + color_danger: config.color_danger || this.themeDefaults.color_danger, + color_info: config.color_info || this.themeDefaults.color_info, + color_white: config.color_white || this.themeDefaults.color_white, + color_light: config.color_light || this.themeDefaults.color_light, + color_medium: config.color_medium || this.themeDefaults.color_medium, + color_black: config.color_black || this.themeDefaults.color_black, + }; + this.themeSaveSuccess = ''; + this.themeSaveError = ''; + this.revalidateTheme(); + } + + revalidateTheme(): void { + const primary = this.themeColors.color_primary; + const accent = this.themeColors.color_accent; + this.themeHarmonyRule = detectHarmonyRule(primary, accent); + this.themeValidation = validatePalette(this.themeColors, this.themeHarmonyRule); + } + + onThemeColorChange(field: string, value: string): void { + this.themeColors[field] = value; + + if (this.themeAutoVariants) { + if (field === 'color_primary') { + this.themeColors.color_primary_light = generateLight(value); + this.themeColors.color_primary_dark = generateDark(value); + this.themeColors.color_primary_muted = generateMuted(value); + } else if (field === 'color_accent') { + this.themeColors.color_accent_light = generateLight(value); + this.themeColors.color_accent_dark = generateDark(value); + this.themeColors.color_accent_muted = generateMuted(value); + } + } + + this.themeService.apply(this.themeColors as any); + this.revalidateTheme(); + } + + autoFixTheme(): void { + const fixed = autoFixPalette(this.themeColors, this.themeHarmonyRule); + for (const key of Object.keys(fixed) as (keyof ThemeColorState)[]) { + (this.themeColors as any)[key] = fixed[key]; + } + this.themeService.apply(this.themeColors as any); + this.revalidateTheme(); + } + + resetTheme(): void { + for (const key of Object.keys(this.themeDefaults) as (keyof ThemeColorState)[]) { + (this.themeColors as any)[key] = this.themeDefaults[key]; + } + this.themeService.apply(this.themeColors as any); + this.revalidateTheme(); + } + + async saveTheme(): Promise { + this.themeSaving = true; + this.themeSaveError = ''; + this.themeSaveSuccess = ''; + + try { + await this.stationConfigService.updateConfig(this.themeColors); + this.themeSaveSuccess = 'Theme saved successfully!'; + } catch (err: any) { + this.themeSaveError = err?.error?.detail || 'Failed to save theme.'; + } finally { + this.themeSaving = false; + } + } + + getHarmonyLabel(): string { + return HARMONY_RULE_LABELS[this.themeHarmonyRule]; + } + + hasHarmonyIssue(field: string): boolean { + const v = this.themeValidation[field]; + return v ? !v.isHarmonious : false; + } +} diff --git a/src/app/admin/admin-underwriters-tab.component.html b/src/app/admin/admin-underwriters-tab.component.html new file mode 100644 index 0000000..0bf5157 --- /dev/null +++ b/src/app/admin/admin-underwriters-tab.component.html @@ -0,0 +1,44 @@ +
+
+

Underwriters ({{ (underwriters$ | async)?.length }})

+ +
+ + + + + + + + + + + + + + @for (uw of underwriters$ | async; track uw.id) { + + + + + + + + + } @empty { + + + + } + +
NameDescriptionWebsiteOrderActiveActions
{{ uw.name }}{{ uw.description }} + @if (uw.website_url) { + {{ uw.website_url }} + } @else { + — + } + {{ uw.display_order }}{{ uw.active ? 'Yes' : 'No' }} + + +
No underwriters yet. Click "Add Underwriter" to create one.
+
diff --git a/src/app/admin/admin-underwriters-tab.component.scss b/src/app/admin/admin-underwriters-tab.component.scss new file mode 100644 index 0000000..33f34c5 --- /dev/null +++ b/src/app/admin/admin-underwriters-tab.component.scss @@ -0,0 +1,9 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.description-cell { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/app/admin/admin-underwriters-tab.component.ts b/src/app/admin/admin-underwriters-tab.component.ts new file mode 100644 index 0000000..9b3042e --- /dev/null +++ b/src/app/admin/admin-underwriters-tab.component.ts @@ -0,0 +1,45 @@ +import { Component, inject, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { firstValueFrom } from 'rxjs'; + +import { Underwriter } from '../interfaces/underwriter'; +import { UnderwriterService } from '../services/underwriter.service'; + +@Component({ + selector: 'app-admin-underwriters-tab', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-underwriters-tab.component.html', + styleUrl: './admin-underwriters-tab.component.scss', +}) +export class AdminUnderwritersTabComponent { + private underwriterService = inject(UnderwriterService); + + readonly openForm = output(); + readonly editItem = output(); + readonly deleteItem = output(); + + readonly underwriters$ = this.underwriterService.getUnderwriters(); + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + await firstValueFrom(this.underwriterService.getUnderwriters()); + } + + onAdd(): void { + this.openForm.emit(); + } + + onEdit(underwriter: Underwriter): void { + this.editItem.emit(underwriter); + } + + async onDelete(id: number): Promise { + if (!confirm('Delete this underwriter?')) return; + await firstValueFrom(this.underwriterService.deleteUnderwriter(id)); + await this.load(); + } +} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index d5bbd0d..5cb5c92 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -8,634 +8,66 @@
- - + + - - - - - + + + + +
- + @if (activeTab() === 'shows') { - @if (shows$ | async; as shows) { -
-
-

Shows ({{ shows.length }})

- -
- - - - - - - - - - - - - - @for (show of shows; track show.id) { - - - - - - - - - } @empty { - - - - } - -
OrderTitleHostGenreSlotsActions
{{ show.display_order }}{{ show.title }}{{ show.host }}{{ show.genre }} - @for (slot of show.schedules; track slot.id) { - {{ slot.day_label.charAt(0) }} {{ slot.time }} - } - - - -
No shows yet. Click "Add Show" to create one.
-
- } + } - @if (activeTab() === 'events') { - @if (events$ | async; as events) { -
-
-

Events ({{ events.length }})

- -
- - - - - - - - - - - - - @for (event of events; track event.id) { - - - - - - - - } @empty { - - - - } - -
DateTitleLocationActiveActions
{{ event.date }}{{ event.title }}{{ event.location }}{{ event.active ? 'Yes' : 'No' }} - - -
No events yet. Click "Add Event" to create one.
-
- } + } - @if (activeTab() === 'history') { - @if (historyEntries$ | async; as entries) { -
-
-

History Timeline ({{ entries.length }})

- -
- - - - - - - - - - - - - @for (entry of entries; track entry.id) { - - - - - - - - } @empty { - - - - } - -
YearTitleOrderActiveActions
{{ entry.year }}{{ entry.title }}{{ entry.display_order }}{{ entry.active ? 'Yes' : 'No' }} - - -
No history entries yet. Click "Add Entry" to create one.
-
- } + } - @if (activeTab() === 'team') { - @if (teamMembers$ | async; as members) { -
-
-

Team Members ({{ members.length }})

- -
- - - - - - - - - - - - - @for (member of members; track member.id) { - - - - - - - - } @empty { - - - - } - -
NameTitleOrderActiveActions
{{ member.name }}{{ member.title }}{{ member.display_order }}{{ member.active ? 'Yes' : 'No' }} - - -
No team members yet. Click "Add Member" to create one.
-
- } + } - @if (activeTab() === 'community') { - @if (communityHighlights$ | async; as highlights) { -
-
-

Community Highlights ({{ highlights.length }})

- -
- - - - - - - - - - - - - @for (highlight of highlights; track highlight.id) { - - - - - - - - } @empty { - - - - } - -
IconTitleOrderActiveActions
{{ highlight.icon }}{{ highlight.title }}{{ highlight.display_order }}{{ highlight.active ? 'Yes' : 'No' }} - - -
No highlights yet. Click "Add Highlight" to create one.
-
- } + } - @if (activeTab() === 'station') { - @if (stationConfig$ | async; as config) { -
-
-

Station Configuration

- -
- -
-
- Callsign - {{ config.callsign }} -
-
- Name - {{ config.name_primary }} {{ config.name_secondary }} -
-
- Tagline - {{ config.tagline }} -
-
- Frequency - {{ config.frequency }} -
-
- Founded - {{ config.founded_year }} -
-
- Nonprofit - {{ config.nonprofit_type }} — EIN {{ config.ein }} -
-
- Email - {{ config.email }} -
-
- Phone - {{ config.phone }} -
-
- Website - {{ config.website }} -
-
- Donation URL - {{ config.donation_url || '(not set)' }} -
-
- Play Store URL - {{ config.play_store_url || '(not set)' }} -
-
- App Store Embed - {{ config.app_store_embed_html ? 'Configured' : '(not set)' }} -
-
-
- } + } - @if (activeTab() === 'theme') { -
-
-

Color Theme

-
- -
-
- Harmony: - {{ getHarmonyLabel() }} -
- -
- - @if (themeSaveSuccess) { -
{{ themeSaveSuccess }}
- } - @if (themeSaveError) { -
{{ themeSaveError }}
- } - - -
- @for (role of colorRoles; track role.field) { -
-
- -
-
- {{ role.label }} - {{ themeColors[role.field] }} -
-
- @if (hasHarmonyIssue(role.field)) { - - } @else if (role.group !== 'neutral') { - - } -
-
- } -
- -
- - - -
-
+ } - @if (activeTab() === 'underwriters') { - @if (underwriters$ | async; as underwriters) { -
-
-

Underwriters ({{ underwriters.length }})

- -
- - - - - - - - - - - - - - @for (uw of underwriters; track uw.id) { - - - - - - - - - } @empty { - - - - } - -
NameDescriptionWebsiteOrderActiveActions
{{ uw.name }}{{ uw.description }} - @if (uw.website_url) { - {{ uw.website_url }} - } @else { - — - } - {{ uw.display_order }}{{ uw.active ? 'Yes' : 'No' }} - - -
No underwriters yet. Click "Add Underwriter" to create one.
-
- } + } - @if (activeTab() === 'mobile-builds') { -
-
-

Mobile Build Orchestration

- -
- -
-
-

Derived Defaults

- @if (mobileBuildDefaults; as defaults) { -
-
Tenant: {{ defaults.tenant_key }}
-
App Name: {{ defaults.app_name }}
-
Support Email: {{ defaults.support_email }}
-
Website: {{ defaults.website }}
-
Stream URL: {{ defaults.stream_url || '(not set)' }}
-
- } @else { -

Defaults unavailable. Check station config.

- } -
- -
-

Theme Export

- @if (themeJsonStatus$ | async; as status) { - @if (status.exists) { -
-
theme.json — ready
-
Path: {{ status.path }}
-
Generated: {{ status.generated_at }}
-
Size: {{ status.size }} bytes
-
- } @else { -
-

theme.json not found at {{ status.path }}

-

Regenerate to create it from the current station config.

-
- } - - } @else { - @if (themeJsonLoading$ | async) { -

Loading...

- } - } -
- -
-

Create Build Request

- -
- - -
- -
- - -
- -
- - - -
- -

Required Files

-
- - -
- -
- - -
- - @if (mobileBuildStatusMessage) { -

{{ mobileBuildStatusMessage }}

- } - @if (mobileBuildErrorMessage) { -

{{ mobileBuildErrorMessage }}

- } - - -
-
- -
-

Build Requests

- @if (mobileBuilds$ | async; as builds) { - - - - - - - - - - - - - @for (build of builds; track build.id) { - - - - - - - - - } @empty { - - - - } - -
IDPlatformsVersionStatusPreflightActions
#{{ build.id }} - {{ build.platform_android ? 'Android' : '' }} - {{ build.platform_android && build.platform_ios ? ' + ' : '' }} - {{ build.platform_ios ? 'iOS' : '' }} - {{ build.version_name }} ({{ build.build_number }}){{ build.status }}{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }} - -
No build requests yet.
- } -
- - @if (selectedMobileBuild; as build) { -
-
-

Selected Request #{{ build.id }}

-
- - - -
-
- - @if (mobileBuildPreflightIssues.length > 0) { -
- Preflight issues: -
    - @for (issue of mobileBuildPreflightIssues; track issue) { -
  • {{ issue }}
  • - } -
-
- } - - @if (build.error_message) { -

Error: {{ build.error_message }}

- } - -

Artifacts

-
- @for (artifact of build.artifacts; track artifact.id) { -
-
- {{ artifact.file_name }} -
{{ artifact.platform }} • {{ artifact.artifact_type }} • {{ - artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}
-
- -
- } @empty { -

No artifacts available yet.

- } -
- -

Build Log

-
{{ build.build_log || 'No logs yet.' }}
-
- } -
+ } - @if (activeTab() === 'stats') {
@@ -651,7 +83,7 @@ } @if (showStationForm) { - + } @if (showHistoryForm) { @@ -663,7 +95,6 @@ } @if (showUnderwriterForm) { - + } - \ No newline at end of file + diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index a7463a1..023c3d8 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -5,608 +5,201 @@ min-height: calc(100vh - 80px); background: $neutral-cream; padding: $spacing-xl 0; -} -.admin-header { - text-align: center; - margin-bottom: $spacing-xl; + // ── Header ───────────────────────────────────────────── - h1 { - font-family: $font-heading; - color: $primary-blue; - font-size: 2rem; - margin: 0 0 $spacing-xs; - } - - p { - color: $neutral-medium; - margin: 0; - } -} - -// ── Tabs ───────────────────────────────────────────────── - -.admin-tabs { - display: flex; - justify-content: center; - gap: $spacing-sm; - margin-bottom: $spacing-xl; -} - -.tab-btn { - background: $neutral-white; - border: 1px solid $neutral-light; - border-radius: $radius-md; - padding: $spacing-sm $spacing-xl; - font-size: 0.95rem; - font-weight: 600; - color: $neutral-dark; - cursor: pointer; - transition: all $transition-fast; - - &:hover { - border-color: $primary-blue-muted; - color: $primary-blue; - } - - &.active { - background: $primary-blue; - border-color: $primary-blue; - color: $neutral-white; - } -} - -// ── Tab content ────────────────────────────────────────── - -.tab-content { - @include card-style; - padding: $spacing-lg; -} - -.tab-toolbar { - @include flex-between; - margin-bottom: $spacing-lg; - - h2 { - font-family: $font-heading; - color: $primary-blue; - font-size: 1.3rem; - margin: 0; - } -} - -.btn-add { - @include button-style($success-green, $neutral-white, darken($success-green, 8%)); -} - -// ── Table ──────────────────────────────────────────────── - -.admin-table { - width: 100%; - border-collapse: collapse; - - thead th { - text-align: left; - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; - color: $neutral-medium; - padding: $spacing-sm $spacing-md; - border-bottom: 2px solid $neutral-light; - } - - tbody td { - padding: $spacing-sm $spacing-md; - border-bottom: 1px solid $neutral-light; - font-size: 0.95rem; - color: $neutral-dark; - vertical-align: middle; - } - - .benefits-cell { - max-width: 300px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .slot-badge { - display: inline-block; - background: rgba($primary-blue, 0.1); - color: $primary-blue; - font-size: 0.75rem; - font-family: var(--font-mono, monospace); - padding: 2px 8px; - border-radius: 10px; - margin: 2px; - white-space: nowrap; - } - - .empty-row { + .admin-header { text-align: center; - color: $neutral-medium; - padding: $spacing-xl !important; - } + margin-bottom: $spacing-xl; - .actions { - display: flex; - gap: $spacing-xs; - white-space: nowrap; - } -} + h1 { + font-family: $font-heading; + color: $primary-blue; + font-size: 2rem; + margin: 0 0 $spacing-xs; + } -.btn-icon { - border: none; - padding: $spacing-xs $spacing-sm; - border-radius: $radius-sm; - font-size: 0.85rem; - cursor: pointer; - transition: background $transition-fast; -} - -.btn-edit { - background: rgba($info-blue, 0.1); - color: $info-blue; - - &:hover { - background: rgba($info-blue, 0.2); - } -} - -.btn-delete { - background: rgba($danger-red, 0.1); - color: $danger-red; - - &:hover { - background: rgba($danger-red, 0.2); - } -} - -.loading-message { - text-align: center; - padding: $spacing-3xl; - color: $neutral-medium; - font-size: 1.1rem; -} - -// ── Station config view ────────────────────────────────── - -.station-config-view { - .config-row { - display: flex; - padding: $spacing-sm 0; - border-bottom: 1px solid $neutral-light; - - &:last-child { - border-bottom: none; + p { + color: $neutral-medium; + margin: 0; } } - .config-label { - font-weight: 600; - color: $neutral-medium; - width: 140px; - flex-shrink: 0; - font-size: 0.9rem; + // ── Tabs ─────────────────────────────────────────────── + + .admin-tabs { + display: flex; + justify-content: center; + gap: $spacing-sm; + margin-bottom: $spacing-xl; + flex-wrap: wrap; } - .config-value { - color: $neutral-dark; + .tab-btn { + background: $neutral-white; + border: 1px solid $neutral-light; + border-radius: $radius-md; + padding: $spacing-sm $spacing-xl; font-size: 0.95rem; - } -} + font-weight: 600; + color: $neutral-dark; + cursor: pointer; + transition: all $transition-fast; -// ── Responsive ─────────────────────────────────────────── - -@include responsive(md) { - .admin-table { - font-size: 0.85rem; - - thead th, - tbody td { - padding: $spacing-xs $spacing-sm; + &:hover { + border-color: $primary-blue-muted; + color: $primary-blue; } + + &.active { + background: $primary-blue; + border-color: $primary-blue; + color: $neutral-white; + } + } + + // ── Tab content ──────────────────────────────────────── + + .tab-content { + @include card-style; + padding: $spacing-lg; } .tab-toolbar { - flex-direction: column; - align-items: flex-start; - gap: $spacing-sm; - } -} - -// ── Theme tab ──────────────────────────────────────────── - -.theme-panel { - .theme-harmony-bar { - display: flex; - justify-content: space-between; - align-items: center; - padding: $spacing-md; - background: rgba($primary-blue, 0.05); - border-radius: $radius-md; + @include flex-between; margin-bottom: $spacing-lg; - flex-wrap: wrap; - gap: $spacing-sm; - .harmony-info { - display: flex; - align-items: center; - gap: $spacing-sm; - - .harmony-label { - font-weight: 600; - color: $neutral-medium; - font-size: 0.9rem; - } - - .harmony-rule { - color: $primary-blue; - font-weight: 600; - font-size: 0.95rem; - } - } - - .auto-variants-toggle { - display: flex; - align-items: center; - gap: $spacing-sm; - font-size: 0.875rem; - color: $neutral-dark; - cursor: pointer; - - input[type="checkbox"] { - width: 16px; - height: 16px; - cursor: pointer; - } + h2 { + font-family: $font-heading; + color: $primary-blue; + font-size: 1.3rem; + margin: 0; } } - .theme-message { - padding: $spacing-sm $spacing-md; - border-radius: $radius-md; - font-size: 0.875rem; - margin-bottom: $spacing-md; - text-align: center; - - &.theme-success { - background: rgba($success-green, 0.1); - color: $success-green; - } - - &.theme-error { - background: rgba($danger-red, 0.1); - color: $danger-red; - } - } -} - -// Color grid -.color-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: $spacing-md; - margin-bottom: $spacing-xl; -} - -.color-card { - display: flex; - align-items: center; - gap: $spacing-sm; - padding: $spacing-sm $spacing-md; - background: $neutral-white; - border: 2px solid $neutral-light; - border-radius: $radius-md; - transition: border-color $transition-fast, box-shadow $transition-fast; - - &.has-issue { - border-color: #e6a817; - box-shadow: 0 0 0 1px rgba(230, 168, 23, 0.3); + .btn-add { + @include button-style($success-green, $neutral-white, darken($success-green, 8%)); } - &:hover { - border-color: $primary-blue-muted; - } -} + // ── Table ────────────────────────────────────────────── -.color-swatch { - position: relative; - width: 40px; - height: 40px; - border-radius: $radius-sm; - flex-shrink: 0; - cursor: pointer; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); - transition: transform $transition-fast; - - &:hover { - transform: scale(1.1); - } - - input[type="color"] { - position: absolute; - inset: 0; - opacity: 0; + .admin-table { width: 100%; - height: 100%; - cursor: pointer; + border-collapse: collapse; + + thead th { + text-align: left; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + color: $neutral-medium; + padding: $spacing-sm $spacing-md; + border-bottom: 2px solid $neutral-light; + } + + tbody td { + padding: $spacing-sm $spacing-md; + border-bottom: 1px solid $neutral-light; + font-size: 0.95rem; + color: $neutral-dark; + vertical-align: middle; + } + + .slot-badge { + display: inline-block; + background: rgba($primary-blue, 0.1); + color: $primary-blue; + font-size: 0.75rem; + font-family: var(--font-mono, monospace); + padding: 2px 8px; + border-radius: 10px; + margin: 2px; + white-space: nowrap; + } + + .empty-row { + text-align: center; + color: $neutral-medium; + padding: $spacing-xl !important; + } + + .actions { + display: flex; + gap: $spacing-xs; + white-space: nowrap; + } + } + + .btn-icon { border: none; - padding: 0; - background: none; - } -} - -.color-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - - .color-name { - font-size: 0.85rem; - font-weight: 600; - color: $neutral-dark; - white-space: nowrap; - } - - .color-hex { - font-size: 0.75rem; - color: $neutral-medium; - font-family: var(--font-mono, monospace); - } -} - -.color-status { - font-size: 1.1rem; - flex-shrink: 0; - width: 24px; - text-align: center; - - .harmony-ok { - color: $success-green; - } - - .harmony-warn { - color: #e6a817; - } -} - -// Theme actions -.theme-actions { - display: flex; - justify-content: flex-end; - gap: $spacing-sm; - flex-wrap: wrap; - padding-top: $spacing-md; - border-top: 1px solid $neutral-light; -} - -.btn-save-theme { - @include button-style($primary-blue, $neutral-white, $primary-blue-light); - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } -} - -.btn-outline-theme { - background: transparent; - border: 2px solid $primary-blue; - color: $primary-blue; - border-radius: $radius-md; - padding: $spacing-sm $spacing-lg; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - transition: background $transition-fast, color $transition-fast; - - &:hover { - background: $primary-blue; - color: $neutral-white; - } -} - -// ── Mobile builds tab ─────────────────────────────────── - -.mobile-builds-panel { - display: grid; - gap: $spacing-lg; -} - -.mobile-build-grid { - display: grid; - grid-template-columns: 1fr 2fr; - gap: $spacing-md; -} - -.build-card { - background: $neutral-white; - border: 1px solid $neutral-light; - border-radius: $radius-md; - padding: $spacing-md; - overflow: hidden; - - h3, - h4 { - color: $primary-blue; - margin-top: 0; - } -} - -.derived-list { - display: grid; - gap: $spacing-xs; - font-size: 0.92rem; -} - -.form-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: $spacing-sm; - margin-bottom: $spacing-sm; - - label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 0.85rem; - color: $neutral-dark; - font-weight: 600; - } - - input[type='text'], - input[type='file'] { - border: 1px solid $neutral-light; - border-radius: $radius-sm; padding: $spacing-xs $spacing-sm; - font-size: 0.88rem; - } -} - -.platform-row { - grid-template-columns: auto auto; - - label { - flex-direction: row; - align-items: center; - font-weight: 600; - } -} - -.status { - border-radius: $radius-sm; - padding: $spacing-sm; - margin: $spacing-sm 0; - font-size: 0.9rem; - - &.success { - background: rgba($success-green, 0.1); - color: $success-green; + border-radius: $radius-sm; + font-size: 0.85rem; + cursor: pointer; + transition: background $transition-fast; } - &.error { + .btn-edit { + background: rgba($info-blue, 0.1); + color: $info-blue; + + &:hover { + background: rgba($info-blue, 0.2); + } + } + + .btn-delete { background: rgba($danger-red, 0.1); color: $danger-red; + + &:hover { + background: rgba($danger-red, 0.2); + } } - ul { - margin: $spacing-xs 0 0 $spacing-md; - } -} + // ── Station config view ──────────────────────────────── -.selected-build { - background: rgba($primary-blue, 0.06); -} + .station-config-view { + .config-row { + display: flex; + padding: $spacing-sm 0; + border-bottom: 1px solid $neutral-light; -.tab-toolbar.compact { - margin-bottom: $spacing-sm; -} + &:last-child { + border-bottom: none; + } + } -.action-group { - display: flex; - gap: $spacing-xs; -} + .config-label { + font-weight: 600; + color: $neutral-medium; + width: 140px; + flex-shrink: 0; + font-size: 0.9rem; + } -.artifact-list { - display: grid; - gap: $spacing-sm; - margin-bottom: $spacing-md; -} - -.artifact-row { - display: flex; - justify-content: space-between; - align-items: center; - border: 1px solid $neutral-light; - border-radius: $radius-sm; - padding: $spacing-sm; -} - -.artifact-meta { - font-size: 0.78rem; - color: $neutral-medium; -} - -.build-log { - background: $neutral-black; - color: $neutral-white; - border-radius: $radius-sm; - padding: $spacing-sm; - max-height: 280px; - overflow-x: auto; - overflow-y: auto; - font-size: 0.8rem; - width: 100%; - - pre { - margin: 0; - white-space: pre; - } -} - -// ── Theme export status ────────────────────────────────── - -.theme-status-ok, -.theme-status-missing { - padding: $spacing-sm $spacing-md; - border-radius: $radius-sm; - margin-bottom: $spacing-sm; - font-size: 0.88rem; - color: $neutral-dark; -} - -.theme-status-ok { - background: rgba($success-green, 0.08); - border: 1px solid rgba($success-green, 0.25); - display: grid; - gap: 2px; -} - -.theme-status-missing p { - margin: 0 0 $spacing-xs; - - &:last-child { - margin-bottom: 0; - } -} - -.theme-status-missing { - background: rgba($accent-orange, 0.08); - border: 1px solid rgba($accent-orange, 0.25); -} - -.btn-outline { - background: transparent; - border: 2px solid $primary-blue; - color: $primary-blue; - border-radius: $radius-md; - padding: $spacing-xs $spacing-md; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: background $transition-fast, color $transition-fast; - margin-top: $spacing-sm; - - &:hover:not(:disabled) { - background: $primary-blue; - color: $neutral-white; + .config-value { + color: $neutral-dark; + font-size: 0.95rem; + } } - &:disabled { - opacity: 0.6; - cursor: not-allowed; + // ── Responsive ───────────────────────────────────────── + + @include responsive(md) { + .admin-table { + font-size: 0.85rem; + + thead th, + tbody td { + padding: $spacing-xs $spacing-sm; + } + } + + .tab-toolbar { + flex-direction: column; + align-items: flex-start; + gap: $spacing-sm; + } } } - -@include responsive(md) { - .mobile-build-grid { - grid-template-columns: 1fr; - } - - .action-group { - flex-wrap: wrap; - } -} \ No newline at end of file diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 5e76f79..4da8e95 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -1,30 +1,23 @@ -import { Component, inject, signal, OnInit } from '@angular/core'; -import { CommonModule, AsyncPipe } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { Component, inject, OnInit, signal, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { Show } from '../interfaces/show'; import { Event as StationEvent } from '../interfaces/event'; -import { StationConfig } from '../interfaces/station-config'; import { HistoryEntry } from '../interfaces/history-entry'; import { TeamMember } from '../interfaces/team-member'; import { CommunityHighlight } from '../interfaces/community-highlight'; import { Underwriter } from '../interfaces/underwriter'; -import { - MobileBuildArtifact, - MobileBuildDefaults, - MobileBuildRequest, - ThemeStatus, -} from '../interfaces/mobile-build'; -import { ShowService } from '../services/show.service'; -import { EventService } from '../services/event.service'; +import { StationConfig } from '../interfaces/station-config'; import { StationConfigService } from '../services/station-config.service'; -import { ThemeService } from '../services/theme.service'; -import { HistoryEntryService } from '../services/history-entry.service'; -import { TeamMemberService } from '../services/team-member.service'; -import { CommunityHighlightService } from '../services/community-highlight.service'; -import { UnderwriterService } from '../services/underwriter.service'; -import { MobileBuildService } from '../services/mobile-build.service'; +import { AdminShowsTabComponent } from './admin-shows-tab.component'; +import { AdminEventsTabComponent } from './admin-events-tab.component'; +import { AdminHistoryTabComponent } from './admin-history-tab.component'; +import { AdminTeamTabComponent } from './admin-team-tab.component'; +import { AdminCommunityTabComponent } from './admin-community-tab.component'; +import { AdminStationTabComponent } from './admin-station-tab.component'; +import { AdminThemeTabComponent } from './admin-theme-tab.component'; +import { AdminUnderwritersTabComponent } from './admin-underwriters-tab.component'; +import { AdminMobileBuildsTabComponent } from './admin-mobile-builds-tab.component'; import { AdminShowFormComponent } from './admin-show-form.component'; import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminStationFormComponent } from './admin-station-form.component'; @@ -33,50 +26,35 @@ import { AdminTeamFormComponent } from './admin-team-form.component'; import { AdminCommunityFormComponent } from './admin-community-form.component'; import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component'; import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component'; -import { - COLOR_ROLES, - HARMONY_RULE_LABELS, - detectHarmonyRule, - validatePalette, - autoFixPalette, - generateLight, - generateDark, - generateMuted, - HarmonyRule, - ColorValidation, -} from '../utils/color-harmony'; -import { getAppConfig } from '../services/app-config.service'; -type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'mobile-builds' | 'stats'; - -interface ThemeColorState { - color_primary: string; - color_primary_light: string; - color_primary_dark: string; - color_primary_muted: string; - color_accent: string; - color_accent_light: string; - color_accent_dark: string; - color_accent_muted: string; - color_background: string; - color_text: string; - color_success: string; - color_danger: string; - color_info: string; - color_white: string; - color_light: string; - color_medium: string; - color_black: string; - [key: string]: string; -} +type TabKey = + | 'shows' + | 'events' + | 'history' + | 'team' + | 'community' + | 'station' + | 'theme' + | 'underwriters' + | 'mobile-builds' + | 'stats'; @Component({ selector: 'app-admin', standalone: true, + encapsulation: ViewEncapsulation.None, imports: [ CommonModule, - AsyncPipe, - FormsModule, + AdminShowsTabComponent, + AdminEventsTabComponent, + AdminHistoryTabComponent, + AdminTeamTabComponent, + AdminCommunityTabComponent, + AdminStationTabComponent, + AdminThemeTabComponent, + AdminUnderwritersTabComponent, + AdminMobileBuildsTabComponent, + AdminStatsDashboardComponent, AdminShowFormComponent, AdminEventFormComponent, AdminStationFormComponent, @@ -84,109 +62,25 @@ interface ThemeColorState { AdminTeamFormComponent, AdminCommunityFormComponent, AdminUnderwriterFormComponent, - AdminStatsDashboardComponent, ], templateUrl: './admin.component.html', styleUrl: './admin.component.scss', }) export class AdminComponent implements OnInit { - private showService = inject(ShowService); - private eventService = inject(EventService); private stationConfigService = inject(StationConfigService); - private themeService = inject(ThemeService); - private historyEntryService = inject(HistoryEntryService); - private teamMemberService = inject(TeamMemberService); - private communityHighlightService = inject(CommunityHighlightService); - private underwriterService = inject(UnderwriterService); - private mobileBuildService = inject(MobileBuildService); activeTab = signal('shows'); - /** Item currently being edited (set by editXxx, cleared on save/close). */ - editingShow: Show | null = null; - editingEvent: StationEvent | null = null; - editingHistory: HistoryEntry | null = null; - editingTeam: TeamMember | null = null; - editingCommunity: CommunityHighlight | null = null; - editingUnderwriter: Underwriter | null = null; + /** Station config for the station form modal. */ + stationConfig: StationConfig | null = null; - /** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */ - readonly shows$ = new BehaviorSubject([]); - readonly events$ = new BehaviorSubject([]); - readonly stationConfig$ = new BehaviorSubject(null); - readonly historyEntries$ = new BehaviorSubject([]); - readonly teamMembers$ = new BehaviorSubject([]); - readonly communityHighlights$ = new BehaviorSubject([]); - readonly underwriters$ = new BehaviorSubject([]); - readonly mobileBuilds$ = new BehaviorSubject([]); - - mobileBuildDefaults: MobileBuildDefaults | null = null; - selectedMobileBuildId: number | null = null; - selectedMobileBuild: MobileBuildRequest | null = null; - mobileBuildStatusMessage = ''; - mobileBuildErrorMessage = ''; - mobileBuildPreflightIssues: string[] = []; - mobileBuildBusy = false; - // ── Theme JSON status (mobile build tab) ──────────────────── - readonly themeJsonStatus$ = new BehaviorSubject(null); - readonly themeJsonLoading$ = new BehaviorSubject(false); - - buildAndroid = true; - buildIos = true; - androidApplicationId = ''; - iosBundleId = ''; - buildVersionName = '1.0.0'; - buildNumber = '1'; - buildReleaseProfile = 'release'; - - androidKeystoreFile: File | null = null; - androidDescriptorFile: File | null = null; - iosCertificateFile: File | null = null; - iosProfileFile: File | null = null; - - // ── Theme tab state ────────────────────────────────────────── - - /** Local editable color palette (copied from StationConfig on tab open). */ - themeColors: ThemeColorState = {} as ThemeColorState; - - /** Current harmony rule detected from the palette. */ - themeHarmonyRule: HarmonyRule = 'none'; - - /** Validation results per color field. */ - themeValidation: { [key: string]: ColorValidation } = {}; - - /** Whether auto-generate variants is enabled. */ - themeAutoVariants = true; - - /** Saving state for theme tab. */ - themeSaving = false; - themeSaveSuccess = ''; - themeSaveError = ''; - - /** Color role definitions for the template. */ - readonly colorRoles = COLOR_ROLES; - - /** Default palette for reset. */ - readonly themeDefaults: ThemeColorState = { - color_primary: '#1a3a5c', - color_primary_light: '#2a5a8c', - color_primary_dark: '#0e2440', - color_primary_muted: '#3a6a9c', - color_accent: '#e87a2e', - color_accent_light: '#f09a4e', - color_accent_dark: '#c85a1e', - color_accent_muted: '#f0a86a', - color_background: '#faf6f0', - color_text: '#3a3632', - color_success: '#4a9e4f', - color_danger: '#c83030', - color_info: '#3a8abf', - color_white: '#ffffff', - color_light: '#f0ebe3', - color_medium: '#8a8580', - color_black: '#1a1816', - }; + ngOnInit(): void { + this.stationConfigService.load().then(() => { + this.stationConfig = this.stationConfigService.config(); + }); + } + // ── Form modal state ────────────────────────────────────── showShowForm = false; showEventForm = false; showStationForm = false; @@ -195,286 +89,14 @@ export class AdminComponent implements OnInit { showCommunityForm = false; showUnderwriterForm = false; - ngOnInit(): void { - this.loadAll(); - } - - async reloadShows(): Promise { - const data = await firstValueFrom(this.showService.getShows()); - this.shows$.next(data); - } - - async reloadEvents(): Promise { - const data = await firstValueFrom(this.eventService.getEvents()); - this.events$.next(data); - } - - async reloadStationConfig(): Promise { - await this.stationConfigService.load(); - this.stationConfig$.next(this.stationConfigService.config()); - } - - async reloadHistoryEntries(): Promise { - const data = await firstValueFrom(this.historyEntryService.getHistoryEntries()); - this.historyEntries$.next(data); - } - - async reloadTeamMembers(): Promise { - const data = await firstValueFrom(this.teamMemberService.getTeamMembers()); - this.teamMembers$.next(data); - } - - async reloadCommunityHighlights(): Promise { - const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights()); - this.communityHighlights$.next(data); - } - - async reloadUnderwriters(): Promise { - const data = await firstValueFrom(this.underwriterService.getUnderwriters()); - this.underwriters$.next(data); - } - - async loadAll(): Promise { - await Promise.all([ - this.reloadShows(), - this.reloadEvents(), - this.reloadStationConfig(), - this.reloadHistoryEntries(), - this.reloadTeamMembers(), - this.reloadCommunityHighlights(), - this.reloadUnderwriters(), - this.reloadMobileBuildDefaults(), - this.reloadMobileBuilds(), - ]); - } - - async reloadMobileBuildDefaults(): Promise { - try { - this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults()); - } catch { - this.mobileBuildDefaults = null; - } - } - - async reloadMobileBuilds(): Promise { - const data = await firstValueFrom(this.mobileBuildService.listRequests()); - this.mobileBuilds$.next(data); - - if (this.selectedMobileBuildId !== null) { - const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null; - this.selectedMobileBuild = selected; - if (!selected) { - this.selectedMobileBuildId = null; - } - } - } - - onMobileBuildSelected(buildId: number): void { - this.selectedMobileBuildId = buildId; - const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null; - this.selectedMobileBuild = selected; - this.mobileBuildPreflightIssues = selected?.preflight_report - ? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0) - : []; - } - - onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void { - const input = event.target as HTMLInputElement; - const file = input.files && input.files.length > 0 ? input.files[0] : null; - - if (target === 'android-keystore') this.androidKeystoreFile = file; - if (target === 'android-descriptor') this.androidDescriptorFile = file; - if (target === 'ios-certificate') this.iosCertificateFile = file; - if (target === 'ios-profile') this.iosProfileFile = file; - } - - private getErrorMessage(err: unknown, fallback: string): string { - if (typeof err === 'object' && err !== null) { - const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string }; - const detail = maybeErr.error?.detail; - if (typeof detail === 'string') return detail; - if (Array.isArray(detail)) return detail.join(' | '); - if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message; - if (typeof maybeErr.message === 'string') return maybeErr.message; - } - return fallback; - } - - private resetMobileBuildMessages(): void { - this.mobileBuildStatusMessage = ''; - this.mobileBuildErrorMessage = ''; - } - - private validateBuildInputs(): string | null { - if (!this.buildAndroid && !this.buildIos) { - return 'Select at least one platform.'; - } - - if (this.buildAndroid && !this.androidApplicationId.trim()) { - return 'Android application ID is required.'; - } - - if (this.buildIos && !this.iosBundleId.trim()) { - return 'iOS bundle ID is required.'; - } - - if (!this.buildVersionName.trim() || !this.buildNumber.trim()) { - return 'Version name and build number are required.'; - } - - if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) { - return 'Android requires both keystore and descriptor files.'; - } - - if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) { - return 'iOS requires both certificate and provisioning profile files.'; - } - - return null; - } - - async createAndUploadMobileBuildRequest(): Promise { - this.resetMobileBuildMessages(); - this.mobileBuildPreflightIssues = []; - const inputError = this.validateBuildInputs(); - if (inputError) { - this.mobileBuildErrorMessage = inputError; - return; - } - - this.mobileBuildBusy = true; - try { - const request = await firstValueFrom(this.mobileBuildService.createRequest({ - platform_android: this.buildAndroid, - platform_ios: this.buildIos, - android_application_id: this.androidApplicationId.trim() || undefined, - ios_bundle_id: this.iosBundleId.trim() || undefined, - version_name: this.buildVersionName.trim(), - build_number: this.buildNumber.trim(), - release_profile: this.buildReleaseProfile.trim() || 'release', - })); - - if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) { - await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile)); - await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile)); - } - - if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) { - await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile)); - await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile)); - } - - this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`; - await this.reloadMobileBuilds(); - this.onMobileBuildSelected(request.id); - } catch (err) { - this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.'); - } finally { - this.mobileBuildBusy = false; - } - } - - async runPreflightForSelectedBuild(): Promise { - if (!this.selectedMobileBuildId) return; - - this.resetMobileBuildMessages(); - this.mobileBuildBusy = true; - try { - const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId)); - this.mobileBuildPreflightIssues = result.issues; - this.mobileBuildStatusMessage = result.passed - ? 'Preflight passed. Build can be triggered.' - : 'Preflight failed. Resolve the listed issues.'; - await this.reloadMobileBuilds(); - this.onMobileBuildSelected(this.selectedMobileBuildId); - } catch (err) { - this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.'); - } finally { - this.mobileBuildBusy = false; - } - } - - async triggerSelectedBuild(): Promise { - if (!this.selectedMobileBuildId) return; - - this.resetMobileBuildMessages(); - this.mobileBuildBusy = true; - try { - const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId)); - this.mobileBuildStatusMessage = `Build #${request.id} queued.`; - await this.reloadMobileBuilds(); - this.onMobileBuildSelected(this.selectedMobileBuildId); - } catch (err) { - this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.'); - } finally { - this.mobileBuildBusy = false; - } - } - - async refreshSelectedBuild(): Promise { - if (!this.selectedMobileBuildId) return; - await this.reloadMobileBuilds(); - this.onMobileBuildSelected(this.selectedMobileBuildId); - } - - // ── Theme JSON status ────────────────────────────────────── - - async loadThemeJsonStatus(): Promise { - try { - this.themeJsonLoading$.next(true); - this.themeJsonStatus$.next(await firstValueFrom( - this.mobileBuildService.getThemeStatus(), - )); - } catch { - this.themeJsonStatus$.next(null); - } finally { - this.themeJsonLoading$.next(false); - } - } - - async regenerateThemeJson(): Promise { - try { - this.themeJsonLoading$.next(true); - await firstValueFrom(this.mobileBuildService.regenerateTheme()); - await this.loadThemeJsonStatus(); - } catch (err) { - this.mobileBuildErrorMessage = this.getErrorMessage( - err, - 'Failed to regenerate theme.json.', - ); - } finally { - this.themeJsonLoading$.next(false); - } - } - - getArtifactDownloadHref(path: string): string { - return `${getAppConfig().apiBaseUrl}${path}`; - } - - formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const size = bytes / Math.pow(1024, i); - return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`; - } - - downloadArtifact(artifact: MobileBuildArtifact): void { - this.mobileBuildService - .getDownloadUrl(artifact.id) - .subscribe({ - next: (res) => { - // Trigger native browser download with the signed URL - window.open(res.download_url, '_blank'); - }, - error: (err) => { - console.error('Failed to get download URL:', err); - }, - }); - } - - // ── Show CRUD ─────────────────────────────────────────── + editingShow: Show | null = null; + editingEvent: StationEvent | null = null; + editingHistory: HistoryEntry | null = null; + editingTeam: TeamMember | null = null; + editingCommunity: CommunityHighlight | null = null; + editingUnderwriter: Underwriter | null = null; + // ── Show form ───────────────────────────────────────────── openShowForm(): void { this.editingShow = null; this.showShowForm = true; @@ -486,7 +108,6 @@ export class AdminComponent implements OnInit { } onShowSaved(): void { - this.reloadShows(); this.showShowForm = false; this.editingShow = null; } @@ -496,14 +117,7 @@ export class AdminComponent implements OnInit { this.editingShow = null; } - async deleteShow(id: number): Promise { - if (!confirm('Delete this show?')) return; - await firstValueFrom(this.showService.deleteShow(id)); - await this.reloadShows(); - } - - // ── Event CRUD ────────────────────────────────────────── - + // ── Event form ──────────────────────────────────────────── openEventForm(): void { this.editingEvent = null; this.showEventForm = true; @@ -515,7 +129,6 @@ export class AdminComponent implements OnInit { } onEventSaved(): void { - this.reloadEvents(); this.showEventForm = false; this.editingEvent = null; } @@ -525,20 +138,13 @@ export class AdminComponent implements OnInit { this.editingEvent = null; } - async deleteEvent(id: number): Promise { - if (!confirm('Delete this event?')) return; - await firstValueFrom(this.eventService.deleteEvent(id)); - await this.reloadEvents(); - } - - // ── Station Config ────────────────────────────────────── - + // ── Station form ────────────────────────────────────────── openStationForm(): void { this.showStationForm = true; } onStationSaved(): void { - this.reloadStationConfig(); + this.stationConfig = this.stationConfigService.config(); this.showStationForm = false; } @@ -546,8 +152,7 @@ export class AdminComponent implements OnInit { this.showStationForm = false; } - // ── History CRUD ──────────────────────────────────────── - + // ── History form ────────────────────────────────────────── openHistoryForm(): void { this.editingHistory = null; this.showHistoryForm = true; @@ -559,7 +164,6 @@ export class AdminComponent implements OnInit { } onHistorySaved(): void { - this.reloadHistoryEntries(); this.showHistoryForm = false; this.editingHistory = null; } @@ -569,14 +173,7 @@ export class AdminComponent implements OnInit { this.editingHistory = null; } - async deleteHistory(id: number): Promise { - if (!confirm('Delete this history entry?')) return; - await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id)); - await this.reloadHistoryEntries(); - } - - // ── Team CRUD ─────────────────────────────────────────── - + // ── Team form ───────────────────────────────────────────── openTeamForm(): void { this.editingTeam = null; this.showTeamForm = true; @@ -588,7 +185,6 @@ export class AdminComponent implements OnInit { } onTeamSaved(): void { - this.reloadTeamMembers(); this.showTeamForm = false; this.editingTeam = null; } @@ -598,14 +194,7 @@ export class AdminComponent implements OnInit { this.editingTeam = null; } - async deleteTeam(id: number): Promise { - if (!confirm('Delete this team member?')) return; - await firstValueFrom(this.teamMemberService.deleteTeamMember(id)); - await this.reloadTeamMembers(); - } - - // ── Community CRUD ────────────────────────────────────── - + // ── Community form ──────────────────────────────────────── openCommunityForm(): void { this.editingCommunity = null; this.showCommunityForm = true; @@ -617,7 +206,6 @@ export class AdminComponent implements OnInit { } onCommunitySaved(): void { - this.reloadCommunityHighlights(); this.showCommunityForm = false; this.editingCommunity = null; } @@ -627,14 +215,7 @@ export class AdminComponent implements OnInit { this.editingCommunity = null; } - async deleteCommunity(id: number): Promise { - if (!confirm('Delete this community highlight?')) return; - await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id)); - await this.reloadCommunityHighlights(); - } - - // ── Underwriter CRUD ──────────────────────────────────── - + // ── Underwriter form ────────────────────────────────────── openUnderwriterForm(): void { this.editingUnderwriter = null; this.showUnderwriterForm = true; @@ -646,7 +227,6 @@ export class AdminComponent implements OnInit { } onUnderwriterSaved(): void { - this.reloadUnderwriters(); this.showUnderwriterForm = false; this.editingUnderwriter = null; } @@ -655,120 +235,4 @@ export class AdminComponent implements OnInit { this.showUnderwriterForm = false; this.editingUnderwriter = null; } - - async deleteUnderwriter(id: number): Promise { - if (!confirm('Delete this underwriter?')) return; - await firstValueFrom(this.underwriterService.deleteUnderwriter(id)); - await this.reloadUnderwriters(); - } - - // ── Theme tab ──────────────────────────────────────────── - - onTabChanged(): void { - if (this.activeTab() === 'theme') { - this.loadThemeColors(); - } - } - - loadThemeColors(): void { - const config = this.stationConfigService.config(); - this.themeColors = { - color_primary: config.color_primary || this.themeDefaults.color_primary, - color_primary_light: config.color_primary_light || this.themeDefaults.color_primary_light, - color_primary_dark: config.color_primary_dark || this.themeDefaults.color_primary_dark, - color_primary_muted: config.color_primary_muted || this.themeDefaults.color_primary_muted, - color_accent: config.color_accent || this.themeDefaults.color_accent, - color_accent_light: config.color_accent_light || this.themeDefaults.color_accent_light, - color_accent_dark: config.color_accent_dark || this.themeDefaults.color_accent_dark, - color_accent_muted: config.color_accent_muted || this.themeDefaults.color_accent_muted, - color_background: config.color_background || this.themeDefaults.color_background, - color_text: config.color_text || this.themeDefaults.color_text, - color_success: config.color_success || this.themeDefaults.color_success, - color_danger: config.color_danger || this.themeDefaults.color_danger, - color_info: config.color_info || this.themeDefaults.color_info, - color_white: config.color_white || this.themeDefaults.color_white, - color_light: config.color_light || this.themeDefaults.color_light, - color_medium: config.color_medium || this.themeDefaults.color_medium, - color_black: config.color_black || this.themeDefaults.color_black, - }; - this.themeSaveSuccess = ''; - this.themeSaveError = ''; - this.revalidateTheme(); - } - - /** Re-run harmony detection + validation on the current palette. */ - revalidateTheme(): void { - const primary = this.themeColors.color_primary; - const accent = this.themeColors.color_accent; - this.themeHarmonyRule = detectHarmonyRule(primary, accent); - this.themeValidation = validatePalette(this.themeColors, this.themeHarmonyRule); - } - - /** Handle color change from the picker. */ - onThemeColorChange(field: string, value: string): void { - this.themeColors[field] = value; - - // Auto-generate variants for primary/accent bases - if (this.themeAutoVariants) { - if (field === 'color_primary') { - this.themeColors.color_primary_light = generateLight(value); - this.themeColors.color_primary_dark = generateDark(value); - this.themeColors.color_primary_muted = generateMuted(value); - } else if (field === 'color_accent') { - this.themeColors.color_accent_light = generateLight(value); - this.themeColors.color_accent_dark = generateDark(value); - this.themeColors.color_accent_muted = generateMuted(value); - } - } - - // Live-apply to DOM for instant preview - this.themeService.apply(this.themeColors as any); - this.revalidateTheme(); - } - - /** Auto-fix all non-harmonious colors. */ - autoFixTheme(): void { - const fixed = autoFixPalette(this.themeColors, this.themeHarmonyRule); - for (const key of Object.keys(fixed) as (keyof ThemeColorState)[]) { - (this.themeColors as any)[key] = fixed[key]; - } - this.themeService.apply(this.themeColors as any); - this.revalidateTheme(); - } - - /** Reset to default palette. */ - resetTheme(): void { - for (const key of Object.keys(this.themeDefaults) as (keyof ThemeColorState)[]) { - (this.themeColors as any)[key] = this.themeDefaults[key]; - } - this.themeService.apply(this.themeColors as any); - this.revalidateTheme(); - } - - /** Save theme colors to the database. */ - async saveTheme(): Promise { - this.themeSaving = true; - this.themeSaveError = ''; - this.themeSaveSuccess = ''; - - try { - await this.stationConfigService.updateConfig(this.themeColors); - this.themeSaveSuccess = 'Theme saved successfully!'; - } catch (err: any) { - this.themeSaveError = err?.error?.detail || 'Failed to save theme.'; - } finally { - this.themeSaving = false; - } - } - - /** Get harmony rule label for display. */ - getHarmonyLabel(): string { - return HARMONY_RULE_LABELS[this.themeHarmonyRule]; - } - - /** Check if a color field has a harmony issue. */ - hasHarmonyIssue(field: string): boolean { - const v = this.themeValidation[field]; - return v ? !v.isHarmonious : false; - } }