Refactors admin page design to move each 'facet' of admin into its own component.
This commit is contained in:
@@ -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 *;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,634 +8,66 @@
|
||||
<!-- 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() === 'events'"
|
||||
(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() === 'events'" (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() === 'team'" (click)="activeTab.set('team')">Team</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'community'"
|
||||
(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() === 'theme'"
|
||||
(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() === 'community'" (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() === 'theme'" (click)="activeTab.set('theme')">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')">Mobile Builds</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
||||
</div>
|
||||
|
||||
<!-- Shows Tab -->
|
||||
<!-- Tab content -->
|
||||
@if (activeTab() === 'shows') {
|
||||
@if (shows$ | async; as shows) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<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>
|
||||
}
|
||||
<app-admin-shows-tab
|
||||
(openForm)="openShowForm()"
|
||||
(editItem)="editShow($event)" />
|
||||
}
|
||||
|
||||
<!-- Events Tab -->
|
||||
@if (activeTab() === 'events') {
|
||||
@if (events$ | async; as events) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<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>
|
||||
}
|
||||
<app-admin-events-tab
|
||||
(openForm)="openEventForm()"
|
||||
(editItem)="editEvent($event)" />
|
||||
}
|
||||
|
||||
<!-- History Tab -->
|
||||
@if (activeTab() === 'history') {
|
||||
@if (historyEntries$ | async; as entries) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<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>
|
||||
}
|
||||
<app-admin-history-tab
|
||||
(openForm)="openHistoryForm()"
|
||||
(editItem)="editHistory($event)" />
|
||||
}
|
||||
|
||||
<!-- Team Tab -->
|
||||
@if (activeTab() === 'team') {
|
||||
@if (teamMembers$ | async; as members) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<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>
|
||||
}
|
||||
<app-admin-team-tab
|
||||
(openForm)="openTeamForm()"
|
||||
(editItem)="editTeam($event)" />
|
||||
}
|
||||
|
||||
<!-- Community Tab -->
|
||||
@if (activeTab() === 'community') {
|
||||
@if (communityHighlights$ | async; as highlights) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<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>
|
||||
}
|
||||
<app-admin-community-tab
|
||||
(openForm)="openCommunityForm()"
|
||||
(editItem)="editCommunity($event)" />
|
||||
}
|
||||
|
||||
<!-- Station Tab -->
|
||||
@if (activeTab() === 'station') {
|
||||
@if (stationConfig$ | async; as config) {
|
||||
<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>
|
||||
}
|
||||
<app-admin-station-tab (openForm)="openStationForm()" />
|
||||
}
|
||||
|
||||
<!-- Theme Tab -->
|
||||
@if (activeTab() === 'theme') {
|
||||
<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>
|
||||
<app-admin-theme-tab />
|
||||
}
|
||||
|
||||
<!-- 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 (underwriters$ | async; as underwriters) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<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>
|
||||
}
|
||||
<app-admin-underwriters-tab
|
||||
(openForm)="openUnderwriterForm()"
|
||||
(editItem)="editUnderwriter($event)" />
|
||||
}
|
||||
|
||||
<!-- Mobile Builds Tab -->
|
||||
@if (activeTab() === 'mobile-builds') {
|
||||
<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>
|
||||
<app-admin-mobile-builds-tab />
|
||||
}
|
||||
|
||||
<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') {
|
||||
<div class="tab-content">
|
||||
<app-admin-stats-dashboard />
|
||||
@@ -651,7 +83,7 @@
|
||||
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
||||
}
|
||||
@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) {
|
||||
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
|
||||
@@ -663,7 +95,6 @@
|
||||
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
|
||||
}
|
||||
@if (showUnderwriterForm) {
|
||||
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()"
|
||||
(closed)="onUnderwriterClosed()" />
|
||||
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" />
|
||||
}
|
||||
</section>
|
||||
@@ -5,9 +5,10 @@
|
||||
min-height: calc(100vh - 80px);
|
||||
background: $neutral-cream;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
// ── Header ─────────────────────────────────────────────
|
||||
|
||||
.admin-header {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
@@ -22,18 +23,19 @@
|
||||
color: $neutral-medium;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────
|
||||
// ── Tabs ───────────────────────────────────────────────
|
||||
|
||||
.admin-tabs {
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
.tab-btn {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
@@ -54,16 +56,16 @@
|
||||
border-color: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab content ──────────────────────────────────────────
|
||||
// ── Tab content ────────────────────────────────────────
|
||||
|
||||
.tab-content {
|
||||
.tab-content {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
.tab-toolbar {
|
||||
@include flex-between;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
@@ -73,15 +75,15 @@
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
.btn-add {
|
||||
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table ────────────────────────────────────────────────
|
||||
// ── Table ──────────────────────────────────────────────
|
||||
|
||||
.admin-table {
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -103,13 +105,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.benefits-cell {
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
display: inline-block;
|
||||
background: rgba($primary-blue, 0.1);
|
||||
@@ -133,45 +128,38 @@
|
||||
gap: $spacing-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
.btn-icon {
|
||||
border: none;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
.btn-edit {
|
||||
background: rgba($info-blue, 0.1);
|
||||
color: $info-blue;
|
||||
|
||||
&:hover {
|
||||
background: rgba($info-blue, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
.btn-delete {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
|
||||
&:hover {
|
||||
background: rgba($danger-red, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
padding: $spacing-3xl;
|
||||
color: $neutral-medium;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
// ── Station config view ────────────────────────────────
|
||||
|
||||
// ── Station config view ──────────────────────────────────
|
||||
|
||||
.station-config-view {
|
||||
.station-config-view {
|
||||
.config-row {
|
||||
display: flex;
|
||||
padding: $spacing-sm 0;
|
||||
@@ -194,11 +182,11 @@
|
||||
color: $neutral-dark;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Responsive ───────────────────────────────────────────
|
||||
// ── Responsive ─────────────────────────────────────────
|
||||
|
||||
@include responsive(md) {
|
||||
@include responsive(md) {
|
||||
.admin-table {
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -213,400 +201,5 @@
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme tab ────────────────────────────────────────────
|
||||
|
||||
.theme-panel {
|
||||
.theme-harmony-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background: rgba($primary-blue, 0.05);
|
||||
border-radius: $radius-md;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,23 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
import { Component, inject, OnInit, signal, ViewEncapsulation } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { Show } from '../interfaces/show';
|
||||
import { Event as StationEvent } from '../interfaces/event';
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
import { HistoryEntry } from '../interfaces/history-entry';
|
||||
import { TeamMember } from '../interfaces/team-member';
|
||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||
import { Underwriter } from '../interfaces/underwriter';
|
||||
import {
|
||||
MobileBuildArtifact,
|
||||
MobileBuildDefaults,
|
||||
MobileBuildRequest,
|
||||
ThemeStatus,
|
||||
} from '../interfaces/mobile-build';
|
||||
import { ShowService } from '../services/show.service';
|
||||
import { EventService } from '../services/event.service';
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { ThemeService } from '../services/theme.service';
|
||||
import { HistoryEntryService } from '../services/history-entry.service';
|
||||
import { TeamMemberService } from '../services/team-member.service';
|
||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
||||
import { UnderwriterService } from '../services/underwriter.service';
|
||||
import { MobileBuildService } from '../services/mobile-build.service';
|
||||
import { AdminShowsTabComponent } from './admin-shows-tab.component';
|
||||
import { AdminEventsTabComponent } from './admin-events-tab.component';
|
||||
import { AdminHistoryTabComponent } from './admin-history-tab.component';
|
||||
import { AdminTeamTabComponent } from './admin-team-tab.component';
|
||||
import { AdminCommunityTabComponent } from './admin-community-tab.component';
|
||||
import { AdminStationTabComponent } from './admin-station-tab.component';
|
||||
import { AdminThemeTabComponent } from './admin-theme-tab.component';
|
||||
import { AdminUnderwritersTabComponent } from './admin-underwriters-tab.component';
|
||||
import { AdminMobileBuildsTabComponent } from './admin-mobile-builds-tab.component';
|
||||
import { AdminShowFormComponent } from './admin-show-form.component';
|
||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||
@@ -33,50 +26,35 @@ import { AdminTeamFormComponent } from './admin-team-form.component';
|
||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
||||
import {
|
||||
COLOR_ROLES,
|
||||
HARMONY_RULE_LABELS,
|
||||
detectHarmonyRule,
|
||||
validatePalette,
|
||||
autoFixPalette,
|
||||
generateLight,
|
||||
generateDark,
|
||||
generateMuted,
|
||||
HarmonyRule,
|
||||
ColorValidation,
|
||||
} from '../utils/color-harmony';
|
||||
import { getAppConfig } from '../services/app-config.service';
|
||||
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'mobile-builds' | 'stats';
|
||||
|
||||
interface ThemeColorState {
|
||||
color_primary: string;
|
||||
color_primary_light: string;
|
||||
color_primary_dark: string;
|
||||
color_primary_muted: string;
|
||||
color_accent: string;
|
||||
color_accent_light: string;
|
||||
color_accent_dark: string;
|
||||
color_accent_muted: string;
|
||||
color_background: string;
|
||||
color_text: string;
|
||||
color_success: string;
|
||||
color_danger: string;
|
||||
color_info: string;
|
||||
color_white: string;
|
||||
color_light: string;
|
||||
color_medium: string;
|
||||
color_black: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
type TabKey =
|
||||
| 'shows'
|
||||
| 'events'
|
||||
| 'history'
|
||||
| 'team'
|
||||
| 'community'
|
||||
| 'station'
|
||||
| 'theme'
|
||||
| 'underwriters'
|
||||
| 'mobile-builds'
|
||||
| 'stats';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
standalone: true,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
AdminShowsTabComponent,
|
||||
AdminEventsTabComponent,
|
||||
AdminHistoryTabComponent,
|
||||
AdminTeamTabComponent,
|
||||
AdminCommunityTabComponent,
|
||||
AdminStationTabComponent,
|
||||
AdminThemeTabComponent,
|
||||
AdminUnderwritersTabComponent,
|
||||
AdminMobileBuildsTabComponent,
|
||||
AdminStatsDashboardComponent,
|
||||
AdminShowFormComponent,
|
||||
AdminEventFormComponent,
|
||||
AdminStationFormComponent,
|
||||
@@ -84,109 +62,25 @@ interface ThemeColorState {
|
||||
AdminTeamFormComponent,
|
||||
AdminCommunityFormComponent,
|
||||
AdminUnderwriterFormComponent,
|
||||
AdminStatsDashboardComponent,
|
||||
],
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrl: './admin.component.scss',
|
||||
})
|
||||
export class AdminComponent implements OnInit {
|
||||
private showService = inject(ShowService);
|
||||
private eventService = inject(EventService);
|
||||
private stationConfigService = inject(StationConfigService);
|
||||
private themeService = inject(ThemeService);
|
||||
private historyEntryService = inject(HistoryEntryService);
|
||||
private teamMemberService = inject(TeamMemberService);
|
||||
private communityHighlightService = inject(CommunityHighlightService);
|
||||
private underwriterService = inject(UnderwriterService);
|
||||
private mobileBuildService = inject(MobileBuildService);
|
||||
|
||||
activeTab = signal<TabKey>('shows');
|
||||
|
||||
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
||||
editingShow: Show | null = null;
|
||||
editingEvent: StationEvent | null = null;
|
||||
editingHistory: HistoryEntry | null = null;
|
||||
editingTeam: TeamMember | null = null;
|
||||
editingCommunity: CommunityHighlight | null = null;
|
||||
editingUnderwriter: Underwriter | null = null;
|
||||
/** Station config for the station form modal. */
|
||||
stationConfig: StationConfig | null = null;
|
||||
|
||||
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
||||
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
||||
readonly events$ = new BehaviorSubject<StationEvent[]>([]);
|
||||
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',
|
||||
};
|
||||
ngOnInit(): void {
|
||||
this.stationConfigService.load().then(() => {
|
||||
this.stationConfig = this.stationConfigService.config();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Form modal state ──────────────────────────────────────
|
||||
showShowForm = false;
|
||||
showEventForm = false;
|
||||
showStationForm = false;
|
||||
@@ -195,286 +89,14 @@ export class AdminComponent implements OnInit {
|
||||
showCommunityForm = false;
|
||||
showUnderwriterForm = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
async reloadShows(): Promise<void> {
|
||||
const data = await firstValueFrom(this.showService.getShows());
|
||||
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 ───────────────────────────────────────────
|
||||
editingShow: Show | null = null;
|
||||
editingEvent: StationEvent | null = null;
|
||||
editingHistory: HistoryEntry | null = null;
|
||||
editingTeam: TeamMember | null = null;
|
||||
editingCommunity: CommunityHighlight | null = null;
|
||||
editingUnderwriter: Underwriter | null = null;
|
||||
|
||||
// ── Show form ─────────────────────────────────────────────
|
||||
openShowForm(): void {
|
||||
this.editingShow = null;
|
||||
this.showShowForm = true;
|
||||
@@ -486,7 +108,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onShowSaved(): void {
|
||||
this.reloadShows();
|
||||
this.showShowForm = false;
|
||||
this.editingShow = null;
|
||||
}
|
||||
@@ -496,14 +117,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingShow = null;
|
||||
}
|
||||
|
||||
async deleteShow(id: number): Promise<void> {
|
||||
if (!confirm('Delete this show?')) return;
|
||||
await firstValueFrom(this.showService.deleteShow(id));
|
||||
await this.reloadShows();
|
||||
}
|
||||
|
||||
// ── Event CRUD ──────────────────────────────────────────
|
||||
|
||||
// ── Event form ────────────────────────────────────────────
|
||||
openEventForm(): void {
|
||||
this.editingEvent = null;
|
||||
this.showEventForm = true;
|
||||
@@ -515,7 +129,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onEventSaved(): void {
|
||||
this.reloadEvents();
|
||||
this.showEventForm = false;
|
||||
this.editingEvent = null;
|
||||
}
|
||||
@@ -525,20 +138,13 @@ export class AdminComponent implements OnInit {
|
||||
this.editingEvent = null;
|
||||
}
|
||||
|
||||
async deleteEvent(id: number): Promise<void> {
|
||||
if (!confirm('Delete this event?')) return;
|
||||
await firstValueFrom(this.eventService.deleteEvent(id));
|
||||
await this.reloadEvents();
|
||||
}
|
||||
|
||||
// ── Station Config ──────────────────────────────────────
|
||||
|
||||
// ── Station form ──────────────────────────────────────────
|
||||
openStationForm(): void {
|
||||
this.showStationForm = true;
|
||||
}
|
||||
|
||||
onStationSaved(): void {
|
||||
this.reloadStationConfig();
|
||||
this.stationConfig = this.stationConfigService.config();
|
||||
this.showStationForm = false;
|
||||
}
|
||||
|
||||
@@ -546,8 +152,7 @@ export class AdminComponent implements OnInit {
|
||||
this.showStationForm = false;
|
||||
}
|
||||
|
||||
// ── History CRUD ────────────────────────────────────────
|
||||
|
||||
// ── History form ──────────────────────────────────────────
|
||||
openHistoryForm(): void {
|
||||
this.editingHistory = null;
|
||||
this.showHistoryForm = true;
|
||||
@@ -559,7 +164,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onHistorySaved(): void {
|
||||
this.reloadHistoryEntries();
|
||||
this.showHistoryForm = false;
|
||||
this.editingHistory = null;
|
||||
}
|
||||
@@ -569,14 +173,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingHistory = null;
|
||||
}
|
||||
|
||||
async deleteHistory(id: number): Promise<void> {
|
||||
if (!confirm('Delete this history entry?')) return;
|
||||
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
|
||||
await this.reloadHistoryEntries();
|
||||
}
|
||||
|
||||
// ── Team CRUD ───────────────────────────────────────────
|
||||
|
||||
// ── Team form ─────────────────────────────────────────────
|
||||
openTeamForm(): void {
|
||||
this.editingTeam = null;
|
||||
this.showTeamForm = true;
|
||||
@@ -588,7 +185,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onTeamSaved(): void {
|
||||
this.reloadTeamMembers();
|
||||
this.showTeamForm = false;
|
||||
this.editingTeam = null;
|
||||
}
|
||||
@@ -598,14 +194,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingTeam = null;
|
||||
}
|
||||
|
||||
async deleteTeam(id: number): Promise<void> {
|
||||
if (!confirm('Delete this team member?')) return;
|
||||
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
|
||||
await this.reloadTeamMembers();
|
||||
}
|
||||
|
||||
// ── Community CRUD ──────────────────────────────────────
|
||||
|
||||
// ── Community form ────────────────────────────────────────
|
||||
openCommunityForm(): void {
|
||||
this.editingCommunity = null;
|
||||
this.showCommunityForm = true;
|
||||
@@ -617,7 +206,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onCommunitySaved(): void {
|
||||
this.reloadCommunityHighlights();
|
||||
this.showCommunityForm = false;
|
||||
this.editingCommunity = null;
|
||||
}
|
||||
@@ -627,14 +215,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingCommunity = null;
|
||||
}
|
||||
|
||||
async deleteCommunity(id: number): Promise<void> {
|
||||
if (!confirm('Delete this community highlight?')) return;
|
||||
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
|
||||
await this.reloadCommunityHighlights();
|
||||
}
|
||||
|
||||
// ── Underwriter CRUD ────────────────────────────────────
|
||||
|
||||
// ── Underwriter form ──────────────────────────────────────
|
||||
openUnderwriterForm(): void {
|
||||
this.editingUnderwriter = null;
|
||||
this.showUnderwriterForm = true;
|
||||
@@ -646,7 +227,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onUnderwriterSaved(): void {
|
||||
this.reloadUnderwriters();
|
||||
this.showUnderwriterForm = false;
|
||||
this.editingUnderwriter = null;
|
||||
}
|
||||
@@ -655,120 +235,4 @@ export class AdminComponent implements OnInit {
|
||||
this.showUnderwriterForm = false;
|
||||
this.editingUnderwriter = null;
|
||||
}
|
||||
|
||||
async deleteUnderwriter(id: number): Promise<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user