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