adds color themeing support to the site
This commit is contained in:
@@ -37,6 +37,11 @@
|
||||
[class.active]="activeTab() === 'station'"
|
||||
(click)="activeTab.set('station')"
|
||||
>Station</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
[class.active]="activeTab() === 'theme'"
|
||||
(click)="activeTab.set('theme'); onTabChanged()"
|
||||
>Theme</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
[class.active]="activeTab() === 'underwriters'"
|
||||
@@ -318,6 +323,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Theme Tab -->
|
||||
@if (activeTab() === 'theme') {
|
||||
<div class="tab-content theme-panel">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Color Theme</h2>
|
||||
</div>
|
||||
|
||||
<div class="theme-harmony-bar">
|
||||
<div class="harmony-info">
|
||||
<span class="harmony-label">Harmony:</span>
|
||||
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
|
||||
</div>
|
||||
<label class="auto-variants-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="themeAutoVariants"
|
||||
>
|
||||
Auto-generate variants
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (themeSaveSuccess) {
|
||||
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
|
||||
}
|
||||
@if (themeSaveError) {
|
||||
<div class="theme-message theme-error">{{ themeSaveError }}</div>
|
||||
}
|
||||
|
||||
<!-- Color grid -->
|
||||
<div class="color-grid">
|
||||
@for (role of colorRoles; track role.field) {
|
||||
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
|
||||
<div class="color-swatch" [style.background]="themeColors[role.field]">
|
||||
<input
|
||||
type="color"
|
||||
[value]="themeColors[role.field]"
|
||||
(input)="onThemeColorChange(role.field, $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
<div class="color-info">
|
||||
<span class="color-name">{{ role.label }}</span>
|
||||
<span class="color-hex">{{ themeColors[role.field] }}</span>
|
||||
</div>
|
||||
<div class="color-status">
|
||||
@if (hasHarmonyIssue(role.field)) {
|
||||
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct">⚠</span>
|
||||
} @else if (role.group !== 'neutral') {
|
||||
<span class="harmony-ok">✓</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="theme-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-theme"
|
||||
(click)="resetTheme()"
|
||||
>Reset to Defaults</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-theme"
|
||||
(click)="autoFixTheme()"
|
||||
>Auto-Fix Harmony</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-save-theme"
|
||||
(click)="saveTheme()"
|
||||
[disabled]="themeSaving"
|
||||
>
|
||||
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Underwriters Tab -->
|
||||
@if (activeTab() === 'underwriters') {
|
||||
@if (underwriters$ | async; as underwriters) {
|
||||
|
||||
@@ -214,3 +214,197 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Underwriter } from '../interfaces/underwriter';
|
||||
import { ShowService } from '../services/show.service';
|
||||
import { EventService } from '../services/event.service';
|
||||
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';
|
||||
@@ -25,8 +26,37 @@ 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';
|
||||
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters' | 'stats';
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | '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;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
@@ -51,6 +81,7 @@ 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);
|
||||
@@ -75,6 +106,45 @@ export class AdminComponent implements OnInit {
|
||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
||||
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
||||
|
||||
// ── 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',
|
||||
};
|
||||
|
||||
showShowForm = false;
|
||||
showEventForm = false;
|
||||
showStationForm = false;
|
||||
@@ -322,4 +392,110 @@ export class AdminComponent implements OnInit {
|
||||
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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +80,25 @@
|
||||
}
|
||||
|
||||
.btn-form {
|
||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
||||
align-self: flex-start;
|
||||
font-size: 1rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, transform $transition-fast;
|
||||
align-self: flex-start;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Info
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: $sky-gradient;
|
||||
background: var(--sky-gradient);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -105,10 +105,25 @@
|
||||
}
|
||||
|
||||
.btn-hero-primary {
|
||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
||||
font-size: 1.1rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, transform $transition-fast;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-hero-secondary {
|
||||
@@ -139,7 +154,7 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: $radius-full;
|
||||
background: $success-green;
|
||||
background: var(--color-success);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,4 +23,18 @@ export interface StationConfig {
|
||||
play_store_icon_url: string;
|
||||
play_store_url: string;
|
||||
app_store_embed_html: string;
|
||||
// Color theme
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -158,9 +158,24 @@
|
||||
}
|
||||
|
||||
.btn-donate-nav {
|
||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, transform $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
|
||||
@@ -4,6 +4,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
import { getAppConfig } from './app-config.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
|
||||
const DEFAULT_CONFIG: StationConfig = {
|
||||
@@ -31,6 +32,20 @@ const DEFAULT_CONFIG: StationConfig = {
|
||||
play_store_icon_url: '',
|
||||
play_store_url: '',
|
||||
app_store_embed_html: '',
|
||||
// Color theme defaults (match _variables.scss)
|
||||
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',
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
@@ -38,6 +53,7 @@ const DEFAULT_CONFIG: StationConfig = {
|
||||
})
|
||||
export class StationConfigService {
|
||||
private http = inject(HttpClient);
|
||||
private themeService = inject(ThemeService);
|
||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
||||
|
||||
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
|
||||
@@ -47,6 +63,7 @@ export class StationConfigService {
|
||||
try {
|
||||
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
|
||||
this.config.set(data);
|
||||
this.themeService.apply(data);
|
||||
} catch {
|
||||
// Keep the default config if the API is unavailable.
|
||||
console.warn('StationConfigService: Failed to load config, using defaults.');
|
||||
@@ -59,6 +76,7 @@ export class StationConfigService {
|
||||
this.http.put<StationConfig>(this.baseUrl, payload),
|
||||
);
|
||||
this.config.set(data);
|
||||
this.themeService.apply(data);
|
||||
}
|
||||
|
||||
/** Upload an image file for station branding (admin only). Returns the relative URL. */
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
|
||||
/** CSS custom property names mapped from StationConfig color fields. */
|
||||
const COLOR_MAP: ReadonlyArray<{ field: keyof StationConfig; prop: string }> = [
|
||||
{ field: 'color_primary', prop: '--color-primary' },
|
||||
{ field: 'color_primary_light', prop: '--color-primary-light' },
|
||||
{ field: 'color_primary_dark', prop: '--color-primary-dark' },
|
||||
{ field: 'color_primary_muted', prop: '--color-primary-muted' },
|
||||
{ field: 'color_accent', prop: '--color-accent' },
|
||||
{ field: 'color_accent_light', prop: '--color-accent-light' },
|
||||
{ field: 'color_accent_dark', prop: '--color-accent-dark' },
|
||||
{ field: 'color_accent_muted', prop: '--color-accent-muted' },
|
||||
{ field: 'color_background', prop: '--color-cream' },
|
||||
{ field: 'color_text', prop: '--color-dark' },
|
||||
{ field: 'color_success', prop: '--color-success' },
|
||||
{ field: 'color_danger', prop: '--color-danger' },
|
||||
{ field: 'color_info', prop: '--color-info' },
|
||||
];
|
||||
|
||||
/** Build a gradient CSS value from three color stops. */
|
||||
function gradient(from: string, mid: string, to: string): string {
|
||||
return `linear-gradient(135deg, ${from} 0%, ${mid} 40%, ${to} 100%)`;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ThemeService {
|
||||
/**
|
||||
* Apply color theme from StationConfig as CSS custom properties on `:root`.
|
||||
* Also generates derived gradient variables.
|
||||
*/
|
||||
apply(config: StationConfig): void {
|
||||
const root = document.documentElement.style;
|
||||
|
||||
for (const { field, prop } of COLOR_MAP) {
|
||||
const value = (config as any)[field];
|
||||
if (value) {
|
||||
root.setProperty(prop, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived gradients
|
||||
root.setProperty(
|
||||
'--sky-gradient',
|
||||
gradient(
|
||||
config.color_primary_dark,
|
||||
config.color_primary,
|
||||
config.color_primary_light,
|
||||
),
|
||||
);
|
||||
root.setProperty(
|
||||
'--sunset-gradient',
|
||||
gradient(
|
||||
config.color_accent_dark,
|
||||
config.color_accent,
|
||||
config.color_accent_light,
|
||||
),
|
||||
);
|
||||
root.setProperty(
|
||||
'--mixed-gradient',
|
||||
`linear-gradient(135deg, ${config.color_primary_dark} 0%, ${config.color_primary} 50%, ${config.color_accent} 100%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// =============================================================================
|
||||
// Color harmony utilities — pure functions, no Angular dependencies
|
||||
// =============================================================================
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'none';
|
||||
|
||||
export interface HSL {
|
||||
h: number; // 0-360
|
||||
s: number; // 0-100
|
||||
l: number; // 0-100
|
||||
}
|
||||
|
||||
export interface ColorValidation {
|
||||
isHarmonious: boolean;
|
||||
suggestedHex?: string;
|
||||
deviation: number; // degrees from expected hue
|
||||
}
|
||||
|
||||
// ── Hex ↔ HSL conversion ────────────────────────────────────────────────────
|
||||
|
||||
export function hexToHSL(hex: string): HSL {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map(c => c + c).join('');
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
|
||||
if (max === r) {
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
} else if (max === g) {
|
||||
h = ((b - r) / d + 2) * 60;
|
||||
} else {
|
||||
h = ((r - g) / d + 4) * 60;
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = Math.max(0, Math.min(100, s)) / 100;
|
||||
l = Math.max(0, Math.min(100, l)) / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
|
||||
const toHex = (v: number) => {
|
||||
const hex = Math.round((v + m) * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
// ── Variant generation ──────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a lighter variant (+20% lightness). */
|
||||
export function generateLight(hex: string): string {
|
||||
const { h, s, l } = hexToHSL(hex);
|
||||
return hslToHex(h, s, Math.min(95, l + 20));
|
||||
}
|
||||
|
||||
/** Generate a darker variant (-20% lightness). */
|
||||
export function generateDark(hex: string): string {
|
||||
const { h, s, l } = hexToHSL(hex);
|
||||
return hslToHex(h, s, Math.max(5, l - 20));
|
||||
}
|
||||
|
||||
/** Generate a muted variant (-20% saturation). */
|
||||
export function generateMuted(hex: string): string {
|
||||
const { h, s, l } = hexToHSL(hex);
|
||||
return hslToHex(h, Math.max(0, s - 20), l);
|
||||
}
|
||||
|
||||
// ── Harmony detection ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect the harmony rule between two hues.
|
||||
* Returns the best-fit rule or 'none' if the colors are too close or unrelated.
|
||||
*/
|
||||
export function detectHarmonyRule(primaryHex: string, accentHex: string): HarmonyRule {
|
||||
const p = hexToHSL(primaryHex);
|
||||
const a = hexToHSL(accentHex);
|
||||
|
||||
// If either color is near-gray (low saturation), harmony doesn't apply
|
||||
if (p.s < 10 || a.s < 10) return 'none';
|
||||
|
||||
const diff = hueDistance(p.h, a.h);
|
||||
|
||||
// Tolerance in degrees for each rule
|
||||
const rules: Array<{ rule: HarmonyRule; target: number; tolerance: number }> = [
|
||||
{ rule: 'complementary', target: 180, tolerance: 20 },
|
||||
{ rule: 'split-complementary', target: 150, tolerance: 15 },
|
||||
{ rule: 'triadic', target: 120, tolerance: 15 },
|
||||
{ rule: 'analogous', target: 30, tolerance: 15 },
|
||||
];
|
||||
|
||||
for (const { rule, target, tolerance } of rules) {
|
||||
if (Math.abs(diff - target) <= tolerance) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Calculate the shortest distance between two hues (0-180). */
|
||||
function hueDistance(h1: number, h2: number): number {
|
||||
const diff = Math.abs(h1 - h2);
|
||||
return Math.min(diff, 360 - diff);
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a color's hue is harmonious given the primary hue and harmony rule.
|
||||
* Neutrals (saturation < 10) are always considered harmonious.
|
||||
*/
|
||||
export function validateColor(
|
||||
colorHex: string,
|
||||
primaryHex: string,
|
||||
rule: HarmonyRule,
|
||||
tolerance: number = 15,
|
||||
): ColorValidation {
|
||||
const color = hexToHSL(colorHex);
|
||||
const primary = hexToHSL(primaryHex);
|
||||
|
||||
// Neutrals are always harmonious
|
||||
if (color.s < 10) {
|
||||
return { isHarmonious: true, deviation: 0 };
|
||||
}
|
||||
|
||||
// No rule = can't validate
|
||||
if (rule === 'none') {
|
||||
return { isHarmonious: true, deviation: 0 };
|
||||
}
|
||||
|
||||
const expectedHues = getExpectedHues(primary.h, rule);
|
||||
let minDeviation = 360;
|
||||
|
||||
for (const expected of expectedHues) {
|
||||
const dev = hueDistance(color.h, expected);
|
||||
if (dev < minDeviation) minDeviation = dev;
|
||||
}
|
||||
|
||||
const isHarmonious = minDeviation <= tolerance;
|
||||
|
||||
return {
|
||||
isHarmonious,
|
||||
deviation: minDeviation,
|
||||
suggestedHex: isHarmonious ? undefined : suggestHarmoniousColor(
|
||||
colorHex, primaryHex, rule,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the expected hues for a given harmony rule relative to the primary hue. */
|
||||
function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] {
|
||||
switch (rule) {
|
||||
case 'complementary':
|
||||
return [primaryHue, (primaryHue + 180) % 360];
|
||||
case 'analogous':
|
||||
return [primaryHue, (primaryHue + 30) % 360, (primaryHue - 30 + 360) % 360];
|
||||
case 'triadic':
|
||||
return [primaryHue, (primaryHue + 120) % 360, (primaryHue + 240) % 360];
|
||||
case 'split-complementary':
|
||||
return [primaryHue, (primaryHue + 150) % 360, (primaryHue + 210) % 360];
|
||||
default:
|
||||
return [primaryHue];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a harmonious color by adjusting the hue to the nearest expected hue,
|
||||
* preserving the original saturation and lightness.
|
||||
*/
|
||||
export function suggestHarmoniousColor(
|
||||
colorHex: string,
|
||||
primaryHex: string,
|
||||
rule: HarmonyRule,
|
||||
): string {
|
||||
const color = hexToHSL(colorHex);
|
||||
const expectedHues = getExpectedHues(hexToHSL(primaryHex).h, rule);
|
||||
|
||||
// Find the closest expected hue
|
||||
let closestHue = expectedHues[0];
|
||||
let closestDist = 360;
|
||||
for (const hue of expectedHues) {
|
||||
const dist = hueDistance(color.h, hue);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closestHue = hue;
|
||||
}
|
||||
}
|
||||
|
||||
return hslToHex(closestHue, color.s, color.l);
|
||||
}
|
||||
|
||||
// ── Palette-level operations ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Color role definitions for the theme palette.
|
||||
* Each role maps to a StationConfig color field.
|
||||
*/
|
||||
export interface ColorRole {
|
||||
field: string;
|
||||
label: string;
|
||||
group: 'primary' | 'accent' | 'neutral' | 'semantic';
|
||||
}
|
||||
|
||||
export const COLOR_ROLES: readonly ColorRole[] = [
|
||||
{ field: 'color_primary', label: 'Primary', group: 'primary' },
|
||||
{ field: 'color_primary_light', label: 'Primary Light', group: 'primary' },
|
||||
{ field: 'color_primary_dark', label: 'Primary Dark', group: 'primary' },
|
||||
{ field: 'color_primary_muted', label: 'Primary Muted', group: 'primary' },
|
||||
{ field: 'color_accent', label: 'Accent', group: 'accent' },
|
||||
{ field: 'color_accent_light', label: 'Accent Light', group: 'accent' },
|
||||
{ field: 'color_accent_dark', label: 'Accent Dark', group: 'accent' },
|
||||
{ field: 'color_accent_muted', label: 'Accent Muted', group: 'accent' },
|
||||
{ field: 'color_background', label: 'Background', group: 'neutral' },
|
||||
{ field: 'color_text', label: 'Text', group: 'neutral' },
|
||||
{ field: 'color_success', label: 'Success', group: 'semantic' },
|
||||
{ field: 'color_danger', label: 'Danger', group: 'semantic' },
|
||||
{ field: 'color_info', label: 'Info', group: 'semantic' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Validate an entire color palette against the detected harmony rule.
|
||||
* Returns a map of field → validation result.
|
||||
*/
|
||||
export function validatePalette(
|
||||
colors: { [key: string]: string },
|
||||
rule: HarmonyRule,
|
||||
): { [key: string]: ColorValidation } {
|
||||
const primary = colors['color_primary'] || '#1a3a5c';
|
||||
const result: { [key: string]: ColorValidation } = {};
|
||||
|
||||
for (const role of COLOR_ROLES) {
|
||||
const hex = colors[role.field];
|
||||
if (!hex) {
|
||||
result[role.field] = { isHarmonious: true, deviation: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
// Neutrals are always harmonious
|
||||
if (role.group === 'neutral') {
|
||||
result[role.field] = { isHarmonious: true, deviation: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
// Semantic colors get wider tolerance
|
||||
const tolerance = role.group === 'semantic' ? 30 : 15;
|
||||
result[role.field] = validateColor(hex, primary, rule, tolerance);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fix the entire palette by adjusting non-harmonious colors.
|
||||
* Returns a new colors record with corrections applied.
|
||||
*/
|
||||
export function autoFixPalette(
|
||||
colors: { [key: string]: string },
|
||||
rule: HarmonyRule,
|
||||
): { [key: string]: string } {
|
||||
const fixed = { ...colors };
|
||||
const primary = fixed['color_primary'] || '#1a3a5c';
|
||||
|
||||
for (const role of COLOR_ROLES) {
|
||||
if (role.group === 'neutral') continue;
|
||||
|
||||
const hex = fixed[role.field];
|
||||
if (!hex) continue;
|
||||
|
||||
const validation = validateColor(hex, primary, rule, role.group === 'semantic' ? 30 : 15);
|
||||
if (!validation.isHarmonious && validation.suggestedHex) {
|
||||
fixed[role.field] = validation.suggestedHex;
|
||||
}
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
// ── Harmony rule labels ─────────────────────────────────────────────────────
|
||||
|
||||
export const HARMONY_RULE_LABELS: Record<HarmonyRule, string> = {
|
||||
complementary: 'Complementary (opposite, ~180°)',
|
||||
analogous: 'Analogous (neighbors, ±30°)',
|
||||
triadic: 'Triadic (three-way, ±120°)',
|
||||
'split-complementary': 'Split-Complementary (offset, ±150°)',
|
||||
none: 'No harmony detected',
|
||||
};
|
||||
+46
-6
@@ -80,26 +80,66 @@ img, svg {
|
||||
}
|
||||
|
||||
.btn {
|
||||
@include button-style($accent-orange, $neutral-white);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, transform $transition-fast;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-primary);
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@include button-style($primary-blue, $neutral-white, $primary-blue-dark);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, transform $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid $accent-orange;
|
||||
color: $accent-orange;
|
||||
border: 2px solid var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $accent-orange;
|
||||
color: $neutral-white;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user