New programming screen

This commit is contained in:
2026-06-21 21:55:55 +00:00
parent 96d0fef792
commit e627fe3637
24 changed files with 1297 additions and 122 deletions
@@ -0,0 +1,79 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Show' : 'Add Show' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form" #form="ngForm">
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="Show title" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" [(ngModel)]="description" name="description" rows="3" placeholder="What is this show about?"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="host">Host</label>
<input id="host" type="text" [(ngModel)]="host" name="host" placeholder="Host name" required [class.is-invalid]="submitted && !host">
</div>
<div class="form-group">
<label for="genre">Genre</label>
<input id="genre" type="text" [(ngModel)]="genre" name="genre" placeholder="Genre" required [class.is-invalid]="submitted && !genre">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="show_art_url">Art URL</label>
<input id="show_art_url" type="text" [(ngModel)]="show_art_url" name="show_art_url" placeholder="uploads/show-art.jpg">
</div>
<div class="form-group">
<label for="hero_image_url">Hero Image URL</label>
<input id="hero_image_url" type="text" [(ngModel)]="hero_image_url" name="hero_image_url" placeholder="uploads/hero-bg.jpg">
</div>
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order" min="0">
</div>
<!-- Schedule slots -->
<div class="form-group">
<label>Schedule Slots</label>
<div class="schedule-slots">
@for (slot of schedules; track slot.day_of_week; let i = $index) {
<div class="slot-row">
<select [(ngModel)]="slot.day_of_week" [name]="'slot_day_' + i">
@for (day of days; track day.value) {
<option [value]="day.value">{{ day.label }}</option>
}
</select>
<input type="text" [(ngModel)]="slot.time" [name]="'slot_time_' + i" placeholder="6:00 AM">
<button type="button" class="btn-remove-slot" (click)="removeScheduleSlot(i)" [disabled]="schedules.length <= 1" title="Remove slot">&times;</button>
</div>
}
</div>
<button type="button" class="btn-add-slot" (click)="addScheduleSlot()">+ Add Slot</button>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
{{ loading ? 'Saving...' : (id ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,216 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.form-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
@include flex-center;
z-index: $z-modal;
@include fade-in;
}
.form-dialog {
background: $neutral-white;
border-radius: $radius-lg;
padding: $spacing-xl;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: $shadow-xl;
}
.form-header {
@include flex-between;
margin-bottom: $spacing-lg;
h2 {
font-family: $font-heading;
color: $primary-blue;
margin: 0;
font-size: 1.4rem;
}
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: $neutral-medium;
line-height: 1;
padding: $spacing-xs;
&:hover {
color: $neutral-dark;
}
}
.form-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.admin-form {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input, select, textarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.95rem;
transition: border-color $transition-fast;
box-sizing: border-box;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
&.is-invalid {
border-color: $danger-red;
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
}
}
}
}
// ── Schedule slots ─────────────────────────────────────────
.schedule-slots {
display: flex;
flex-direction: column;
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
.slot-row {
display: flex;
gap: $spacing-sm;
align-items: center;
select {
flex: 1;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.9rem;
&:focus {
outline: none;
border-color: $primary-blue;
}
}
input {
flex: 1;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.9rem;
&:focus {
outline: none;
border-color: $primary-blue;
}
}
}
.btn-remove-slot {
background: none;
border: 1px solid $neutral-light;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 1.1rem;
cursor: pointer;
color: $neutral-medium;
display: flex;
align-items: center;
justify-content: center;
transition: background $transition-fast, color $transition-fast;
flex-shrink: 0;
&:hover:not(:disabled) {
background: $danger-red;
color: $neutral-white;
border-color: $danger-red;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.btn-add-slot {
background: none;
border: 1px dashed $neutral-light;
border-radius: $radius-md;
padding: $spacing-xs $spacing-md;
font-size: 0.85rem;
cursor: pointer;
color: $primary-blue;
transition: border-color $transition-fast, background $transition-fast;
width: 100%;
&:hover {
border-color: $primary-blue;
background: rgba($primary-blue, 0.05);
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md;
}
.btn-save {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-cancel {
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 0.95rem;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
}
+153
View File
@@ -0,0 +1,153 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { Show } from '../interfaces/show';
import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service';
@Component({
selector: 'app-admin-show-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-show-form.component.html',
styleUrl: './admin-show-form.component.scss',
})
export class AdminShowFormComponent implements OnChanges {
private showService = inject(ShowService);
@Input() editItem: Show | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
readonly days = [
{ value: 1, label: 'Monday' },
{ value: 2, label: 'Tuesday' },
{ value: 3, label: 'Wednesday' },
{ value: 4, label: 'Thursday' },
{ value: 5, label: 'Friday' },
{ value: 6, label: 'Saturday' },
{ value: 7, label: 'Sunday' },
];
id: number | null = null;
title = '';
description = '';
host = '';
genre = '';
show_art_url = '';
hero_image_url = '';
display_order = 0;
schedules: ShowScheduleCreate[] = [
{ day_of_week: 1, time: '' },
];
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(show: Show): void {
this.id = show.id;
this.title = show.title;
this.description = show.description;
this.host = show.host;
this.genre = show.genre;
this.show_art_url = show.show_art_url || '';
this.hero_image_url = show.hero_image_url || '';
this.display_order = show.display_order;
this.schedules = show.schedules.map((s) => ({
day_of_week: s.day_of_week,
time: s.time,
}));
}
formatError(err: any): string {
if (err?.error?.detail) return err.error.detail;
if (err?.error?.message) return err.error.message;
if (err?.message) return err.message;
return 'Failed to save. Please try again.';
}
reset(): void {
this.id = null;
this.title = '';
this.description = '';
this.host = '';
this.genre = '';
this.show_art_url = '';
this.hero_image_url = '';
this.display_order = 0;
this.schedules = [{ day_of_week: 1, time: '' }];
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
/** Add a new empty schedule slot row. */
addScheduleSlot(): void {
this.schedules.push({ day_of_week: 1, time: '' });
}
/** Remove a schedule slot row by index. */
removeScheduleSlot(index: number): void {
this.schedules.splice(index, 1);
}
async onSubmit(): Promise<void> {
this.submitted = true;
// Validate required fields
if (!this.title || !this.host || !this.genre) {
this.error = 'Please fill in all required fields.';
return;
}
// Validate schedules
const validSchedules = this.schedules.filter(
(s) => s.day_of_week && s.time,
);
if (validSchedules.length === 0) {
this.error = 'Add at least one schedule slot with a day and time.';
return;
}
this.loading = true;
this.error = '';
const payload = {
title: this.title,
description: this.description,
host: this.host,
genre: this.genre,
show_art_url: this.show_art_url || null,
hero_image_url: this.hero_image_url || null,
display_order: this.display_order,
schedules: validSchedules,
};
try {
if (this.id) {
const updatePayload: ShowUpdatePayload = { ...payload };
await firstValueFrom(this.showService.updateShow(this.id, updatePayload));
} else {
await firstValueFrom(this.showService.createShow(payload as ShowCreatePayload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
+56 -2
View File
@@ -2,11 +2,16 @@
<div class="container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage programs, events, tiers, history, team, and community content</p>
<p>Manage shows, programs, events, tiers, history, team, and community content</p>
</div>
<!-- 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() === 'programs'"
@@ -44,6 +49,52 @@
>Station</button>
</div>
<!-- Shows Tab -->
@if (activeTab() === 'shows') {
@if (shows$ | async; as shows) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Shows ({{ shows.length }})</h2>
<button class="btn btn-add" (click)="openShowForm()">+ Add Show</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Host</th>
<th>Genre</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (show of shows; track show.id) {
<tr>
<td>{{ show.display_order }}</td>
<td>{{ show.title }}</td>
<td>{{ show.host }}</td>
<td>{{ show.genre }}</td>
<td>
@for (slot of show.schedules; track slot.id) {
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
}
</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editShow(show)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteShow(show.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Programs Tab -->
@if (activeTab() === 'programs') {
@if (programs$ | async; as programs) {
@@ -339,6 +390,9 @@
</div>
<!-- Form modals -->
@if (showShowForm) {
<app-admin-show-form [editItem]="editingShow" (saved)="onShowSaved()" (closed)="onShowClosed()" />
}
@if (showProgramForm) {
<app-admin-program-form [editItem]="editingProgram" (saved)="onProgramSaved()" (closed)="onProgramClosed()" />
}
@@ -360,4 +414,4 @@
@if (showCommunityForm) {
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
}
</section>
</section>
+12
View File
@@ -110,6 +110,18 @@
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 {
text-align: center;
color: $neutral-medium;
+45 -2
View File
@@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Program } from '../interfaces/program';
import { Show } from '../interfaces/show';
import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier';
import { StationConfig } from '../interfaces/station-config';
@@ -11,6 +12,7 @@ import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { ProgramService } from '../services/program.service';
import { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service';
import { TierService } from '../services/tier.service';
import { StationConfigService } from '../services/station-config.service';
@@ -18,6 +20,7 @@ import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service';
import { AdminProgramFormComponent } from './admin-program-form.component';
import { AdminShowFormComponent } from './admin-show-form.component';
import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminTierFormComponent } from './admin-tier-form.component';
import { AdminStationFormComponent } from './admin-station-form.component';
@@ -25,7 +28,7 @@ import { AdminHistoryFormComponent } from './admin-history-form.component';
import { AdminTeamFormComponent } from './admin-team-form.component';
import { AdminCommunityFormComponent } from './admin-community-form.component';
type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community';
type TabKey = 'shows' | 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community';
@Component({
selector: 'app-admin',
@@ -34,6 +37,7 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' |
CommonModule,
AsyncPipe,
FormsModule,
AdminShowFormComponent,
AdminProgramFormComponent,
AdminEventFormComponent,
AdminTierFormComponent,
@@ -47,6 +51,7 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' |
})
export class AdminComponent implements OnInit {
private programService = inject(ProgramService);
private showService = inject(ShowService);
private eventService = inject(EventService);
private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService);
@@ -54,9 +59,10 @@ export class AdminComponent implements OnInit {
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
activeTab = signal<TabKey>('programs');
activeTab = signal<TabKey>('shows');
/** Item currently being edited (set by editXxx, cleared on save/close). */
editingShow: Show | null = null;
editingProgram: Program | null = null;
editingEvent: Event | null = null;
editingTier: Tier | null = null;
@@ -65,6 +71,7 @@ export class AdminComponent implements OnInit {
editingCommunity: CommunityHighlight | null = null;
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
readonly shows$ = new BehaviorSubject<Show[]>([]);
readonly programs$ = new BehaviorSubject<Program[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
@@ -73,6 +80,7 @@ export class AdminComponent implements OnInit {
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
showShowForm = false;
showProgramForm = false;
showEventForm = false;
showTierForm = false;
@@ -85,6 +93,11 @@ export class AdminComponent implements OnInit {
this.loadAll();
}
async reloadShows(): Promise<void> {
const data = await firstValueFrom(this.showService.getShows());
this.shows$.next(data);
}
async reloadPrograms(): Promise<void> {
const data = await firstValueFrom(this.programService.getPrograms());
this.programs$.next(data);
@@ -122,6 +135,7 @@ export class AdminComponent implements OnInit {
async loadAll(): Promise<void> {
await Promise.all([
this.reloadShows(),
this.reloadPrograms(),
this.reloadEvents(),
this.reloadTiers(),
@@ -132,6 +146,35 @@ export class AdminComponent implements OnInit {
]);
}
// ── Show CRUD ───────────────────────────────────────────
openShowForm(): void {
this.editingShow = null;
this.showShowForm = true;
}
editShow(show: Show): void {
this.editingShow = show;
this.showShowForm = true;
}
onShowSaved(): void {
this.reloadShows();
this.showShowForm = false;
this.editingShow = null;
}
onShowClosed(): void {
this.showShowForm = false;
this.editingShow = null;
}
async deleteShow(id: number): Promise<void> {
if (!confirm('Delete this show?')) return;
await firstValueFrom(this.showService.deleteShow(id));
await this.reloadShows();
}
// ── Program CRUD ────────────────────────────────────────
openProgramForm(): void {
+20
View File
@@ -0,0 +1,20 @@
export interface ShowSchedule {
id: number;
show_id: number;
day_of_week: number;
day_label: string;
time: string;
}
export interface Show {
id: number;
title: string;
description: string;
host: string;
genre: string;
show_art_url: string | null;
hero_image_url: string | null;
display_order: number;
active: boolean;
schedules: ShowSchedule[];
}
+33
View File
@@ -0,0 +1,33 @@
import { Pipe, PipeTransform } from '@angular/core';
import { ShowSchedule } from '../interfaces/show';
const DAY_ORDER = {
Monday: 1,
Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
Sunday: 7,
};
/** Sort schedule slots by day of week, then by time string. */
@Pipe({
name: 'sortSchedules',
standalone: true,
pure: true,
})
export class SortSchedulesPipe implements PipeTransform {
transform(schedules: ShowSchedule[]): ShowSchedule[] {
if (!schedules?.length) {
return [];
}
return [...schedules].sort((a, b) => {
const dayA = DAY_ORDER[a.day_label as keyof typeof DAY_ORDER] ?? 9;
const dayB = DAY_ORDER[b.day_label as keyof typeof DAY_ORDER] ?? 9;
if (dayA !== dayB) return dayA - dayB;
return a.time.localeCompare(b.time);
});
}
}
+37 -21
View File
@@ -12,31 +12,47 @@
@if (schedule$ | async; as state) {
@if (state.loading) {
<div class="schedule-loading">Loading schedule&hellip;</div>
} @else if (state.dayGroups.length === 0) {
<div class="schedule-empty">No programs scheduled. Check back soon!</div>
} @else if (state.shows.length === 0) {
<div class="schedule-empty">No shows scheduled. Check back soon!</div>
} @else {
@for (group of state.dayGroups; track group.day_label) {
<div class="schedule-day-block">
<div class="schedule-day-label">{{ group.day_label }}</div>
<div class="schedule-table">
@for (program of group.programs; track program.id) {
<div class="schedule-row">
<div class="schedule-time">{{ program.time }}</div>
<div class="schedule-divider"></div>
<div class="schedule-details">
<h4 class="schedule-title">{{ program.title }}</h4>
<div class="schedule-meta">
<span class="schedule-host">
<span aria-hidden="true">&#x1F399;</span> {{ program.host }}
<div class="show-list">
@for (show of state.shows; track show.id) {
<article class="show-card">
<!-- Left: Show art badge -->
<div class="show-card-art">
@if (show.show_art_url) {
<img [src]="show.show_art_url" [alt]="show.title + ' art'" class="show-art-img" />
} @else {
<div class="show-art-placeholder">
<span aria-hidden="true">&#x1F3B5;</span>
</div>
}
</div>
<!-- Right: Hero-backed info area -->
<div class="show-card-right" [class.has-hero]="show.hero_image_url" [style.background-image]="show.hero_image_url ? 'url(' + show.hero_image_url + ')' : ''">
<div class="show-card-overlay"></div>
<div class="show-card-info">
<h3 class="show-title">{{ show.title }}</h3>
<p class="show-description">{{ show.description }}</p>
<div class="show-meta">
<span class="show-host">
<span aria-hidden="true">&#x1F399;</span> {{ show.host }}
</span>
<span class="show-genre">{{ show.genre }}</span>
</div>
<div class="show-schedule-slots">
@for (slot of show.schedules | sortSchedules; track slot.id) {
<span class="slot-tag">
{{ slot.day_label }} {{ slot.time }}
</span>
<span class="schedule-genre">{{ program.genre }}</span>
</div>
}
</div>
</div>
}
</div>
</div>
}
</div>
</article>
}
</div>
}
}
</div>
+179 -58
View File
@@ -26,89 +26,210 @@
font-size: 1.1rem;
}
.schedule-day-block {
max-width: 800px;
margin: 0 auto $spacing-xl;
}
// ── Show list (vertical stack) ─────────────────────────────
.schedule-day-label {
text-align: center;
font-family: var(--font-heading);
font-size: 1.2rem;
color: var(--color-primary);
margin-bottom: $spacing-md;
font-weight: 600;
}
.schedule-table {
.show-list {
display: flex;
flex-direction: column;
gap: 0;
gap: $spacing-lg;
}
.schedule-row {
// ── Horizontal show card ───────────────────────────────────
.show-card {
display: flex;
flex-direction: row;
align-items: stretch;
padding: $spacing-md 0;
border-bottom: 1px solid var(--color-light);
transition: background $transition-fast;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-md;
transition: transform $transition-normal, box-shadow $transition-normal;
min-height: 180px;
&:hover {
background: var(--color-cream);
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
}
.schedule-time {
width: 100px;
// ── Left: Show art badge (square) ──────────────────────────
.show-card-art {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 0.95rem;
color: var(--color-accent);
font-weight: 600;
padding-right: $spacing-md;
text-align: right;
padding-top: 2px;
}
.schedule-divider {
width: 2px;
background: var(--color-accent-muted);
margin: 0 $spacing-sm;
border-radius: 1px;
opacity: 0.4;
}
.schedule-details {
flex: 1;
padding: $spacing-xs 0;
}
.schedule-title {
font-size: 1.05rem;
color: var(--color-primary);
margin-bottom: $spacing-xs;
}
.schedule-meta {
width: 140px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary-dark);
padding: $spacing-md;
.show-art-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: $radius-md;
display: block;
}
.show-art-placeholder {
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.12);
border-radius: $radius-md;
font-size: 3rem;
color: var(--color-accent);
}
}
// ── Right: Hero-backed info panel ──────────────────────────
.show-card-right {
flex: 1;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
background-color: var(--color-light);
&.has-hero {
background-size: cover;
background-position: center;
}
}
.show-card-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(26, 36, 50, 0.95) 0%,
rgba(26, 36, 50, 0.85) 60%,
rgba(26, 36, 50, 0.7) 100%
);
z-index: 1;
}
.show-card-info {
position: relative;
z-index: 2;
padding: $spacing-lg $spacing-xl;
min-width: 0;
}
// ── Show title ─────────────────────────────────────────────
.show-title {
font-family: var(--font-heading);
font-size: 1.4rem;
color: var(--color-white);
margin: 0 0 $spacing-sm;
line-height: 1.25;
}
// ── Show description (line-clamped) ────────────────────────
.show-description {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.82);
margin: 0 0 $spacing-md;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
// ── Meta row (host + genre) ────────────────────────────────
.show-meta {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
font-size: 0.85rem;
color: var(--color-medium);
margin-bottom: $spacing-md;
.schedule-host {
.show-host {
color: var(--color-accent);
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.schedule-genre {
background: var(--color-light);
padding: 2px 10px;
.show-genre {
background: rgba(255, 255, 255, 0.15);
padding: 3px 12px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.88);
font-style: italic;
}
}
@media (max-width: #{$bp-md - 1px}) {
.schedule-time { width: 80px; font-size: 0.85rem; }
// ── Schedule slot tags ─────────────────────────────────────
.show-schedule-slots {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.slot-tag {
display: inline-block;
background: rgba(255, 255, 255, 0.16);
color: var(--color-white);
font-size: 0.78rem;
font-family: var(--font-mono);
padding: 4px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
white-space: nowrap;
letter-spacing: 0.02em;
}
// ── Responsive ─────────────────────────────────────────────
@include responsive(md) {
.show-card-art {
width: 110px;
padding: $spacing-sm;
}
.show-card-info {
padding: $spacing-md $spacing-lg;
}
.show-title {
font-size: 1.2rem;
}
.show-description {
-webkit-line-clamp: 2;
font-size: 0.88rem;
}
}
@include responsive(sm) {
.show-card {
flex-direction: column;
min-height: auto;
}
.show-card-art {
width: 100%;
aspect-ratio: 16 / 9;
padding: $spacing-md;
}
.show-card-info {
padding: $spacing-md $spacing-lg $spacing-lg;
}
.show-description {
-webkit-line-clamp: 2;
}
}
+10 -33
View File
@@ -1,55 +1,32 @@
import { Component, inject } from '@angular/core';
import { AsyncPipe, NgFor } from '@angular/common';
import { AsyncPipe } from '@angular/common';
import { map, Observable, startWith } from 'rxjs';
import { ProgramService } from '../services/program.service';
import { Program } from '../interfaces/program';
import { ShowService } from '../services/show.service';
import { Show } from '../interfaces/show';
import { StationConfigService } from '../services/station-config.service';
interface DayGroup {
day_label: string;
day_of_week: number;
programs: Program[];
}
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
interface ScheduleState {
loading: boolean;
dayGroups: DayGroup[];
}
function groupByDay(programs: Program[]): DayGroup[] {
const map = new Map<string, DayGroup>();
for (const p of programs) {
if (!map.has(p.day_label)) {
map.set(p.day_label, {
day_label: p.day_label,
day_of_week: p.day_of_week,
programs: [],
});
}
map.get(p.day_label)!.programs.push(p);
}
return Array.from(map.values()).sort((a, b) => a.day_of_week - b.day_of_week);
shows: Show[];
}
@Component({
selector: 'app-schedule',
standalone: true,
imports: [AsyncPipe],
imports: [AsyncPipe, SortSchedulesPipe],
templateUrl: './schedule.component.html',
styleUrl: './schedule.component.scss',
})
export class ScheduleComponent {
private programService = inject(ProgramService);
private showService = inject(ShowService);
readonly config = inject(StationConfigService).config;
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly schedule$: Observable<ScheduleState> =
this.programService.getPrograms().pipe(
map((programs) => groupByDay(programs)),
map((dayGroups) => ({ loading: false, dayGroups })),
startWith({ loading: true, dayGroups: [] }),
this.showService.getShows().pipe(
map((shows) => ({ loading: false, shows })),
startWith({ loading: true, shows: [] }),
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Show } from '../interfaces/show';
import { getAppConfig } from './app-config.service';
export interface ShowScheduleCreate {
day_of_week: number;
time: string;
}
export interface ShowCreatePayload {
title: string;
description: string;
host: string;
genre: string;
show_art_url: string | null;
hero_image_url: string | null;
display_order: number;
schedules: ShowScheduleCreate[];
}
export interface ShowUpdatePayload {
title?: string;
description?: string;
host?: string;
genre?: string;
show_art_url?: string | null;
hero_image_url?: string | null;
display_order?: number;
active?: boolean;
schedules?: ShowScheduleCreate[];
}
@Injectable({
providedIn: 'root',
})
export class ShowService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
getShows(): Observable<Show[]> {
return this.http.get<Show[]>(this.baseUrl);
}
getShow(id: number): Observable<Show> {
return this.http.get<Show>(`${this.baseUrl}/${id}`);
}
createShow(payload: ShowCreatePayload): Observable<Show> {
return this.http.post<Show>(this.baseUrl, payload);
}
updateShow(id: number, payload: ShowUpdatePayload): Observable<Show> {
return this.http.put<Show>(`${this.baseUrl}/${id}`, payload);
}
deleteShow(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}