Refactors admin page design to move each 'facet' of admin into its own component.

This commit is contained in:
2026-07-10 01:14:30 -07:00
parent a4093e86b1
commit c02c0cdde9
30 changed files with 1970 additions and 1757 deletions
@@ -0,0 +1,36 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>Community Highlights ({{ (highlights$ | async)?.length }})</h2>
<button class="btn btn-add" (click)="onAdd()">+ Add Highlight</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Icon</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (highlight of highlights$ | async; track highlight.id) {
<tr>
<td>{{ highlight.icon }}</td>
<td>{{ highlight.title }}</td>
<td>{{ highlight.display_order }}</td>
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onEdit(highlight)">Edit</button>
<button class="btn-icon btn-delete" (click)="onDelete(highlight.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,2 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@@ -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<void>();
readonly editItem = output<CommunityHighlight>();
readonly deleteItem = output<number>();
readonly highlights$ = this.communityHighlightService.getCommunityHighlights();
ngOnInit(): void {
this.load();
}
async load(): Promise<void> {
await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
}
onAdd(): void {
this.openForm.emit();
}
onEdit(highlight: CommunityHighlight): void {
this.editItem.emit(highlight);
}
async onDelete(id: number): Promise<void> {
if (!confirm('Delete this community highlight?')) return;
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
await this.load();
}
}
@@ -0,0 +1,36 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>Events ({{ (events$ | async)?.length }})</h2>
<button class="btn btn-add" (click)="onAdd()">+ Add Event</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Location</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (event of events$ | async; track event.id) {
<tr>
<td>{{ event.date }}</td>
<td>{{ event.title }}</td>
<td>{{ event.location }}</td>
<td>{{ event.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onEdit(event)">Edit</button>
<button class="btn-icon btn-delete" (click)="onDelete(event.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,2 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@@ -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<void>();
readonly editItem = output<StationEvent>();
readonly deleteItem = output<number>();
readonly events$ = this.eventService.getEvents();
ngOnInit(): void {
this.load();
}
async load(): Promise<void> {
await firstValueFrom(this.eventService.getEvents());
}
onAdd(): void {
this.openForm.emit();
}
onEdit(event: StationEvent): void {
this.editItem.emit(event);
}
async onDelete(id: number): Promise<void> {
if (!confirm('Delete this event?')) return;
await firstValueFrom(this.eventService.deleteEvent(id));
await this.load();
}
}
@@ -0,0 +1,36 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>History Timeline ({{ (entries$ | async)?.length }})</h2>
<button class="btn btn-add" (click)="onAdd()">+ Add Entry</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Year</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of entries$ | async; track entry.id) {
<tr>
<td>{{ entry.year }}</td>
<td>{{ entry.title }}</td>
<td>{{ entry.display_order }}</td>
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onEdit(entry)">Edit</button>
<button class="btn-icon btn-delete" (click)="onDelete(entry.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,2 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@@ -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<void>();
readonly editItem = output<HistoryEntry>();
readonly deleteItem = output<number>();
readonly entries$ = this.historyEntryService.getHistoryEntries();
ngOnInit(): void {
this.load();
}
async load(): Promise<void> {
await firstValueFrom(this.historyEntryService.getHistoryEntries());
}
onAdd(): void {
this.openForm.emit();
}
onEdit(entry: HistoryEntry): void {
this.editItem.emit(entry);
}
async onDelete(id: number): Promise<void> {
if (!confirm('Delete this history entry?')) return;
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
await this.load();
}
}
@@ -0,0 +1,218 @@
<div class="tab-content mobile-builds-panel">
<div class="tab-toolbar">
<h2>Mobile Build Orchestration</h2>
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
</div>
<div class="mobile-build-grid">
<section class="build-card">
<h3>Derived Defaults</h3>
@if (mobileBuildDefaults; as defaults) {
<div class="derived-list">
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
<div><strong>Website:</strong> {{ defaults.website }}</div>
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
</div>
} @else {
<p class="empty-row">Defaults unavailable. Check station config.</p>
}
</section>
<section class="build-card">
<h3>Theme Export</h3>
@if (themeJsonStatus$ | async; as status) {
@if (status.exists) {
<div class="theme-status-ok">
<div><strong>theme.json</strong> — ready</div>
<div>Path: {{ status.path }}</div>
<div>Generated: {{ status.generated_at }}</div>
<div>Size: {{ status.size }} bytes</div>
</div>
} @else {
<div class="theme-status-missing">
<p><strong>theme.json</strong> not found at {{ status.path }}</p>
<p>Regenerate to create it from the current station config.</p>
</div>
}
<button class="btn btn-outline" (click)="regenerateThemeJson()"
[disabled]="themeJsonLoading$ | async">
{{ (themeJsonLoading$ | async) ? 'Regenerating...' : 'Regenerate' }}
</button>
} @else {
@if (themeJsonLoading$ | async) {
<p class="empty-row">Loading...</p>
}
}
</section>
<section class="build-card">
<h3>Create Build Request</h3>
<div class="form-row platform-row">
<label>
<input type="checkbox" [(ngModel)]="buildAndroid">
Android
</label>
<label>
<input type="checkbox" [(ngModel)]="buildIos">
iOS
</label>
</div>
<div class="form-row">
<label>
Android Application ID
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
</label>
<label>
iOS Bundle ID
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
</label>
</div>
<div class="form-row">
<label>
Version Name
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
</label>
<label>
Build Number
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
</label>
<label>
Release Profile
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
</label>
</div>
<h4>Required Files</h4>
<div class="form-row">
<label>
Android Keystore (.jks/.keystore)
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
</label>
<label>
Android Descriptor (JSON)
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
</label>
</div>
<div class="form-row">
<label>
iOS Certificate (.p12)
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
</label>
<label>
iOS Provisioning Profile (.mobileprovision)
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
</label>
</div>
@if (mobileBuildStatusMessage) {
<p class="status success">{{ mobileBuildStatusMessage }}</p>
}
@if (mobileBuildErrorMessage) {
<p class="status error">{{ mobileBuildErrorMessage }}</p>
}
<button class="btn btn-add" [disabled]="mobileBuildBusy" (click)="createAndUploadMobileBuildRequest()">
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
</button>
</section>
</div>
<section class="build-card">
<h3>Build Requests</h3>
@if (mobileBuilds$ | async; as builds) {
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Platforms</th>
<th>Version</th>
<th>Status</th>
<th>Preflight</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (build of builds; track build.id) {
<tr [class.selected-build]="selectedMobileBuildId === build.id">
<td>#{{ build.id }}</td>
<td>
{{ build.platform_android ? 'Android' : '' }}
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
{{ build.platform_ios ? 'iOS' : '' }}
</td>
<td>{{ build.version_name }} ({{ build.build_number }})</td>
<td>{{ build.status }}</td>
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No build requests yet.</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (selectedMobileBuild; as build) {
<section class="build-card">
<div class="tab-toolbar compact">
<h3>Selected Request #{{ build.id }}</h3>
<div class="action-group">
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run
Preflight</button>
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed"
(click)="triggerSelectedBuild()">Trigger Build</button>
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy"
(click)="refreshSelectedBuild()">Refresh</button>
</div>
</div>
@if (mobileBuildPreflightIssues.length > 0) {
<div class="status error">
<strong>Preflight issues:</strong>
<ul>
@for (issue of mobileBuildPreflightIssues; track issue) {
<li>{{ issue }}</li>
}
</ul>
</div>
}
@if (build.error_message) {
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
}
<h4>Artifacts</h4>
<div class="artifact-list">
@for (artifact of build.artifacts; track artifact.id) {
<div class="artifact-row">
<div>
<strong>{{ artifact.file_name }}</strong>
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}</div>
</div>
<button class="btn-icon btn-edit"
(click)="downloadArtifact(artifact)">
Download
</button>
</div>
} @empty {
<p class="empty-row">No artifacts available yet.</p>
}
</div>
<h4>Build Log</h4>
<div class="build-log"><pre>{{ build.build_log || 'No logs yet.' }}</pre></div>
</section>
}
</div>
@@ -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;
}
}
@@ -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<MobileBuildRequest[]>([]);
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(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<void> {
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<void> {
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<void> {
try {
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
} catch {
this.mobileBuildDefaults = null;
}
}
async reloadMobileBuilds(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
},
});
}
}
@@ -0,0 +1,42 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>Shows ({{ (shows$ | async)?.length }})</h2>
<button class="btn btn-add" (click)="onAdd()">+ Add Show</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Host</th>
<th>Genre</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (show of shows$ | async; track show.id) {
<tr>
<td>{{ show.display_order }}</td>
<td>{{ show.title }}</td>
<td>{{ show.host }}</td>
<td>{{ show.genre }}</td>
<td>
@for (slot of show.schedules; track slot.id) {
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
}
</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onEdit(show)">Edit</button>
<button class="btn-icon btn-delete" (click)="onDelete(show.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,2 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@@ -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<void>();
readonly editItem = output<Show>();
readonly deleteItem = output<number>();
readonly shows$ = this.showService.getShows();
ngOnInit(): void {
this.load();
}
async load(): Promise<void> {
await firstValueFrom(this.showService.getShows());
}
onAdd(): void {
this.openForm.emit();
}
onEdit(show: Show): void {
this.editItem.emit(show);
}
async onDelete(id: number): Promise<void> {
if (!confirm('Delete this show?')) return;
await firstValueFrom(this.showService.deleteShow(id));
await this.load();
}
}
@@ -0,0 +1,59 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>Station Configuration</h2>
<button class="btn btn-add" (click)="onEdit()">Edit Config</button>
</div>
@if (config(); as config) {
<div class="station-config-view">
<div class="config-row">
<span class="config-label">Callsign</span>
<span class="config-value">{{ config.callsign }}</span>
</div>
<div class="config-row">
<span class="config-label">Name</span>
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
</div>
<div class="config-row">
<span class="config-label">Tagline</span>
<span class="config-value">{{ config.tagline }}</span>
</div>
<div class="config-row">
<span class="config-label">Frequency</span>
<span class="config-value">{{ config.frequency }}</span>
</div>
<div class="config-row">
<span class="config-label">Founded</span>
<span class="config-value">{{ config.founded_year }}</span>
</div>
<div class="config-row">
<span class="config-label">Nonprofit</span>
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
</div>
<div class="config-row">
<span class="config-label">Email</span>
<span class="config-value">{{ config.email }}</span>
</div>
<div class="config-row">
<span class="config-label">Phone</span>
<span class="config-value">{{ config.phone }}</span>
</div>
<div class="config-row">
<span class="config-label">Website</span>
<span class="config-value">{{ config.website }}</span>
</div>
<div class="config-row">
<span class="config-label">Donation URL</span>
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
</div>
<div class="config-row">
<span class="config-label">Play Store URL</span>
<span class="config-value">{{ config.play_store_url || '(not set)' }}</span>
</div>
<div class="config-row">
<span class="config-label">App Store Embed</span>
<span class="config-value">{{ config.app_store_embed_html ? 'Configured' : '(not set)' }}</span>
</div>
</div>
}
</div>
@@ -0,0 +1,2 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@@ -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<void>();
readonly config = this.stationConfigService.config;
ngOnInit(): void {
this.stationConfigService.load();
}
onEdit(): void {
this.openForm.emit();
}
}
@@ -0,0 +1,36 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>Team Members ({{ (members$ | async)?.length }})</h2>
<button class="btn btn-add" (click)="onAdd()">+ Add Member</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (member of members$ | async; track member.id) {
<tr>
<td>{{ member.name }}</td>
<td>{{ member.title }}</td>
<td>{{ member.display_order }}</td>
<td>{{ member.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onEdit(member)">Edit</button>
<button class="btn-icon btn-delete" (click)="onDelete(member.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,2 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
+45
View File
@@ -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<void>();
readonly editItem = output<TeamMember>();
readonly deleteItem = output<number>();
readonly members$ = this.teamMemberService.getTeamMembers();
ngOnInit(): void {
this.load();
}
async load(): Promise<void> {
await firstValueFrom(this.teamMemberService.getTeamMembers());
}
onAdd(): void {
this.openForm.emit();
}
onEdit(member: TeamMember): void {
this.editItem.emit(member);
}
async onDelete(id: number): Promise<void> {
if (!confirm('Delete this team member?')) return;
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
await this.load();
}
}
@@ -0,0 +1,53 @@
<div class="tab-content theme-panel">
<div class="tab-toolbar">
<h2>Color Theme</h2>
</div>
<div class="theme-harmony-bar">
<div class="harmony-info">
<span class="harmony-label">Harmony:</span>
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
</div>
<label class="auto-variants-toggle">
<input type="checkbox" [(ngModel)]="themeAutoVariants">
Auto-generate variants
</label>
</div>
@if (themeSaveSuccess) {
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
}
@if (themeSaveError) {
<div class="theme-message theme-error">{{ themeSaveError }}</div>
}
<div class="color-grid">
@for (role of colorRoles; track role.field) {
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
<div class="color-swatch" [style.background]="themeColors[role.field]">
<input type="color" [value]="themeColors[role.field]"
(input)="onThemeColorChange(role.field, $event.target.value)">
</div>
<div class="color-info">
<span class="color-name">{{ role.label }}</span>
<span class="color-hex">{{ themeColors[role.field] }}</span>
</div>
<div class="color-status">
@if (hasHarmonyIssue(role.field)) {
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct"></span>
} @else if (role.group !== 'neutral') {
<span class="harmony-ok"></span>
}
</div>
</div>
}
</div>
<div class="theme-actions">
<button type="button" class="btn btn-outline-theme" (click)="resetTheme()">Reset to Defaults</button>
<button type="button" class="btn btn-outline-theme" (click)="autoFixTheme()">Auto-Fix Harmony</button>
<button type="button" class="btn btn-save-theme" (click)="saveTheme()" [disabled]="themeSaving">
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
</button>
</div>
</div>
@@ -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;
}
}
+178
View File
@@ -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<void> {
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;
}
}
@@ -0,0 +1,44 @@
<div class="tab-content">
<div class="tab-toolbar">
<h2>Underwriters ({{ (underwriters$ | async)?.length }})</h2>
<button class="btn btn-add" (click)="onAdd()">+ Add Underwriter</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Website</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (uw of underwriters$ | async; track uw.id) {
<tr>
<td>{{ uw.name }}</td>
<td class="description-cell">{{ uw.description }}</td>
<td>
@if (uw.website_url) {
<a [href]="uw.website_url" target="_blank" rel="noopener">{{ uw.website_url }}</a>
} @else {
}
</td>
<td>{{ uw.display_order }}</td>
<td>{{ uw.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onEdit(uw)">Edit</button>
<button class="btn-icon btn-delete" (click)="onDelete(uw.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
@@ -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;
}
@@ -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<void>();
readonly editItem = output<Underwriter>();
readonly deleteItem = output<number>();
readonly underwriters$ = this.underwriterService.getUnderwriters();
ngOnInit(): void {
this.load();
}
async load(): Promise<void> {
await firstValueFrom(this.underwriterService.getUnderwriters());
}
onAdd(): void {
this.openForm.emit();
}
onEdit(underwriter: Underwriter): void {
this.editItem.emit(underwriter);
}
async onDelete(id: number): Promise<void> {
if (!confirm('Delete this underwriter?')) return;
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
await this.load();
}
}
+31 -600
View File
@@ -8,634 +8,66 @@
<!-- Tabs --> <!-- Tabs -->
<div class="admin-tabs"> <div class="admin-tabs">
<button class="tab-btn" [class.active]="activeTab() === 'shows'" (click)="activeTab.set('shows')">Shows</button> <button class="tab-btn" [class.active]="activeTab() === 'shows'" (click)="activeTab.set('shows')">Shows</button>
<button class="tab-btn" [class.active]="activeTab() === 'events'" <button class="tab-btn" [class.active]="activeTab() === 'events'" (click)="activeTab.set('events')">Events</button>
(click)="activeTab.set('events')">Events</button> <button class="tab-btn" [class.active]="activeTab() === 'history'" (click)="activeTab.set('history')">History</button>
<button class="tab-btn" [class.active]="activeTab() === 'history'"
(click)="activeTab.set('history')">History</button>
<button class="tab-btn" [class.active]="activeTab() === 'team'" (click)="activeTab.set('team')">Team</button> <button class="tab-btn" [class.active]="activeTab() === 'team'" (click)="activeTab.set('team')">Team</button>
<button class="tab-btn" [class.active]="activeTab() === 'community'" <button class="tab-btn" [class.active]="activeTab() === 'community'" (click)="activeTab.set('community')">Community</button>
(click)="activeTab.set('community')">Community</button> <button class="tab-btn" [class.active]="activeTab() === 'station'" (click)="activeTab.set('station')">Station</button>
<button class="tab-btn" [class.active]="activeTab() === 'station'" <button class="tab-btn" [class.active]="activeTab() === 'theme'" (click)="activeTab.set('theme')">Theme</button>
(click)="activeTab.set('station')">Station</button> <button class="tab-btn" [class.active]="activeTab() === 'underwriters'" (click)="activeTab.set('underwriters')">Underwriters</button>
<button class="tab-btn" [class.active]="activeTab() === 'theme'" <button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'" (click)="activeTab.set('mobile-builds')">Mobile Builds</button>
(click)="activeTab.set('theme'); onTabChanged()">Theme</button>
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'"
(click)="activeTab.set('underwriters')">Underwriters</button>
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'"
(click)="activeTab.set('mobile-builds'); loadThemeJsonStatus()">Mobile Builds</button>
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button> <button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
</div> </div>
<!-- Shows Tab --> <!-- Tab content -->
@if (activeTab() === 'shows') { @if (activeTab() === 'shows') {
@if (shows$ | async; as shows) { <app-admin-shows-tab
<div class="tab-content"> (openForm)="openShowForm()"
<div class="tab-toolbar"> (editItem)="editShow($event)" />
<h2>Shows ({{ shows.length }})</h2>
<button class="btn btn-add" (click)="openShowForm()">+ Add Show</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Host</th>
<th>Genre</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (show of shows; track show.id) {
<tr>
<td>{{ show.display_order }}</td>
<td>{{ show.title }}</td>
<td>{{ show.host }}</td>
<td>{{ show.genre }}</td>
<td>
@for (slot of show.schedules; track slot.id) {
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
}
</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editShow(show)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteShow(show.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
}
} }
<!-- Events Tab -->
@if (activeTab() === 'events') { @if (activeTab() === 'events') {
@if (events$ | async; as events) { <app-admin-events-tab
<div class="tab-content"> (openForm)="openEventForm()"
<div class="tab-toolbar"> (editItem)="editEvent($event)" />
<h2>Events ({{ events.length }})</h2>
<button class="btn btn-add" (click)="openEventForm()">+ Add Event</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Location</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (event of events; track event.id) {
<tr>
<td>{{ event.date }}</td>
<td>{{ event.title }}</td>
<td>{{ event.location }}</td>
<td>{{ event.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editEvent(event)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteEvent(event.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
}
} }
<!-- History Tab -->
@if (activeTab() === 'history') { @if (activeTab() === 'history') {
@if (historyEntries$ | async; as entries) { <app-admin-history-tab
<div class="tab-content"> (openForm)="openHistoryForm()"
<div class="tab-toolbar"> (editItem)="editHistory($event)" />
<h2>History Timeline ({{ entries.length }})</h2>
<button class="btn btn-add" (click)="openHistoryForm()">+ Add Entry</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Year</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of entries; track entry.id) {
<tr>
<td>{{ entry.year }}</td>
<td>{{ entry.title }}</td>
<td>{{ entry.display_order }}</td>
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editHistory(entry)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteHistory(entry.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
}
} }
<!-- Team Tab -->
@if (activeTab() === 'team') { @if (activeTab() === 'team') {
@if (teamMembers$ | async; as members) { <app-admin-team-tab
<div class="tab-content"> (openForm)="openTeamForm()"
<div class="tab-toolbar"> (editItem)="editTeam($event)" />
<h2>Team Members ({{ members.length }})</h2>
<button class="btn btn-add" (click)="openTeamForm()">+ Add Member</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (member of members; track member.id) {
<tr>
<td>{{ member.name }}</td>
<td>{{ member.title }}</td>
<td>{{ member.display_order }}</td>
<td>{{ member.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editTeam(member)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteTeam(member.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
}
} }
<!-- Community Tab -->
@if (activeTab() === 'community') { @if (activeTab() === 'community') {
@if (communityHighlights$ | async; as highlights) { <app-admin-community-tab
<div class="tab-content"> (openForm)="openCommunityForm()"
<div class="tab-toolbar"> (editItem)="editCommunity($event)" />
<h2>Community Highlights ({{ highlights.length }})</h2>
<button class="btn btn-add" (click)="openCommunityForm()">+ Add Highlight</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Icon</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (highlight of highlights; track highlight.id) {
<tr>
<td>{{ highlight.icon }}</td>
<td>{{ highlight.title }}</td>
<td>{{ highlight.display_order }}</td>
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editCommunity(highlight)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteCommunity(highlight.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
}
} }
<!-- Station Tab -->
@if (activeTab() === 'station') { @if (activeTab() === 'station') {
@if (stationConfig$ | async; as config) { <app-admin-station-tab (openForm)="openStationForm()" />
<div class="tab-content">
<div class="tab-toolbar">
<h2>Station Configuration</h2>
<button class="btn btn-add" (click)="openStationForm()">Edit Config</button>
</div>
<div class="station-config-view">
<div class="config-row">
<span class="config-label">Callsign</span>
<span class="config-value">{{ config.callsign }}</span>
</div>
<div class="config-row">
<span class="config-label">Name</span>
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
</div>
<div class="config-row">
<span class="config-label">Tagline</span>
<span class="config-value">{{ config.tagline }}</span>
</div>
<div class="config-row">
<span class="config-label">Frequency</span>
<span class="config-value">{{ config.frequency }}</span>
</div>
<div class="config-row">
<span class="config-label">Founded</span>
<span class="config-value">{{ config.founded_year }}</span>
</div>
<div class="config-row">
<span class="config-label">Nonprofit</span>
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
</div>
<div class="config-row">
<span class="config-label">Email</span>
<span class="config-value">{{ config.email }}</span>
</div>
<div class="config-row">
<span class="config-label">Phone</span>
<span class="config-value">{{ config.phone }}</span>
</div>
<div class="config-row">
<span class="config-label">Website</span>
<span class="config-value">{{ config.website }}</span>
</div>
<div class="config-row">
<span class="config-label">Donation URL</span>
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
</div>
<div class="config-row">
<span class="config-label">Play Store URL</span>
<span class="config-value">{{ config.play_store_url || '(not set)' }}</span>
</div>
<div class="config-row">
<span class="config-label">App Store Embed</span>
<span class="config-value">{{ config.app_store_embed_html ? 'Configured' : '(not set)' }}</span>
</div>
</div>
</div>
}
} }
<!-- Theme Tab -->
@if (activeTab() === 'theme') { @if (activeTab() === 'theme') {
<div class="tab-content theme-panel"> <app-admin-theme-tab />
<div class="tab-toolbar">
<h2>Color Theme</h2>
</div>
<div class="theme-harmony-bar">
<div class="harmony-info">
<span class="harmony-label">Harmony:</span>
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
</div>
<label class="auto-variants-toggle">
<input type="checkbox" [(ngModel)]="themeAutoVariants">
Auto-generate variants
</label>
</div>
@if (themeSaveSuccess) {
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
}
@if (themeSaveError) {
<div class="theme-message theme-error">{{ themeSaveError }}</div>
} }
<!-- Color grid -->
<div class="color-grid">
@for (role of colorRoles; track role.field) {
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
<div class="color-swatch" [style.background]="themeColors[role.field]">
<input type="color" [value]="themeColors[role.field]"
(input)="onThemeColorChange(role.field, $event.target.value)">
</div>
<div class="color-info">
<span class="color-name">{{ role.label }}</span>
<span class="color-hex">{{ themeColors[role.field] }}</span>
</div>
<div class="color-status">
@if (hasHarmonyIssue(role.field)) {
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct"></span>
} @else if (role.group !== 'neutral') {
<span class="harmony-ok"></span>
}
</div>
</div>
}
</div>
<div class="theme-actions">
<button type="button" class="btn btn-outline-theme" (click)="resetTheme()">Reset to Defaults</button>
<button type="button" class="btn btn-outline-theme" (click)="autoFixTheme()">Auto-Fix Harmony</button>
<button type="button" class="btn btn-save-theme" (click)="saveTheme()" [disabled]="themeSaving">
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
</button>
</div>
</div>
}
<!-- Underwriters Tab -->
@if (activeTab() === 'underwriters') { @if (activeTab() === 'underwriters') {
@if (underwriters$ | async; as underwriters) { <app-admin-underwriters-tab
<div class="tab-content"> (openForm)="openUnderwriterForm()"
<div class="tab-toolbar"> (editItem)="editUnderwriter($event)" />
<h2>Underwriters ({{ underwriters.length }})</h2>
<button class="btn btn-add" (click)="openUnderwriterForm()">+ Add Underwriter</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Website</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (uw of underwriters; track uw.id) {
<tr>
<td>{{ uw.name }}</td>
<td class="description-cell">{{ uw.description }}</td>
<td>
@if (uw.website_url) {
<a [href]="uw.website_url" target="_blank" rel="noopener">{{ uw.website_url }}</a>
} @else {
}
</td>
<td>{{ uw.display_order }}</td>
<td>{{ uw.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editUnderwriter(uw)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteUnderwriter(uw.id)">Delete</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td>
</tr>
}
</tbody>
</table>
</div>
}
} }
<!-- Mobile Builds Tab -->
@if (activeTab() === 'mobile-builds') { @if (activeTab() === 'mobile-builds') {
<div class="tab-content mobile-builds-panel"> <app-admin-mobile-builds-tab />
<div class="tab-toolbar">
<h2>Mobile Build Orchestration</h2>
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
</div>
<div class="mobile-build-grid">
<section class="build-card">
<h3>Derived Defaults</h3>
@if (mobileBuildDefaults; as defaults) {
<div class="derived-list">
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
<div><strong>Website:</strong> {{ defaults.website }}</div>
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
</div>
} @else {
<p class="empty-row">Defaults unavailable. Check station config.</p>
}
</section>
<section class="build-card">
<h3>Theme Export</h3>
@if (themeJsonStatus$ | async; as status) {
@if (status.exists) {
<div class="theme-status-ok">
<div><strong>theme.json</strong> — ready</div>
<div>Path: {{ status.path }}</div>
<div>Generated: {{ status.generated_at }}</div>
<div>Size: {{ status.size }} bytes</div>
</div>
} @else {
<div class="theme-status-missing">
<p><strong>theme.json</strong> not found at {{ status.path }}</p>
<p>Regenerate to create it from the current station config.</p>
</div>
}
<button class="btn btn-outline" (click)="regenerateThemeJson()"
[disabled]="themeJsonLoading$ | async">
{{ (themeJsonLoading$ | async) ? 'Regenerating...' : 'Regenerate' }}
</button>
} @else {
@if (themeJsonLoading$ | async) {
<p class="empty-row">Loading...</p>
}
}
</section>
<section class="build-card">
<h3>Create Build Request</h3>
<div class="form-row platform-row">
<label>
<input type="checkbox" [(ngModel)]="buildAndroid">
Android
</label>
<label>
<input type="checkbox" [(ngModel)]="buildIos">
iOS
</label>
</div>
<div class="form-row">
<label>
Android Application ID
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
</label>
<label>
iOS Bundle ID
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
</label>
</div>
<div class="form-row">
<label>
Version Name
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
</label>
<label>
Build Number
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
</label>
<label>
Release Profile
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
</label>
</div>
<h4>Required Files</h4>
<div class="form-row">
<label>
Android Keystore (.jks/.keystore)
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
</label>
<label>
Android Descriptor (JSON)
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
</label>
</div>
<div class="form-row">
<label>
iOS Certificate (.p12)
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
</label>
<label>
iOS Provisioning Profile (.mobileprovision)
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
</label>
</div>
@if (mobileBuildStatusMessage) {
<p class="status success">{{ mobileBuildStatusMessage }}</p>
}
@if (mobileBuildErrorMessage) {
<p class="status error">{{ mobileBuildErrorMessage }}</p>
} }
<button class="btn btn-add" [disabled]="mobileBuildBusy" (click)="createAndUploadMobileBuildRequest()">
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
</button>
</section>
</div>
<section class="build-card">
<h3>Build Requests</h3>
@if (mobileBuilds$ | async; as builds) {
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Platforms</th>
<th>Version</th>
<th>Status</th>
<th>Preflight</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (build of builds; track build.id) {
<tr [class.selected-build]="selectedMobileBuildId === build.id">
<td>#{{ build.id }}</td>
<td>
{{ build.platform_android ? 'Android' : '' }}
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
{{ build.platform_ios ? 'iOS' : '' }}
</td>
<td>{{ build.version_name }} ({{ build.build_number }})</td>
<td>{{ build.status }}</td>
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No build requests yet.</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (selectedMobileBuild; as build) {
<section class="build-card">
<div class="tab-toolbar compact">
<h3>Selected Request #{{ build.id }}</h3>
<div class="action-group">
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run
Preflight</button>
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed"
(click)="triggerSelectedBuild()">Trigger Build</button>
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy"
(click)="refreshSelectedBuild()">Refresh</button>
</div>
</div>
@if (mobileBuildPreflightIssues.length > 0) {
<div class="status error">
<strong>Preflight issues:</strong>
<ul>
@for (issue of mobileBuildPreflightIssues; track issue) {
<li>{{ issue }}</li>
}
</ul>
</div>
}
@if (build.error_message) {
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
}
<h4>Artifacts</h4>
<div class="artifact-list">
@for (artifact of build.artifacts; track artifact.id) {
<div class="artifact-row">
<div>
<strong>{{ artifact.file_name }}</strong>
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}</div>
</div>
<button class="btn-icon btn-edit"
(click)="downloadArtifact(artifact)">
Download
</button>
</div>
} @empty {
<p class="empty-row">No artifacts available yet.</p>
}
</div>
<h4>Build Log</h4>
<div class="build-log"><pre>{{ build.build_log || 'No logs yet.' }}</pre></div>
</section>
}
</div>
}
<!-- Stats Tab -->
@if (activeTab() === 'stats') { @if (activeTab() === 'stats') {
<div class="tab-content"> <div class="tab-content">
<app-admin-stats-dashboard /> <app-admin-stats-dashboard />
@@ -651,7 +83,7 @@
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" /> <app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
} }
@if (showStationForm) { @if (showStationForm) {
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" /> <app-admin-station-form [editItem]="stationConfig" (saved)="onStationSaved()" (closed)="onStationClosed()" />
} }
@if (showHistoryForm) { @if (showHistoryForm) {
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" /> <app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
@@ -663,7 +95,6 @@
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" /> <app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
} }
@if (showUnderwriterForm) { @if (showUnderwriterForm) {
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" <app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" />
(closed)="onUnderwriterClosed()" />
} }
</section> </section>
+31 -438
View File
@@ -5,9 +5,10 @@
min-height: calc(100vh - 80px); min-height: calc(100vh - 80px);
background: $neutral-cream; background: $neutral-cream;
padding: $spacing-xl 0; padding: $spacing-xl 0;
}
.admin-header { // ── Header ─────────────────────────────────────────────
.admin-header {
text-align: center; text-align: center;
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
@@ -22,18 +23,19 @@
color: $neutral-medium; color: $neutral-medium;
margin: 0; margin: 0;
} }
} }
// ── Tabs ───────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────
.admin-tabs { .admin-tabs {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: $spacing-sm; gap: $spacing-sm;
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
} flex-wrap: wrap;
}
.tab-btn { .tab-btn {
background: $neutral-white; background: $neutral-white;
border: 1px solid $neutral-light; border: 1px solid $neutral-light;
border-radius: $radius-md; border-radius: $radius-md;
@@ -54,16 +56,16 @@
border-color: $primary-blue; border-color: $primary-blue;
color: $neutral-white; color: $neutral-white;
} }
} }
// ── Tab content ────────────────────────────────────────── // ── Tab content ────────────────────────────────────────
.tab-content { .tab-content {
@include card-style; @include card-style;
padding: $spacing-lg; padding: $spacing-lg;
} }
.tab-toolbar { .tab-toolbar {
@include flex-between; @include flex-between;
margin-bottom: $spacing-lg; margin-bottom: $spacing-lg;
@@ -73,15 +75,15 @@
font-size: 1.3rem; font-size: 1.3rem;
margin: 0; margin: 0;
} }
} }
.btn-add { .btn-add {
@include button-style($success-green, $neutral-white, darken($success-green, 8%)); @include button-style($success-green, $neutral-white, darken($success-green, 8%));
} }
// ── Table ──────────────────────────────────────────────── // ── Table ──────────────────────────────────────────────
.admin-table { .admin-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -103,13 +105,6 @@
vertical-align: middle; vertical-align: middle;
} }
.benefits-cell {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.slot-badge { .slot-badge {
display: inline-block; display: inline-block;
background: rgba($primary-blue, 0.1); background: rgba($primary-blue, 0.1);
@@ -133,45 +128,38 @@
gap: $spacing-xs; gap: $spacing-xs;
white-space: nowrap; white-space: nowrap;
} }
} }
.btn-icon { .btn-icon {
border: none; border: none;
padding: $spacing-xs $spacing-sm; padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm; border-radius: $radius-sm;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: background $transition-fast; transition: background $transition-fast;
} }
.btn-edit { .btn-edit {
background: rgba($info-blue, 0.1); background: rgba($info-blue, 0.1);
color: $info-blue; color: $info-blue;
&:hover { &:hover {
background: rgba($info-blue, 0.2); background: rgba($info-blue, 0.2);
} }
} }
.btn-delete { .btn-delete {
background: rgba($danger-red, 0.1); background: rgba($danger-red, 0.1);
color: $danger-red; color: $danger-red;
&:hover { &:hover {
background: rgba($danger-red, 0.2); background: rgba($danger-red, 0.2);
} }
} }
.loading-message { // ── Station config view ────────────────────────────────
text-align: center;
padding: $spacing-3xl;
color: $neutral-medium;
font-size: 1.1rem;
}
// ── Station config view ────────────────────────────────── .station-config-view {
.station-config-view {
.config-row { .config-row {
display: flex; display: flex;
padding: $spacing-sm 0; padding: $spacing-sm 0;
@@ -194,11 +182,11 @@
color: $neutral-dark; color: $neutral-dark;
font-size: 0.95rem; font-size: 0.95rem;
} }
} }
// ── Responsive ─────────────────────────────────────────── // ── Responsive ─────────────────────────────────────────
@include responsive(md) { @include responsive(md) {
.admin-table { .admin-table {
font-size: 0.85rem; font-size: 0.85rem;
@@ -213,400 +201,5 @@
align-items: flex-start; align-items: flex-start;
gap: $spacing-sm; 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;
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
.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
.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;
}
&.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 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;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@include responsive(md) {
.mobile-build-grid {
grid-template-columns: 1fr;
}
.action-group {
flex-wrap: wrap;
} }
} }
+56 -592
View File
@@ -1,30 +1,23 @@
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, OnInit, signal, ViewEncapsulation } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Show } from '../interfaces/show'; import { Show } from '../interfaces/show';
import { Event as StationEvent } from '../interfaces/event'; import { Event as StationEvent } from '../interfaces/event';
import { StationConfig } from '../interfaces/station-config';
import { HistoryEntry } from '../interfaces/history-entry'; import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member'; import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight'; import { CommunityHighlight } from '../interfaces/community-highlight';
import { Underwriter } from '../interfaces/underwriter'; import { Underwriter } from '../interfaces/underwriter';
import { import { StationConfig } from '../interfaces/station-config';
MobileBuildArtifact,
MobileBuildDefaults,
MobileBuildRequest,
ThemeStatus,
} from '../interfaces/mobile-build';
import { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service';
import { StationConfigService } from '../services/station-config.service'; import { StationConfigService } from '../services/station-config.service';
import { ThemeService } from '../services/theme.service'; import { AdminShowsTabComponent } from './admin-shows-tab.component';
import { HistoryEntryService } from '../services/history-entry.service'; import { AdminEventsTabComponent } from './admin-events-tab.component';
import { TeamMemberService } from '../services/team-member.service'; import { AdminHistoryTabComponent } from './admin-history-tab.component';
import { CommunityHighlightService } from '../services/community-highlight.service'; import { AdminTeamTabComponent } from './admin-team-tab.component';
import { UnderwriterService } from '../services/underwriter.service'; import { AdminCommunityTabComponent } from './admin-community-tab.component';
import { MobileBuildService } from '../services/mobile-build.service'; 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 { AdminShowFormComponent } from './admin-show-form.component';
import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminStationFormComponent } from './admin-station-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 { AdminCommunityFormComponent } from './admin-community-form.component';
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component'; import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.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'; type TabKey =
| 'shows'
interface ThemeColorState { | 'events'
color_primary: string; | 'history'
color_primary_light: string; | 'team'
color_primary_dark: string; | 'community'
color_primary_muted: string; | 'station'
color_accent: string; | 'theme'
color_accent_light: string; | 'underwriters'
color_accent_dark: string; | 'mobile-builds'
color_accent_muted: string; | 'stats';
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({ @Component({
selector: 'app-admin', selector: 'app-admin',
standalone: true, standalone: true,
encapsulation: ViewEncapsulation.None,
imports: [ imports: [
CommonModule, CommonModule,
AsyncPipe, AdminShowsTabComponent,
FormsModule, AdminEventsTabComponent,
AdminHistoryTabComponent,
AdminTeamTabComponent,
AdminCommunityTabComponent,
AdminStationTabComponent,
AdminThemeTabComponent,
AdminUnderwritersTabComponent,
AdminMobileBuildsTabComponent,
AdminStatsDashboardComponent,
AdminShowFormComponent, AdminShowFormComponent,
AdminEventFormComponent, AdminEventFormComponent,
AdminStationFormComponent, AdminStationFormComponent,
@@ -84,109 +62,25 @@ interface ThemeColorState {
AdminTeamFormComponent, AdminTeamFormComponent,
AdminCommunityFormComponent, AdminCommunityFormComponent,
AdminUnderwriterFormComponent, AdminUnderwriterFormComponent,
AdminStatsDashboardComponent,
], ],
templateUrl: './admin.component.html', templateUrl: './admin.component.html',
styleUrl: './admin.component.scss', styleUrl: './admin.component.scss',
}) })
export class AdminComponent implements OnInit { export class AdminComponent implements OnInit {
private showService = inject(ShowService);
private eventService = inject(EventService);
private stationConfigService = inject(StationConfigService); 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<TabKey>('shows'); activeTab = signal<TabKey>('shows');
/** Item currently being edited (set by editXxx, cleared on save/close). */ /** Station config for the station form modal. */
editingShow: Show | null = null; stationConfig: StationConfig | null = null;
editingEvent: StationEvent | null = null;
editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null;
editingCommunity: CommunityHighlight | null = null;
editingUnderwriter: Underwriter | null = null;
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */ ngOnInit(): void {
readonly shows$ = new BehaviorSubject<Show[]>([]); this.stationConfigService.load().then(() => {
readonly events$ = new BehaviorSubject<StationEvent[]>([]); this.stationConfig = this.stationConfigService.config();
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null); });
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]); }
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
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<ThemeStatus | null>(null);
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(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',
};
// ── Form modal state ──────────────────────────────────────
showShowForm = false; showShowForm = false;
showEventForm = false; showEventForm = false;
showStationForm = false; showStationForm = false;
@@ -195,286 +89,14 @@ export class AdminComponent implements OnInit {
showCommunityForm = false; showCommunityForm = false;
showUnderwriterForm = false; showUnderwriterForm = false;
ngOnInit(): void { editingShow: Show | null = null;
this.loadAll(); editingEvent: StationEvent | null = null;
} editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null;
async reloadShows(): Promise<void> { editingCommunity: CommunityHighlight | null = null;
const data = await firstValueFrom(this.showService.getShows()); editingUnderwriter: Underwriter | null = null;
this.shows$.next(data);
}
async reloadEvents(): Promise<void> {
const data = await firstValueFrom(this.eventService.getEvents());
this.events$.next(data);
}
async reloadStationConfig(): Promise<void> {
await this.stationConfigService.load();
this.stationConfig$.next(this.stationConfigService.config());
}
async reloadHistoryEntries(): Promise<void> {
const data = await firstValueFrom(this.historyEntryService.getHistoryEntries());
this.historyEntries$.next(data);
}
async reloadTeamMembers(): Promise<void> {
const data = await firstValueFrom(this.teamMemberService.getTeamMembers());
this.teamMembers$.next(data);
}
async reloadCommunityHighlights(): Promise<void> {
const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
this.communityHighlights$.next(data);
}
async reloadUnderwriters(): Promise<void> {
const data = await firstValueFrom(this.underwriterService.getUnderwriters());
this.underwriters$.next(data);
}
async loadAll(): Promise<void> {
await Promise.all([
this.reloadShows(),
this.reloadEvents(),
this.reloadStationConfig(),
this.reloadHistoryEntries(),
this.reloadTeamMembers(),
this.reloadCommunityHighlights(),
this.reloadUnderwriters(),
this.reloadMobileBuildDefaults(),
this.reloadMobileBuilds(),
]);
}
async reloadMobileBuildDefaults(): Promise<void> {
try {
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
} catch {
this.mobileBuildDefaults = null;
}
}
async reloadMobileBuilds(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (!this.selectedMobileBuildId) return;
await this.reloadMobileBuilds();
this.onMobileBuildSelected(this.selectedMobileBuildId);
}
// ── Theme JSON status ──────────────────────────────────────
async loadThemeJsonStatus(): Promise<void> {
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<void> {
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 ───────────────────────────────────────────
// ── Show form ─────────────────────────────────────────────
openShowForm(): void { openShowForm(): void {
this.editingShow = null; this.editingShow = null;
this.showShowForm = true; this.showShowForm = true;
@@ -486,7 +108,6 @@ export class AdminComponent implements OnInit {
} }
onShowSaved(): void { onShowSaved(): void {
this.reloadShows();
this.showShowForm = false; this.showShowForm = false;
this.editingShow = null; this.editingShow = null;
} }
@@ -496,14 +117,7 @@ export class AdminComponent implements OnInit {
this.editingShow = null; this.editingShow = null;
} }
async deleteShow(id: number): Promise<void> { // ── Event form ────────────────────────────────────────────
if (!confirm('Delete this show?')) return;
await firstValueFrom(this.showService.deleteShow(id));
await this.reloadShows();
}
// ── Event CRUD ──────────────────────────────────────────
openEventForm(): void { openEventForm(): void {
this.editingEvent = null; this.editingEvent = null;
this.showEventForm = true; this.showEventForm = true;
@@ -515,7 +129,6 @@ export class AdminComponent implements OnInit {
} }
onEventSaved(): void { onEventSaved(): void {
this.reloadEvents();
this.showEventForm = false; this.showEventForm = false;
this.editingEvent = null; this.editingEvent = null;
} }
@@ -525,20 +138,13 @@ export class AdminComponent implements OnInit {
this.editingEvent = null; this.editingEvent = null;
} }
async deleteEvent(id: number): Promise<void> { // ── Station form ──────────────────────────────────────────
if (!confirm('Delete this event?')) return;
await firstValueFrom(this.eventService.deleteEvent(id));
await this.reloadEvents();
}
// ── Station Config ──────────────────────────────────────
openStationForm(): void { openStationForm(): void {
this.showStationForm = true; this.showStationForm = true;
} }
onStationSaved(): void { onStationSaved(): void {
this.reloadStationConfig(); this.stationConfig = this.stationConfigService.config();
this.showStationForm = false; this.showStationForm = false;
} }
@@ -546,8 +152,7 @@ export class AdminComponent implements OnInit {
this.showStationForm = false; this.showStationForm = false;
} }
// ── History CRUD ──────────────────────────────────────── // ── History form ──────────────────────────────────────────
openHistoryForm(): void { openHistoryForm(): void {
this.editingHistory = null; this.editingHistory = null;
this.showHistoryForm = true; this.showHistoryForm = true;
@@ -559,7 +164,6 @@ export class AdminComponent implements OnInit {
} }
onHistorySaved(): void { onHistorySaved(): void {
this.reloadHistoryEntries();
this.showHistoryForm = false; this.showHistoryForm = false;
this.editingHistory = null; this.editingHistory = null;
} }
@@ -569,14 +173,7 @@ export class AdminComponent implements OnInit {
this.editingHistory = null; this.editingHistory = null;
} }
async deleteHistory(id: number): Promise<void> { // ── Team form ─────────────────────────────────────────────
if (!confirm('Delete this history entry?')) return;
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
await this.reloadHistoryEntries();
}
// ── Team CRUD ───────────────────────────────────────────
openTeamForm(): void { openTeamForm(): void {
this.editingTeam = null; this.editingTeam = null;
this.showTeamForm = true; this.showTeamForm = true;
@@ -588,7 +185,6 @@ export class AdminComponent implements OnInit {
} }
onTeamSaved(): void { onTeamSaved(): void {
this.reloadTeamMembers();
this.showTeamForm = false; this.showTeamForm = false;
this.editingTeam = null; this.editingTeam = null;
} }
@@ -598,14 +194,7 @@ export class AdminComponent implements OnInit {
this.editingTeam = null; this.editingTeam = null;
} }
async deleteTeam(id: number): Promise<void> { // ── Community form ────────────────────────────────────────
if (!confirm('Delete this team member?')) return;
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
await this.reloadTeamMembers();
}
// ── Community CRUD ──────────────────────────────────────
openCommunityForm(): void { openCommunityForm(): void {
this.editingCommunity = null; this.editingCommunity = null;
this.showCommunityForm = true; this.showCommunityForm = true;
@@ -617,7 +206,6 @@ export class AdminComponent implements OnInit {
} }
onCommunitySaved(): void { onCommunitySaved(): void {
this.reloadCommunityHighlights();
this.showCommunityForm = false; this.showCommunityForm = false;
this.editingCommunity = null; this.editingCommunity = null;
} }
@@ -627,14 +215,7 @@ export class AdminComponent implements OnInit {
this.editingCommunity = null; this.editingCommunity = null;
} }
async deleteCommunity(id: number): Promise<void> { // ── Underwriter form ──────────────────────────────────────
if (!confirm('Delete this community highlight?')) return;
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
await this.reloadCommunityHighlights();
}
// ── Underwriter CRUD ────────────────────────────────────
openUnderwriterForm(): void { openUnderwriterForm(): void {
this.editingUnderwriter = null; this.editingUnderwriter = null;
this.showUnderwriterForm = true; this.showUnderwriterForm = true;
@@ -646,7 +227,6 @@ export class AdminComponent implements OnInit {
} }
onUnderwriterSaved(): void { onUnderwriterSaved(): void {
this.reloadUnderwriters();
this.showUnderwriterForm = false; this.showUnderwriterForm = false;
this.editingUnderwriter = null; this.editingUnderwriter = null;
} }
@@ -655,120 +235,4 @@ export class AdminComponent implements OnInit {
this.showUnderwriterForm = false; this.showUnderwriterForm = false;
this.editingUnderwriter = null; this.editingUnderwriter = null;
} }
async deleteUnderwriter(id: number): Promise<void> {
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<void> {
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;
}
} }