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 -->
|
<!-- Tabs -->
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'shows'" (click)="activeTab.set('shows')">Shows</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'shows'" (click)="activeTab.set('shows')">Shows</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'events'"
|
<button class="tab-btn" [class.active]="activeTab() === 'events'" (click)="activeTab.set('events')">Events</button>
|
||||||
(click)="activeTab.set('events')">Events</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'history'" (click)="activeTab.set('history')">History</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'history'"
|
|
||||||
(click)="activeTab.set('history')">History</button>
|
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'team'" (click)="activeTab.set('team')">Team</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'team'" (click)="activeTab.set('team')">Team</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'community'"
|
<button class="tab-btn" [class.active]="activeTab() === 'community'" (click)="activeTab.set('community')">Community</button>
|
||||||
(click)="activeTab.set('community')">Community</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'station'" (click)="activeTab.set('station')">Station</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'station'"
|
<button class="tab-btn" [class.active]="activeTab() === 'theme'" (click)="activeTab.set('theme')">Theme</button>
|
||||||
(click)="activeTab.set('station')">Station</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'" (click)="activeTab.set('underwriters')">Underwriters</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'theme'"
|
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'" (click)="activeTab.set('mobile-builds')">Mobile Builds</button>
|
||||||
(click)="activeTab.set('theme'); onTabChanged()">Theme</button>
|
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'"
|
|
||||||
(click)="activeTab.set('underwriters')">Underwriters</button>
|
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'"
|
|
||||||
(click)="activeTab.set('mobile-builds'); loadThemeJsonStatus()">Mobile Builds</button>
|
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shows Tab -->
|
<!-- Tab content -->
|
||||||
@if (activeTab() === 'shows') {
|
@if (activeTab() === 'shows') {
|
||||||
@if (shows$ | async; as shows) {
|
<app-admin-shows-tab
|
||||||
<div class="tab-content">
|
(openForm)="openShowForm()"
|
||||||
<div class="tab-toolbar">
|
(editItem)="editShow($event)" />
|
||||||
<h2>Shows ({{ shows.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openShowForm()">+ Add Show</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>Genre</th>
|
|
||||||
<th>Slots</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (show of shows; track show.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ show.display_order }}</td>
|
|
||||||
<td>{{ show.title }}</td>
|
|
||||||
<td>{{ show.host }}</td>
|
|
||||||
<td>{{ show.genre }}</td>
|
|
||||||
<td>
|
|
||||||
@for (slot of show.schedules; track slot.id) {
|
|
||||||
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editShow(show)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteShow(show.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Events Tab -->
|
|
||||||
@if (activeTab() === 'events') {
|
@if (activeTab() === 'events') {
|
||||||
@if (events$ | async; as events) {
|
<app-admin-events-tab
|
||||||
<div class="tab-content">
|
(openForm)="openEventForm()"
|
||||||
<div class="tab-toolbar">
|
(editItem)="editEvent($event)" />
|
||||||
<h2>Events ({{ events.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openEventForm()">+ Add Event</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (event of events; track event.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ event.date }}</td>
|
|
||||||
<td>{{ event.title }}</td>
|
|
||||||
<td>{{ event.location }}</td>
|
|
||||||
<td>{{ event.active ? 'Yes' : 'No' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editEvent(event)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteEvent(event.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- History Tab -->
|
|
||||||
@if (activeTab() === 'history') {
|
@if (activeTab() === 'history') {
|
||||||
@if (historyEntries$ | async; as entries) {
|
<app-admin-history-tab
|
||||||
<div class="tab-content">
|
(openForm)="openHistoryForm()"
|
||||||
<div class="tab-toolbar">
|
(editItem)="editHistory($event)" />
|
||||||
<h2>History Timeline ({{ entries.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openHistoryForm()">+ Add Entry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Year</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (entry of entries; track entry.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ entry.year }}</td>
|
|
||||||
<td>{{ entry.title }}</td>
|
|
||||||
<td>{{ entry.display_order }}</td>
|
|
||||||
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editHistory(entry)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteHistory(entry.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Team Tab -->
|
|
||||||
@if (activeTab() === 'team') {
|
@if (activeTab() === 'team') {
|
||||||
@if (teamMembers$ | async; as members) {
|
<app-admin-team-tab
|
||||||
<div class="tab-content">
|
(openForm)="openTeamForm()"
|
||||||
<div class="tab-toolbar">
|
(editItem)="editTeam($event)" />
|
||||||
<h2>Team Members ({{ members.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openTeamForm()">+ Add Member</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (member of members; track member.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ member.name }}</td>
|
|
||||||
<td>{{ member.title }}</td>
|
|
||||||
<td>{{ member.display_order }}</td>
|
|
||||||
<td>{{ member.active ? 'Yes' : 'No' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editTeam(member)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteTeam(member.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Community Tab -->
|
|
||||||
@if (activeTab() === 'community') {
|
@if (activeTab() === 'community') {
|
||||||
@if (communityHighlights$ | async; as highlights) {
|
<app-admin-community-tab
|
||||||
<div class="tab-content">
|
(openForm)="openCommunityForm()"
|
||||||
<div class="tab-toolbar">
|
(editItem)="editCommunity($event)" />
|
||||||
<h2>Community Highlights ({{ highlights.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openCommunityForm()">+ Add Highlight</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Icon</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (highlight of highlights; track highlight.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ highlight.icon }}</td>
|
|
||||||
<td>{{ highlight.title }}</td>
|
|
||||||
<td>{{ highlight.display_order }}</td>
|
|
||||||
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editCommunity(highlight)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteCommunity(highlight.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Station Tab -->
|
|
||||||
@if (activeTab() === 'station') {
|
@if (activeTab() === 'station') {
|
||||||
@if (stationConfig$ | async; as config) {
|
<app-admin-station-tab (openForm)="openStationForm()" />
|
||||||
<div class="tab-content">
|
|
||||||
<div class="tab-toolbar">
|
|
||||||
<h2>Station Configuration</h2>
|
|
||||||
<button class="btn btn-add" (click)="openStationForm()">Edit Config</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="station-config-view">
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Callsign</span>
|
|
||||||
<span class="config-value">{{ config.callsign }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Name</span>
|
|
||||||
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Tagline</span>
|
|
||||||
<span class="config-value">{{ config.tagline }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Frequency</span>
|
|
||||||
<span class="config-value">{{ config.frequency }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Founded</span>
|
|
||||||
<span class="config-value">{{ config.founded_year }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Nonprofit</span>
|
|
||||||
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Email</span>
|
|
||||||
<span class="config-value">{{ config.email }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Phone</span>
|
|
||||||
<span class="config-value">{{ config.phone }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Website</span>
|
|
||||||
<span class="config-value">{{ config.website }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Donation URL</span>
|
|
||||||
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">Play Store URL</span>
|
|
||||||
<span class="config-value">{{ config.play_store_url || '(not set)' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<span class="config-label">App Store Embed</span>
|
|
||||||
<span class="config-value">{{ config.app_store_embed_html ? 'Configured' : '(not set)' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Theme Tab -->
|
|
||||||
@if (activeTab() === 'theme') {
|
@if (activeTab() === 'theme') {
|
||||||
<div class="tab-content theme-panel">
|
<app-admin-theme-tab />
|
||||||
<div class="tab-toolbar">
|
|
||||||
<h2>Color Theme</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-harmony-bar">
|
|
||||||
<div class="harmony-info">
|
|
||||||
<span class="harmony-label">Harmony:</span>
|
|
||||||
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
|
|
||||||
</div>
|
|
||||||
<label class="auto-variants-toggle">
|
|
||||||
<input type="checkbox" [(ngModel)]="themeAutoVariants">
|
|
||||||
Auto-generate variants
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (themeSaveSuccess) {
|
|
||||||
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
|
|
||||||
}
|
|
||||||
@if (themeSaveError) {
|
|
||||||
<div class="theme-message theme-error">{{ themeSaveError }}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Color grid -->
|
|
||||||
<div class="color-grid">
|
|
||||||
@for (role of colorRoles; track role.field) {
|
|
||||||
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
|
|
||||||
<div class="color-swatch" [style.background]="themeColors[role.field]">
|
|
||||||
<input type="color" [value]="themeColors[role.field]"
|
|
||||||
(input)="onThemeColorChange(role.field, $event.target.value)">
|
|
||||||
</div>
|
|
||||||
<div class="color-info">
|
|
||||||
<span class="color-name">{{ role.label }}</span>
|
|
||||||
<span class="color-hex">{{ themeColors[role.field] }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="color-status">
|
|
||||||
@if (hasHarmonyIssue(role.field)) {
|
|
||||||
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct">⚠</span>
|
|
||||||
} @else if (role.group !== 'neutral') {
|
|
||||||
<span class="harmony-ok">✓</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="theme-actions">
|
|
||||||
<button type="button" class="btn btn-outline-theme" (click)="resetTheme()">Reset to Defaults</button>
|
|
||||||
<button type="button" class="btn btn-outline-theme" (click)="autoFixTheme()">Auto-Fix Harmony</button>
|
|
||||||
<button type="button" class="btn btn-save-theme" (click)="saveTheme()" [disabled]="themeSaving">
|
|
||||||
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Underwriters Tab -->
|
|
||||||
@if (activeTab() === 'underwriters') {
|
@if (activeTab() === 'underwriters') {
|
||||||
@if (underwriters$ | async; as underwriters) {
|
<app-admin-underwriters-tab
|
||||||
<div class="tab-content">
|
(openForm)="openUnderwriterForm()"
|
||||||
<div class="tab-toolbar">
|
(editItem)="editUnderwriter($event)" />
|
||||||
<h2>Underwriters ({{ underwriters.length }})</h2>
|
|
||||||
<button class="btn btn-add" (click)="openUnderwriterForm()">+ Add Underwriter</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Website</th>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (uw of underwriters; track uw.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ uw.name }}</td>
|
|
||||||
<td class="description-cell">{{ uw.description }}</td>
|
|
||||||
<td>
|
|
||||||
@if (uw.website_url) {
|
|
||||||
<a [href]="uw.website_url" target="_blank" rel="noopener">{{ uw.website_url }}</a>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ uw.display_order }}</td>
|
|
||||||
<td>{{ uw.active ? 'Yes' : 'No' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="editUnderwriter(uw)">Edit</button>
|
|
||||||
<button class="btn-icon btn-delete" (click)="deleteUnderwriter(uw.id)">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Mobile Builds Tab -->
|
|
||||||
@if (activeTab() === 'mobile-builds') {
|
@if (activeTab() === 'mobile-builds') {
|
||||||
<div class="tab-content mobile-builds-panel">
|
<app-admin-mobile-builds-tab />
|
||||||
<div class="tab-toolbar">
|
|
||||||
<h2>Mobile Build Orchestration</h2>
|
|
||||||
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-build-grid">
|
|
||||||
<section class="build-card">
|
|
||||||
<h3>Derived Defaults</h3>
|
|
||||||
@if (mobileBuildDefaults; as defaults) {
|
|
||||||
<div class="derived-list">
|
|
||||||
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
|
|
||||||
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
|
|
||||||
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
|
|
||||||
<div><strong>Website:</strong> {{ defaults.website }}</div>
|
|
||||||
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<p class="empty-row">Defaults unavailable. Check station config.</p>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="build-card">
|
|
||||||
<h3>Theme Export</h3>
|
|
||||||
@if (themeJsonStatus$ | async; as status) {
|
|
||||||
@if (status.exists) {
|
|
||||||
<div class="theme-status-ok">
|
|
||||||
<div><strong>theme.json</strong> — ready</div>
|
|
||||||
<div>Path: {{ status.path }}</div>
|
|
||||||
<div>Generated: {{ status.generated_at }}</div>
|
|
||||||
<div>Size: {{ status.size }} bytes</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="theme-status-missing">
|
|
||||||
<p><strong>theme.json</strong> not found at {{ status.path }}</p>
|
|
||||||
<p>Regenerate to create it from the current station config.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button class="btn btn-outline" (click)="regenerateThemeJson()"
|
|
||||||
[disabled]="themeJsonLoading$ | async">
|
|
||||||
{{ (themeJsonLoading$ | async) ? 'Regenerating...' : 'Regenerate' }}
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
@if (themeJsonLoading$ | async) {
|
|
||||||
<p class="empty-row">Loading...</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="build-card">
|
|
||||||
<h3>Create Build Request</h3>
|
|
||||||
|
|
||||||
<div class="form-row platform-row">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" [(ngModel)]="buildAndroid">
|
|
||||||
Android
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" [(ngModel)]="buildIos">
|
|
||||||
iOS
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
Android Application ID
|
|
||||||
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
iOS Bundle ID
|
|
||||||
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
Version Name
|
|
||||||
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Build Number
|
|
||||||
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Release Profile
|
|
||||||
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4>Required Files</h4>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
Android Keystore (.jks/.keystore)
|
|
||||||
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Android Descriptor (JSON)
|
|
||||||
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
iOS Certificate (.p12)
|
|
||||||
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
iOS Provisioning Profile (.mobileprovision)
|
|
||||||
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (mobileBuildStatusMessage) {
|
|
||||||
<p class="status success">{{ mobileBuildStatusMessage }}</p>
|
|
||||||
}
|
|
||||||
@if (mobileBuildErrorMessage) {
|
|
||||||
<p class="status error">{{ mobileBuildErrorMessage }}</p>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<button class="btn btn-add" [disabled]="mobileBuildBusy" (click)="createAndUploadMobileBuildRequest()">
|
|
||||||
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="build-card">
|
|
||||||
<h3>Build Requests</h3>
|
|
||||||
@if (mobileBuilds$ | async; as builds) {
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Platforms</th>
|
|
||||||
<th>Version</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Preflight</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (build of builds; track build.id) {
|
|
||||||
<tr [class.selected-build]="selectedMobileBuildId === build.id">
|
|
||||||
<td>#{{ build.id }}</td>
|
|
||||||
<td>
|
|
||||||
{{ build.platform_android ? 'Android' : '' }}
|
|
||||||
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
|
|
||||||
{{ build.platform_ios ? 'iOS' : '' }}
|
|
||||||
</td>
|
|
||||||
<td>{{ build.version_name }} ({{ build.build_number }})</td>
|
|
||||||
<td>{{ build.status }}</td>
|
|
||||||
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="empty-row">No build requests yet.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (selectedMobileBuild; as build) {
|
|
||||||
<section class="build-card">
|
|
||||||
<div class="tab-toolbar compact">
|
|
||||||
<h3>Selected Request #{{ build.id }}</h3>
|
|
||||||
<div class="action-group">
|
|
||||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run
|
|
||||||
Preflight</button>
|
|
||||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed"
|
|
||||||
(click)="triggerSelectedBuild()">Trigger Build</button>
|
|
||||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy"
|
|
||||||
(click)="refreshSelectedBuild()">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (mobileBuildPreflightIssues.length > 0) {
|
|
||||||
<div class="status error">
|
|
||||||
<strong>Preflight issues:</strong>
|
|
||||||
<ul>
|
|
||||||
@for (issue of mobileBuildPreflightIssues; track issue) {
|
|
||||||
<li>{{ issue }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (build.error_message) {
|
|
||||||
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<h4>Artifacts</h4>
|
|
||||||
<div class="artifact-list">
|
|
||||||
@for (artifact of build.artifacts; track artifact.id) {
|
|
||||||
<div class="artifact-row">
|
|
||||||
<div>
|
|
||||||
<strong>{{ artifact.file_name }}</strong>
|
|
||||||
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
|
|
||||||
artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-icon btn-edit"
|
|
||||||
(click)="downloadArtifact(artifact)">
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<p class="empty-row">No artifacts available yet.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4>Build Log</h4>
|
|
||||||
<div class="build-log"><pre>{{ build.build_log || 'No logs yet.' }}</pre></div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Stats Tab -->
|
|
||||||
@if (activeTab() === 'stats') {
|
@if (activeTab() === 'stats') {
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<app-admin-stats-dashboard />
|
<app-admin-stats-dashboard />
|
||||||
@@ -651,7 +83,7 @@
|
|||||||
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
||||||
}
|
}
|
||||||
@if (showStationForm) {
|
@if (showStationForm) {
|
||||||
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
<app-admin-station-form [editItem]="stationConfig" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
||||||
}
|
}
|
||||||
@if (showHistoryForm) {
|
@if (showHistoryForm) {
|
||||||
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
|
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
|
||||||
@@ -663,7 +95,6 @@
|
|||||||
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
|
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
|
||||||
}
|
}
|
||||||
@if (showUnderwriterForm) {
|
@if (showUnderwriterForm) {
|
||||||
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()"
|
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" />
|
||||||
(closed)="onUnderwriterClosed()" />
|
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
min-height: calc(100vh - 80px);
|
min-height: calc(100vh - 80px);
|
||||||
background: $neutral-cream;
|
background: $neutral-cream;
|
||||||
padding: $spacing-xl 0;
|
padding: $spacing-xl 0;
|
||||||
}
|
|
||||||
|
// ── Header ─────────────────────────────────────────────
|
||||||
|
|
||||||
.admin-header {
|
.admin-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -24,13 +25,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tabs ─────────────────────────────────────────────────
|
// ── Tabs ───────────────────────────────────────────────
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
@@ -56,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab content ──────────────────────────────────────────
|
// ── Tab content ────────────────────────────────────────
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
@include card-style;
|
@include card-style;
|
||||||
@@ -79,7 +81,7 @@
|
|||||||
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Table ────────────────────────────────────────────────
|
// ── Table ──────────────────────────────────────────────
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -103,13 +105,6 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.benefits-cell {
|
|
||||||
max-width: 300px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-badge {
|
.slot-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: rgba($primary-blue, 0.1);
|
background: rgba($primary-blue, 0.1);
|
||||||
@@ -162,14 +157,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-message {
|
// ── Station config view ────────────────────────────────
|
||||||
text-align: center;
|
|
||||||
padding: $spacing-3xl;
|
|
||||||
color: $neutral-medium;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Station config view ──────────────────────────────────
|
|
||||||
|
|
||||||
.station-config-view {
|
.station-config-view {
|
||||||
.config-row {
|
.config-row {
|
||||||
@@ -196,7 +184,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Responsive ───────────────────────────────────────────
|
// ── Responsive ─────────────────────────────────────────
|
||||||
|
|
||||||
@include responsive(md) {
|
@include responsive(md) {
|
||||||
.admin-table {
|
.admin-table {
|
||||||
@@ -214,399 +202,4 @@
|
|||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Theme tab ────────────────────────────────────────────
|
|
||||||
|
|
||||||
.theme-panel {
|
|
||||||
.theme-harmony-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: $spacing-md;
|
|
||||||
background: rgba($primary-blue, 0.05);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
|
|
||||||
.harmony-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
|
|
||||||
.harmony-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: $neutral-medium;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harmony-rule {
|
|
||||||
color: $primary-blue;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-variants-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $neutral-dark;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-message {
|
|
||||||
padding: $spacing-sm $spacing-md;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&.theme-success {
|
|
||||||
background: rgba($success-green, 0.1);
|
|
||||||
color: $success-green;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.theme-error {
|
|
||||||
background: rgba($danger-red, 0.1);
|
|
||||||
color: $danger-red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color grid
|
|
||||||
.color-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: $spacing-md;
|
|
||||||
margin-bottom: $spacing-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
padding: $spacing-sm $spacing-md;
|
|
||||||
background: $neutral-white;
|
|
||||||
border: 2px solid $neutral-light;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
|
||||||
|
|
||||||
&.has-issue {
|
|
||||||
border-color: #e6a817;
|
|
||||||
box-shadow: 0 0 0 1px rgba(230, 168, 23, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $primary-blue-muted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-swatch {
|
|
||||||
position: relative;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
|
||||||
transition: transform $transition-fast;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="color"] {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
.color-name {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $neutral-dark;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-hex {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $neutral-medium;
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-status {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.harmony-ok {
|
|
||||||
color: $success-green;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harmony-warn {
|
|
||||||
color: #e6a817;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme actions
|
|
||||||
.theme-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding-top: $spacing-md;
|
|
||||||
border-top: 1px solid $neutral-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save-theme {
|
|
||||||
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-theme {
|
|
||||||
background: transparent;
|
|
||||||
border: 2px solid $primary-blue;
|
|
||||||
color: $primary-blue;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
padding: $spacing-sm $spacing-lg;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background $transition-fast, color $transition-fast;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $primary-blue;
|
|
||||||
color: $neutral-white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mobile builds tab ───────────────────────────────────
|
|
||||||
|
|
||||||
.mobile-builds-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: $spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-build-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.build-card {
|
|
||||||
background: $neutral-white;
|
|
||||||
border: 1px solid $neutral-light;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
padding: $spacing-md;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
color: $primary-blue;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.derived-list {
|
|
||||||
display: grid;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: $spacing-sm;
|
|
||||||
margin-bottom: $spacing-sm;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: $neutral-dark;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='text'],
|
|
||||||
input[type='file'] {
|
|
||||||
border: 1px solid $neutral-light;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
padding: $spacing-xs $spacing-sm;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-row {
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
|
|
||||||
label {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
padding: $spacing-sm;
|
|
||||||
margin: $spacing-sm 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
background: rgba($success-green, 0.1);
|
|
||||||
color: $success-green;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
background: rgba($danger-red, 0.1);
|
|
||||||
color: $danger-red;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: $spacing-xs 0 0 $spacing-md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-build {
|
|
||||||
background: rgba($primary-blue, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-toolbar.compact {
|
|
||||||
margin-bottom: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-group {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifact-list {
|
|
||||||
display: grid;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifact-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid $neutral-light;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
padding: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifact-meta {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: $neutral-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.build-log {
|
|
||||||
background: $neutral-black;
|
|
||||||
color: $neutral-white;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
padding: $spacing-sm;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
pre {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Theme export status ──────────────────────────────────
|
|
||||||
|
|
||||||
.theme-status-ok,
|
|
||||||
.theme-status-missing {
|
|
||||||
padding: $spacing-sm $spacing-md;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
margin-bottom: $spacing-sm;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: $neutral-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-status-ok {
|
|
||||||
background: rgba($success-green, 0.08);
|
|
||||||
border: 1px solid rgba($success-green, 0.25);
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-status-missing p {
|
|
||||||
margin: 0 0 $spacing-xs;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-status-missing {
|
|
||||||
background: rgba($accent-orange, 0.08);
|
|
||||||
border: 1px solid rgba($accent-orange, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
border: 2px solid $primary-blue;
|
|
||||||
color: $primary-blue;
|
|
||||||
border-radius: $radius-md;
|
|
||||||
padding: $spacing-xs $spacing-md;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background $transition-fast, color $transition-fast;
|
|
||||||
margin-top: $spacing-sm;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: $primary-blue;
|
|
||||||
color: $neutral-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include responsive(md) {
|
|
||||||
.mobile-build-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-group {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,23 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, OnInit, signal, ViewEncapsulation } from '@angular/core';
|
||||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|
||||||
|
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { Event as StationEvent } from '../interfaces/event';
|
import { Event as StationEvent } from '../interfaces/event';
|
||||||
import { StationConfig } from '../interfaces/station-config';
|
|
||||||
import { HistoryEntry } from '../interfaces/history-entry';
|
import { HistoryEntry } from '../interfaces/history-entry';
|
||||||
import { TeamMember } from '../interfaces/team-member';
|
import { TeamMember } from '../interfaces/team-member';
|
||||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||||
import { Underwriter } from '../interfaces/underwriter';
|
import { Underwriter } from '../interfaces/underwriter';
|
||||||
import {
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
MobileBuildArtifact,
|
|
||||||
MobileBuildDefaults,
|
|
||||||
MobileBuildRequest,
|
|
||||||
ThemeStatus,
|
|
||||||
} from '../interfaces/mobile-build';
|
|
||||||
import { ShowService } from '../services/show.service';
|
|
||||||
import { EventService } from '../services/event.service';
|
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { ThemeService } from '../services/theme.service';
|
import { AdminShowsTabComponent } from './admin-shows-tab.component';
|
||||||
import { HistoryEntryService } from '../services/history-entry.service';
|
import { AdminEventsTabComponent } from './admin-events-tab.component';
|
||||||
import { TeamMemberService } from '../services/team-member.service';
|
import { AdminHistoryTabComponent } from './admin-history-tab.component';
|
||||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
import { AdminTeamTabComponent } from './admin-team-tab.component';
|
||||||
import { UnderwriterService } from '../services/underwriter.service';
|
import { AdminCommunityTabComponent } from './admin-community-tab.component';
|
||||||
import { MobileBuildService } from '../services/mobile-build.service';
|
import { AdminStationTabComponent } from './admin-station-tab.component';
|
||||||
|
import { AdminThemeTabComponent } from './admin-theme-tab.component';
|
||||||
|
import { AdminUnderwritersTabComponent } from './admin-underwriters-tab.component';
|
||||||
|
import { AdminMobileBuildsTabComponent } from './admin-mobile-builds-tab.component';
|
||||||
import { AdminShowFormComponent } from './admin-show-form.component';
|
import { AdminShowFormComponent } from './admin-show-form.component';
|
||||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||||
import { AdminStationFormComponent } from './admin-station-form.component';
|
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||||
@@ -33,50 +26,35 @@ import { AdminTeamFormComponent } from './admin-team-form.component';
|
|||||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
||||||
import {
|
|
||||||
COLOR_ROLES,
|
|
||||||
HARMONY_RULE_LABELS,
|
|
||||||
detectHarmonyRule,
|
|
||||||
validatePalette,
|
|
||||||
autoFixPalette,
|
|
||||||
generateLight,
|
|
||||||
generateDark,
|
|
||||||
generateMuted,
|
|
||||||
HarmonyRule,
|
|
||||||
ColorValidation,
|
|
||||||
} from '../utils/color-harmony';
|
|
||||||
import { getAppConfig } from '../services/app-config.service';
|
|
||||||
|
|
||||||
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'mobile-builds' | 'stats';
|
type TabKey =
|
||||||
|
| 'shows'
|
||||||
interface ThemeColorState {
|
| 'events'
|
||||||
color_primary: string;
|
| 'history'
|
||||||
color_primary_light: string;
|
| 'team'
|
||||||
color_primary_dark: string;
|
| 'community'
|
||||||
color_primary_muted: string;
|
| 'station'
|
||||||
color_accent: string;
|
| 'theme'
|
||||||
color_accent_light: string;
|
| 'underwriters'
|
||||||
color_accent_dark: string;
|
| 'mobile-builds'
|
||||||
color_accent_muted: string;
|
| 'stats';
|
||||||
color_background: string;
|
|
||||||
color_text: string;
|
|
||||||
color_success: string;
|
|
||||||
color_danger: string;
|
|
||||||
color_info: string;
|
|
||||||
color_white: string;
|
|
||||||
color_light: string;
|
|
||||||
color_medium: string;
|
|
||||||
color_black: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
AsyncPipe,
|
AdminShowsTabComponent,
|
||||||
FormsModule,
|
AdminEventsTabComponent,
|
||||||
|
AdminHistoryTabComponent,
|
||||||
|
AdminTeamTabComponent,
|
||||||
|
AdminCommunityTabComponent,
|
||||||
|
AdminStationTabComponent,
|
||||||
|
AdminThemeTabComponent,
|
||||||
|
AdminUnderwritersTabComponent,
|
||||||
|
AdminMobileBuildsTabComponent,
|
||||||
|
AdminStatsDashboardComponent,
|
||||||
AdminShowFormComponent,
|
AdminShowFormComponent,
|
||||||
AdminEventFormComponent,
|
AdminEventFormComponent,
|
||||||
AdminStationFormComponent,
|
AdminStationFormComponent,
|
||||||
@@ -84,109 +62,25 @@ interface ThemeColorState {
|
|||||||
AdminTeamFormComponent,
|
AdminTeamFormComponent,
|
||||||
AdminCommunityFormComponent,
|
AdminCommunityFormComponent,
|
||||||
AdminUnderwriterFormComponent,
|
AdminUnderwriterFormComponent,
|
||||||
AdminStatsDashboardComponent,
|
|
||||||
],
|
],
|
||||||
templateUrl: './admin.component.html',
|
templateUrl: './admin.component.html',
|
||||||
styleUrl: './admin.component.scss',
|
styleUrl: './admin.component.scss',
|
||||||
})
|
})
|
||||||
export class AdminComponent implements OnInit {
|
export class AdminComponent implements OnInit {
|
||||||
private showService = inject(ShowService);
|
|
||||||
private eventService = inject(EventService);
|
|
||||||
private stationConfigService = inject(StationConfigService);
|
private stationConfigService = inject(StationConfigService);
|
||||||
private themeService = inject(ThemeService);
|
|
||||||
private historyEntryService = inject(HistoryEntryService);
|
|
||||||
private teamMemberService = inject(TeamMemberService);
|
|
||||||
private communityHighlightService = inject(CommunityHighlightService);
|
|
||||||
private underwriterService = inject(UnderwriterService);
|
|
||||||
private mobileBuildService = inject(MobileBuildService);
|
|
||||||
|
|
||||||
activeTab = signal<TabKey>('shows');
|
activeTab = signal<TabKey>('shows');
|
||||||
|
|
||||||
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
/** Station config for the station form modal. */
|
||||||
editingShow: Show | null = null;
|
stationConfig: StationConfig | null = null;
|
||||||
editingEvent: StationEvent | null = null;
|
|
||||||
editingHistory: HistoryEntry | null = null;
|
|
||||||
editingTeam: TeamMember | null = null;
|
|
||||||
editingCommunity: CommunityHighlight | null = null;
|
|
||||||
editingUnderwriter: Underwriter | null = null;
|
|
||||||
|
|
||||||
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
ngOnInit(): void {
|
||||||
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
this.stationConfigService.load().then(() => {
|
||||||
readonly events$ = new BehaviorSubject<StationEvent[]>([]);
|
this.stationConfig = this.stationConfigService.config();
|
||||||
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
});
|
||||||
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
}
|
||||||
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
|
||||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
|
||||||
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
|
||||||
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
|
|
||||||
|
|
||||||
mobileBuildDefaults: MobileBuildDefaults | null = null;
|
|
||||||
selectedMobileBuildId: number | null = null;
|
|
||||||
selectedMobileBuild: MobileBuildRequest | null = null;
|
|
||||||
mobileBuildStatusMessage = '';
|
|
||||||
mobileBuildErrorMessage = '';
|
|
||||||
mobileBuildPreflightIssues: string[] = [];
|
|
||||||
mobileBuildBusy = false;
|
|
||||||
// ── Theme JSON status (mobile build tab) ────────────────────
|
|
||||||
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
|
|
||||||
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(false);
|
|
||||||
|
|
||||||
buildAndroid = true;
|
|
||||||
buildIos = true;
|
|
||||||
androidApplicationId = '';
|
|
||||||
iosBundleId = '';
|
|
||||||
buildVersionName = '1.0.0';
|
|
||||||
buildNumber = '1';
|
|
||||||
buildReleaseProfile = 'release';
|
|
||||||
|
|
||||||
androidKeystoreFile: File | null = null;
|
|
||||||
androidDescriptorFile: File | null = null;
|
|
||||||
iosCertificateFile: File | null = null;
|
|
||||||
iosProfileFile: File | null = null;
|
|
||||||
|
|
||||||
// ── Theme tab state ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Local editable color palette (copied from StationConfig on tab open). */
|
|
||||||
themeColors: ThemeColorState = {} as ThemeColorState;
|
|
||||||
|
|
||||||
/** Current harmony rule detected from the palette. */
|
|
||||||
themeHarmonyRule: HarmonyRule = 'none';
|
|
||||||
|
|
||||||
/** Validation results per color field. */
|
|
||||||
themeValidation: { [key: string]: ColorValidation } = {};
|
|
||||||
|
|
||||||
/** Whether auto-generate variants is enabled. */
|
|
||||||
themeAutoVariants = true;
|
|
||||||
|
|
||||||
/** Saving state for theme tab. */
|
|
||||||
themeSaving = false;
|
|
||||||
themeSaveSuccess = '';
|
|
||||||
themeSaveError = '';
|
|
||||||
|
|
||||||
/** Color role definitions for the template. */
|
|
||||||
readonly colorRoles = COLOR_ROLES;
|
|
||||||
|
|
||||||
/** Default palette for reset. */
|
|
||||||
readonly themeDefaults: ThemeColorState = {
|
|
||||||
color_primary: '#1a3a5c',
|
|
||||||
color_primary_light: '#2a5a8c',
|
|
||||||
color_primary_dark: '#0e2440',
|
|
||||||
color_primary_muted: '#3a6a9c',
|
|
||||||
color_accent: '#e87a2e',
|
|
||||||
color_accent_light: '#f09a4e',
|
|
||||||
color_accent_dark: '#c85a1e',
|
|
||||||
color_accent_muted: '#f0a86a',
|
|
||||||
color_background: '#faf6f0',
|
|
||||||
color_text: '#3a3632',
|
|
||||||
color_success: '#4a9e4f',
|
|
||||||
color_danger: '#c83030',
|
|
||||||
color_info: '#3a8abf',
|
|
||||||
color_white: '#ffffff',
|
|
||||||
color_light: '#f0ebe3',
|
|
||||||
color_medium: '#8a8580',
|
|
||||||
color_black: '#1a1816',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// ── Form modal state ──────────────────────────────────────
|
||||||
showShowForm = false;
|
showShowForm = false;
|
||||||
showEventForm = false;
|
showEventForm = false;
|
||||||
showStationForm = false;
|
showStationForm = false;
|
||||||
@@ -195,286 +89,14 @@ export class AdminComponent implements OnInit {
|
|||||||
showCommunityForm = false;
|
showCommunityForm = false;
|
||||||
showUnderwriterForm = false;
|
showUnderwriterForm = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
editingShow: Show | null = null;
|
||||||
this.loadAll();
|
editingEvent: StationEvent | null = null;
|
||||||
}
|
editingHistory: HistoryEntry | null = null;
|
||||||
|
editingTeam: TeamMember | null = null;
|
||||||
async reloadShows(): Promise<void> {
|
editingCommunity: CommunityHighlight | null = null;
|
||||||
const data = await firstValueFrom(this.showService.getShows());
|
editingUnderwriter: Underwriter | null = null;
|
||||||
this.shows$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadEvents(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.eventService.getEvents());
|
|
||||||
this.events$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadStationConfig(): Promise<void> {
|
|
||||||
await this.stationConfigService.load();
|
|
||||||
this.stationConfig$.next(this.stationConfigService.config());
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadHistoryEntries(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.historyEntryService.getHistoryEntries());
|
|
||||||
this.historyEntries$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadTeamMembers(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.teamMemberService.getTeamMembers());
|
|
||||||
this.teamMembers$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadCommunityHighlights(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
|
|
||||||
this.communityHighlights$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadUnderwriters(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.underwriterService.getUnderwriters());
|
|
||||||
this.underwriters$.next(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadAll(): Promise<void> {
|
|
||||||
await Promise.all([
|
|
||||||
this.reloadShows(),
|
|
||||||
this.reloadEvents(),
|
|
||||||
this.reloadStationConfig(),
|
|
||||||
this.reloadHistoryEntries(),
|
|
||||||
this.reloadTeamMembers(),
|
|
||||||
this.reloadCommunityHighlights(),
|
|
||||||
this.reloadUnderwriters(),
|
|
||||||
this.reloadMobileBuildDefaults(),
|
|
||||||
this.reloadMobileBuilds(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadMobileBuildDefaults(): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
|
|
||||||
} catch {
|
|
||||||
this.mobileBuildDefaults = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadMobileBuilds(): Promise<void> {
|
|
||||||
const data = await firstValueFrom(this.mobileBuildService.listRequests());
|
|
||||||
this.mobileBuilds$.next(data);
|
|
||||||
|
|
||||||
if (this.selectedMobileBuildId !== null) {
|
|
||||||
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
|
|
||||||
this.selectedMobileBuild = selected;
|
|
||||||
if (!selected) {
|
|
||||||
this.selectedMobileBuildId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMobileBuildSelected(buildId: number): void {
|
|
||||||
this.selectedMobileBuildId = buildId;
|
|
||||||
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
|
|
||||||
this.selectedMobileBuild = selected;
|
|
||||||
this.mobileBuildPreflightIssues = selected?.preflight_report
|
|
||||||
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files && input.files.length > 0 ? input.files[0] : null;
|
|
||||||
|
|
||||||
if (target === 'android-keystore') this.androidKeystoreFile = file;
|
|
||||||
if (target === 'android-descriptor') this.androidDescriptorFile = file;
|
|
||||||
if (target === 'ios-certificate') this.iosCertificateFile = file;
|
|
||||||
if (target === 'ios-profile') this.iosProfileFile = file;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getErrorMessage(err: unknown, fallback: string): string {
|
|
||||||
if (typeof err === 'object' && err !== null) {
|
|
||||||
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
|
|
||||||
const detail = maybeErr.error?.detail;
|
|
||||||
if (typeof detail === 'string') return detail;
|
|
||||||
if (Array.isArray(detail)) return detail.join(' | ');
|
|
||||||
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
|
|
||||||
if (typeof maybeErr.message === 'string') return maybeErr.message;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetMobileBuildMessages(): void {
|
|
||||||
this.mobileBuildStatusMessage = '';
|
|
||||||
this.mobileBuildErrorMessage = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateBuildInputs(): string | null {
|
|
||||||
if (!this.buildAndroid && !this.buildIos) {
|
|
||||||
return 'Select at least one platform.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buildAndroid && !this.androidApplicationId.trim()) {
|
|
||||||
return 'Android application ID is required.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buildIos && !this.iosBundleId.trim()) {
|
|
||||||
return 'iOS bundle ID is required.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
|
|
||||||
return 'Version name and build number are required.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
|
|
||||||
return 'Android requires both keystore and descriptor files.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
|
|
||||||
return 'iOS requires both certificate and provisioning profile files.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createAndUploadMobileBuildRequest(): Promise<void> {
|
|
||||||
this.resetMobileBuildMessages();
|
|
||||||
this.mobileBuildPreflightIssues = [];
|
|
||||||
const inputError = this.validateBuildInputs();
|
|
||||||
if (inputError) {
|
|
||||||
this.mobileBuildErrorMessage = inputError;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mobileBuildBusy = true;
|
|
||||||
try {
|
|
||||||
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
|
||||||
platform_android: this.buildAndroid,
|
|
||||||
platform_ios: this.buildIos,
|
|
||||||
android_application_id: this.androidApplicationId.trim() || undefined,
|
|
||||||
ios_bundle_id: this.iosBundleId.trim() || undefined,
|
|
||||||
version_name: this.buildVersionName.trim(),
|
|
||||||
build_number: this.buildNumber.trim(),
|
|
||||||
release_profile: this.buildReleaseProfile.trim() || 'release',
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
|
||||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
|
||||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
|
|
||||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
|
|
||||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
|
|
||||||
await this.reloadMobileBuilds();
|
|
||||||
this.onMobileBuildSelected(request.id);
|
|
||||||
} catch (err) {
|
|
||||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
|
||||||
} finally {
|
|
||||||
this.mobileBuildBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async runPreflightForSelectedBuild(): Promise<void> {
|
|
||||||
if (!this.selectedMobileBuildId) return;
|
|
||||||
|
|
||||||
this.resetMobileBuildMessages();
|
|
||||||
this.mobileBuildBusy = true;
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
|
|
||||||
this.mobileBuildPreflightIssues = result.issues;
|
|
||||||
this.mobileBuildStatusMessage = result.passed
|
|
||||||
? 'Preflight passed. Build can be triggered.'
|
|
||||||
: 'Preflight failed. Resolve the listed issues.';
|
|
||||||
await this.reloadMobileBuilds();
|
|
||||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
|
||||||
} catch (err) {
|
|
||||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
|
|
||||||
} finally {
|
|
||||||
this.mobileBuildBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async triggerSelectedBuild(): Promise<void> {
|
|
||||||
if (!this.selectedMobileBuildId) return;
|
|
||||||
|
|
||||||
this.resetMobileBuildMessages();
|
|
||||||
this.mobileBuildBusy = true;
|
|
||||||
try {
|
|
||||||
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
|
|
||||||
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
|
|
||||||
await this.reloadMobileBuilds();
|
|
||||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
|
||||||
} catch (err) {
|
|
||||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
|
|
||||||
} finally {
|
|
||||||
this.mobileBuildBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshSelectedBuild(): Promise<void> {
|
|
||||||
if (!this.selectedMobileBuildId) return;
|
|
||||||
await this.reloadMobileBuilds();
|
|
||||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Theme JSON status ──────────────────────────────────────
|
|
||||||
|
|
||||||
async loadThemeJsonStatus(): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.themeJsonLoading$.next(true);
|
|
||||||
this.themeJsonStatus$.next(await firstValueFrom(
|
|
||||||
this.mobileBuildService.getThemeStatus(),
|
|
||||||
));
|
|
||||||
} catch {
|
|
||||||
this.themeJsonStatus$.next(null);
|
|
||||||
} finally {
|
|
||||||
this.themeJsonLoading$.next(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async regenerateThemeJson(): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.themeJsonLoading$.next(true);
|
|
||||||
await firstValueFrom(this.mobileBuildService.regenerateTheme());
|
|
||||||
await this.loadThemeJsonStatus();
|
|
||||||
} catch (err) {
|
|
||||||
this.mobileBuildErrorMessage = this.getErrorMessage(
|
|
||||||
err,
|
|
||||||
'Failed to regenerate theme.json.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
this.themeJsonLoading$.next(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getArtifactDownloadHref(path: string): string {
|
|
||||||
return `${getAppConfig().apiBaseUrl}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
formatFileSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
const size = bytes / Math.pow(1024, i);
|
|
||||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadArtifact(artifact: MobileBuildArtifact): void {
|
|
||||||
this.mobileBuildService
|
|
||||||
.getDownloadUrl(artifact.id)
|
|
||||||
.subscribe({
|
|
||||||
next: (res) => {
|
|
||||||
// Trigger native browser download with the signed URL
|
|
||||||
window.open(res.download_url, '_blank');
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error('Failed to get download URL:', err);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Show CRUD ───────────────────────────────────────────
|
|
||||||
|
|
||||||
|
// ── Show form ─────────────────────────────────────────────
|
||||||
openShowForm(): void {
|
openShowForm(): void {
|
||||||
this.editingShow = null;
|
this.editingShow = null;
|
||||||
this.showShowForm = true;
|
this.showShowForm = true;
|
||||||
@@ -486,7 +108,6 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onShowSaved(): void {
|
onShowSaved(): void {
|
||||||
this.reloadShows();
|
|
||||||
this.showShowForm = false;
|
this.showShowForm = false;
|
||||||
this.editingShow = null;
|
this.editingShow = null;
|
||||||
}
|
}
|
||||||
@@ -496,14 +117,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.editingShow = null;
|
this.editingShow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteShow(id: number): Promise<void> {
|
// ── Event form ────────────────────────────────────────────
|
||||||
if (!confirm('Delete this show?')) return;
|
|
||||||
await firstValueFrom(this.showService.deleteShow(id));
|
|
||||||
await this.reloadShows();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event CRUD ──────────────────────────────────────────
|
|
||||||
|
|
||||||
openEventForm(): void {
|
openEventForm(): void {
|
||||||
this.editingEvent = null;
|
this.editingEvent = null;
|
||||||
this.showEventForm = true;
|
this.showEventForm = true;
|
||||||
@@ -515,7 +129,6 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onEventSaved(): void {
|
onEventSaved(): void {
|
||||||
this.reloadEvents();
|
|
||||||
this.showEventForm = false;
|
this.showEventForm = false;
|
||||||
this.editingEvent = null;
|
this.editingEvent = null;
|
||||||
}
|
}
|
||||||
@@ -525,20 +138,13 @@ export class AdminComponent implements OnInit {
|
|||||||
this.editingEvent = null;
|
this.editingEvent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEvent(id: number): Promise<void> {
|
// ── Station form ──────────────────────────────────────────
|
||||||
if (!confirm('Delete this event?')) return;
|
|
||||||
await firstValueFrom(this.eventService.deleteEvent(id));
|
|
||||||
await this.reloadEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Station Config ──────────────────────────────────────
|
|
||||||
|
|
||||||
openStationForm(): void {
|
openStationForm(): void {
|
||||||
this.showStationForm = true;
|
this.showStationForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onStationSaved(): void {
|
onStationSaved(): void {
|
||||||
this.reloadStationConfig();
|
this.stationConfig = this.stationConfigService.config();
|
||||||
this.showStationForm = false;
|
this.showStationForm = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,8 +152,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.showStationForm = false;
|
this.showStationForm = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── History CRUD ────────────────────────────────────────
|
// ── History form ──────────────────────────────────────────
|
||||||
|
|
||||||
openHistoryForm(): void {
|
openHistoryForm(): void {
|
||||||
this.editingHistory = null;
|
this.editingHistory = null;
|
||||||
this.showHistoryForm = true;
|
this.showHistoryForm = true;
|
||||||
@@ -559,7 +164,6 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onHistorySaved(): void {
|
onHistorySaved(): void {
|
||||||
this.reloadHistoryEntries();
|
|
||||||
this.showHistoryForm = false;
|
this.showHistoryForm = false;
|
||||||
this.editingHistory = null;
|
this.editingHistory = null;
|
||||||
}
|
}
|
||||||
@@ -569,14 +173,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.editingHistory = null;
|
this.editingHistory = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteHistory(id: number): Promise<void> {
|
// ── Team form ─────────────────────────────────────────────
|
||||||
if (!confirm('Delete this history entry?')) return;
|
|
||||||
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
|
|
||||||
await this.reloadHistoryEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Team CRUD ───────────────────────────────────────────
|
|
||||||
|
|
||||||
openTeamForm(): void {
|
openTeamForm(): void {
|
||||||
this.editingTeam = null;
|
this.editingTeam = null;
|
||||||
this.showTeamForm = true;
|
this.showTeamForm = true;
|
||||||
@@ -588,7 +185,6 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onTeamSaved(): void {
|
onTeamSaved(): void {
|
||||||
this.reloadTeamMembers();
|
|
||||||
this.showTeamForm = false;
|
this.showTeamForm = false;
|
||||||
this.editingTeam = null;
|
this.editingTeam = null;
|
||||||
}
|
}
|
||||||
@@ -598,14 +194,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.editingTeam = null;
|
this.editingTeam = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTeam(id: number): Promise<void> {
|
// ── Community form ────────────────────────────────────────
|
||||||
if (!confirm('Delete this team member?')) return;
|
|
||||||
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
|
|
||||||
await this.reloadTeamMembers();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Community CRUD ──────────────────────────────────────
|
|
||||||
|
|
||||||
openCommunityForm(): void {
|
openCommunityForm(): void {
|
||||||
this.editingCommunity = null;
|
this.editingCommunity = null;
|
||||||
this.showCommunityForm = true;
|
this.showCommunityForm = true;
|
||||||
@@ -617,7 +206,6 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCommunitySaved(): void {
|
onCommunitySaved(): void {
|
||||||
this.reloadCommunityHighlights();
|
|
||||||
this.showCommunityForm = false;
|
this.showCommunityForm = false;
|
||||||
this.editingCommunity = null;
|
this.editingCommunity = null;
|
||||||
}
|
}
|
||||||
@@ -627,14 +215,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.editingCommunity = null;
|
this.editingCommunity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCommunity(id: number): Promise<void> {
|
// ── Underwriter form ──────────────────────────────────────
|
||||||
if (!confirm('Delete this community highlight?')) return;
|
|
||||||
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
|
|
||||||
await this.reloadCommunityHighlights();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Underwriter CRUD ────────────────────────────────────
|
|
||||||
|
|
||||||
openUnderwriterForm(): void {
|
openUnderwriterForm(): void {
|
||||||
this.editingUnderwriter = null;
|
this.editingUnderwriter = null;
|
||||||
this.showUnderwriterForm = true;
|
this.showUnderwriterForm = true;
|
||||||
@@ -646,7 +227,6 @@ export class AdminComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUnderwriterSaved(): void {
|
onUnderwriterSaved(): void {
|
||||||
this.reloadUnderwriters();
|
|
||||||
this.showUnderwriterForm = false;
|
this.showUnderwriterForm = false;
|
||||||
this.editingUnderwriter = null;
|
this.editingUnderwriter = null;
|
||||||
}
|
}
|
||||||
@@ -655,120 +235,4 @@ export class AdminComponent implements OnInit {
|
|||||||
this.showUnderwriterForm = false;
|
this.showUnderwriterForm = false;
|
||||||
this.editingUnderwriter = null;
|
this.editingUnderwriter = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUnderwriter(id: number): Promise<void> {
|
|
||||||
if (!confirm('Delete this underwriter?')) return;
|
|
||||||
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
|
|
||||||
await this.reloadUnderwriters();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Theme tab ────────────────────────────────────────────
|
|
||||||
|
|
||||||
onTabChanged(): void {
|
|
||||||
if (this.activeTab() === 'theme') {
|
|
||||||
this.loadThemeColors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadThemeColors(): void {
|
|
||||||
const config = this.stationConfigService.config();
|
|
||||||
this.themeColors = {
|
|
||||||
color_primary: config.color_primary || this.themeDefaults.color_primary,
|
|
||||||
color_primary_light: config.color_primary_light || this.themeDefaults.color_primary_light,
|
|
||||||
color_primary_dark: config.color_primary_dark || this.themeDefaults.color_primary_dark,
|
|
||||||
color_primary_muted: config.color_primary_muted || this.themeDefaults.color_primary_muted,
|
|
||||||
color_accent: config.color_accent || this.themeDefaults.color_accent,
|
|
||||||
color_accent_light: config.color_accent_light || this.themeDefaults.color_accent_light,
|
|
||||||
color_accent_dark: config.color_accent_dark || this.themeDefaults.color_accent_dark,
|
|
||||||
color_accent_muted: config.color_accent_muted || this.themeDefaults.color_accent_muted,
|
|
||||||
color_background: config.color_background || this.themeDefaults.color_background,
|
|
||||||
color_text: config.color_text || this.themeDefaults.color_text,
|
|
||||||
color_success: config.color_success || this.themeDefaults.color_success,
|
|
||||||
color_danger: config.color_danger || this.themeDefaults.color_danger,
|
|
||||||
color_info: config.color_info || this.themeDefaults.color_info,
|
|
||||||
color_white: config.color_white || this.themeDefaults.color_white,
|
|
||||||
color_light: config.color_light || this.themeDefaults.color_light,
|
|
||||||
color_medium: config.color_medium || this.themeDefaults.color_medium,
|
|
||||||
color_black: config.color_black || this.themeDefaults.color_black,
|
|
||||||
};
|
|
||||||
this.themeSaveSuccess = '';
|
|
||||||
this.themeSaveError = '';
|
|
||||||
this.revalidateTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-run harmony detection + validation on the current palette. */
|
|
||||||
revalidateTheme(): void {
|
|
||||||
const primary = this.themeColors.color_primary;
|
|
||||||
const accent = this.themeColors.color_accent;
|
|
||||||
this.themeHarmonyRule = detectHarmonyRule(primary, accent);
|
|
||||||
this.themeValidation = validatePalette(this.themeColors, this.themeHarmonyRule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handle color change from the picker. */
|
|
||||||
onThemeColorChange(field: string, value: string): void {
|
|
||||||
this.themeColors[field] = value;
|
|
||||||
|
|
||||||
// Auto-generate variants for primary/accent bases
|
|
||||||
if (this.themeAutoVariants) {
|
|
||||||
if (field === 'color_primary') {
|
|
||||||
this.themeColors.color_primary_light = generateLight(value);
|
|
||||||
this.themeColors.color_primary_dark = generateDark(value);
|
|
||||||
this.themeColors.color_primary_muted = generateMuted(value);
|
|
||||||
} else if (field === 'color_accent') {
|
|
||||||
this.themeColors.color_accent_light = generateLight(value);
|
|
||||||
this.themeColors.color_accent_dark = generateDark(value);
|
|
||||||
this.themeColors.color_accent_muted = generateMuted(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live-apply to DOM for instant preview
|
|
||||||
this.themeService.apply(this.themeColors as any);
|
|
||||||
this.revalidateTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auto-fix all non-harmonious colors. */
|
|
||||||
autoFixTheme(): void {
|
|
||||||
const fixed = autoFixPalette(this.themeColors, this.themeHarmonyRule);
|
|
||||||
for (const key of Object.keys(fixed) as (keyof ThemeColorState)[]) {
|
|
||||||
(this.themeColors as any)[key] = fixed[key];
|
|
||||||
}
|
|
||||||
this.themeService.apply(this.themeColors as any);
|
|
||||||
this.revalidateTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset to default palette. */
|
|
||||||
resetTheme(): void {
|
|
||||||
for (const key of Object.keys(this.themeDefaults) as (keyof ThemeColorState)[]) {
|
|
||||||
(this.themeColors as any)[key] = this.themeDefaults[key];
|
|
||||||
}
|
|
||||||
this.themeService.apply(this.themeColors as any);
|
|
||||||
this.revalidateTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save theme colors to the database. */
|
|
||||||
async saveTheme(): Promise<void> {
|
|
||||||
this.themeSaving = true;
|
|
||||||
this.themeSaveError = '';
|
|
||||||
this.themeSaveSuccess = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.stationConfigService.updateConfig(this.themeColors);
|
|
||||||
this.themeSaveSuccess = 'Theme saved successfully!';
|
|
||||||
} catch (err: any) {
|
|
||||||
this.themeSaveError = err?.error?.detail || 'Failed to save theme.';
|
|
||||||
} finally {
|
|
||||||
this.themeSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get harmony rule label for display. */
|
|
||||||
getHarmonyLabel(): string {
|
|
||||||
return HARMONY_RULE_LABELS[this.themeHarmonyRule];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a color field has a harmony issue. */
|
|
||||||
hasHarmonyIssue(field: string): boolean {
|
|
||||||
const v = this.themeValidation[field];
|
|
||||||
return v ? !v.isHarmonious : false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user