AI Implementation feature(859): Admin Area General Settings and Categories (#21)

This commit was merged in pull request #21.
This commit is contained in:
2026-07-22 11:45:23 +00:00
parent de527ec6d6
commit c9e8dfc611
34 changed files with 7349 additions and 62 deletions
@@ -0,0 +1,85 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
interface AdminNavEntry {
id: string;
label: string;
path: string;
enabled: boolean;
}
const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: false },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: false },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
];
@Component({
selector: 'app-admin-shell',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet],
styles: [`
.admin-shell { display: flex; gap: 16px; align-items: flex-start; }
.admin-aside {
min-width: 180px;
padding: 12px;
background: var(--color-surface, #fff);
border-radius: var(--radius-md, 8px);
}
.admin-aside ul { list-style: none; padding: 0; margin: 0; }
.admin-aside li {
padding: 8px 10px;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
color: var(--color-text, #000);
}
.admin-aside li.active {
background: var(--color-primary, #3b82f6);
color: #fff;
}
.admin-aside li.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.admin-body { flex: 1; min-width: 0; }
`],
template: `
<section class="admin-shell">
<aside class="admin-aside" data-testid="admin-aside">
<h2>Admin area</h2>
<ul data-testid="admin-nav">
@for (e of entries; track e.id) {
<li
[class.active]="isActive(e)"
[class.disabled]="!e.enabled"
[attr.data-testid]="'admin-nav-' + e.id"
(click)="go(e)"
>
{{ e.label }}
</li>
}
</ul>
</aside>
<div class="admin-body" data-testid="admin-body">
<router-outlet />
</div>
</section>
`,
})
export class AdminShellComponent {
readonly entries = ENTRIES;
private readonly router = inject(Router);
isActive(e: AdminNavEntry): boolean {
return this.router.url.startsWith(e.path);
}
go(e: AdminNavEntry): void {
if (!e.enabled) return;
void this.router.navigateByUrl(e.path);
}
}
@@ -0,0 +1,186 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminService, AdminCategory } from '../../../core/services/admin.service';
import { CategoryFormModalComponent, CategoryFormSubmit } from './category-form-modal.component';
import { CategoryDeleteModalComponent } from './category-delete-modal.component';
export type ModalMode = 'create' | 'edit';
@Component({
selector: 'app-admin-categories',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent],
styles: [`
.cat-list { list-style: none; padding: 0; margin: 0; }
.cat-row {
display: flex; align-items: center; gap: 12px;
padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc);
}
.cat-row .icon { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
.cat-row .abbr { font-weight: bold; min-width: 60px; }
.cat-row .desc { flex: 1; }
.cat-row button { margin-left: 4px; }
.header-bar { display: flex; justify-content: space-between; align-items: center; }
.error { color: var(--color-danger, #f00); }
`],
template: `
<section data-testid="admin-categories">
<div class="header-bar">
<h2>Categories</h2>
<button type="button" (click)="openCreate()" data-testid="cat-add">+</button>
</div>
@if (loading()) {
<p data-testid="cat-loading">Loading categories...</p>
} @else if (loadError()) {
<p class="error" data-testid="cat-error">{{ loadError() }}</p>
} @else {
<ul class="cat-list" data-testid="cat-list">
@for (c of categories(); track c.id) {
<li class="cat-row" [attr.data-testid]="'cat-row-' + c.abbreviation">
<img class="icon" [src]="c.iconPath || '/uploads/icons/placeholder.png'" alt="" />
<span class="abbr">{{ c.abbreviation }}</span>
<span class="desc">{{ c.description }}</span>
<button type="button" (click)="openEdit(c)" [attr.data-testid]="'cat-edit-' + c.abbreviation">✎</button>
<button type="button" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">🗑</button>
</li>
}
</ul>
}
<app-category-form-modal
[open]="modalMode() !== null"
[mode]="modalMode() === 'edit' ? 'edit' : 'create'"
[category]="editing()"
(cancel)="closeModal()"
(submit)="onFormSubmit($event)"
/>
<app-category-delete-modal
[open]="deleteOpen()"
[category]="deleting()"
[errorMessage]="deleteError()"
(cancel)="closeModal()"
(confirm)="onDeleteConfirm()"
/>
</section>
`,
})
export class AdminCategoriesComponent implements OnInit {
private readonly admin = inject(AdminService);
readonly categories = signal<AdminCategory[]>([]);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly modalMode = signal<ModalMode | null>(null);
readonly deleteOpen = signal(false);
readonly editing = signal<AdminCategory | null>(null);
readonly deleting = signal<AdminCategory | null>(null);
readonly deleteError = signal<string | null>(null);
async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const list = await this.admin.listCategories();
list.sort((a, b) => a.abbreviation.toLowerCase().localeCompare(b.abbreviation.toLowerCase()));
this.categories.set(list);
} catch (e: any) {
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load');
} finally {
this.loading.set(false);
}
}
openCreate(): void {
this.editing.set(null);
this.deleting.set(null);
this.deleteError.set(null);
this.deleteOpen.set(false);
this.modalMode.set('create');
}
openEdit(c: AdminCategory): void {
this.deleting.set(null);
this.deleteOpen.set(false);
this.editing.set(c);
this.modalMode.set('edit');
}
openDelete(c: AdminCategory): void {
this.editing.set(null);
this.modalMode.set(null);
this.deleting.set(c);
this.deleteError.set(null);
this.deleteOpen.set(true);
}
closeModal(): void {
this.modalMode.set(null);
this.deleteOpen.set(false);
this.editing.set(null);
this.deleting.set(null);
this.deleteError.set(null);
}
async onFormSubmit(payload: CategoryFormSubmit): Promise<void> {
try {
if (payload.mode === 'create') {
const created = await this.admin.createCategory({
name: payload.name,
abbreviation: payload.abbreviation,
description: payload.description,
iconPath: payload.iconPath,
});
if (payload.iconFile && created.id) {
const r = await this.admin.uploadCategoryIcon(created.id, payload.iconFile);
await this.admin.updateCategory(created.id, { iconPath: r.publicUrl });
}
} else if (payload.mode === 'edit' && this.editing()) {
const id = this.editing()!.id;
let iconPath = payload.iconPath;
if (payload.iconFile) {
const r = await this.admin.uploadCategoryIcon(id, payload.iconFile);
iconPath = r.publicUrl;
}
await this.admin.updateCategory(id, {
name: payload.name,
description: payload.description,
iconPath,
});
}
this.closeModal();
await this.load();
} catch (e: any) {
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed');
}
}
async onDeleteConfirm(): Promise<void> {
const c = this.deleting();
if (!c) return;
try {
await this.admin.deleteCategory(c.id);
this.closeModal();
await this.load();
} catch (e: any) {
const code = e?.error?.code ?? e?.code;
const msg = e?.error?.message ?? e?.message ?? 'Failed';
if (code === 'CATEGORY_HAS_CHALLENGES') {
this.deleteError.set(`Cannot delete: category has ${e?.error?.details?.count ?? ''} challenge(s) attached.`);
} else if (code === 'SYSTEM_PROTECTED') {
this.deleteError.set('System categories cannot be deleted.');
} else {
this.deleteError.set(msg);
}
}
}
readonly deleteErrorText = computed(() => this.deleteError());
}
@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminCategory } from '../../../core/services/admin.service';
@Component({
selector: 'app-category-delete-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 320px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.note { color: var(--color-warning, #fa0); margin-top: 8px; }
`],
template: `
@if (open()) {
<div class="modal-backdrop" data-testid="cat-delete-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()" data-testid="cat-delete-modal">
<h3>Delete category</h3>
@if (category(); as c) {
@if (c.isSystem) {
<p>This is a system category and cannot be deleted.</p>
} @else {
<p>Delete <b>{{ c.name }}</b> ({{ c.abbreviation }})? This cannot be undone.</p>
}
}
@if (errorMessage()) {
<p class="error" data-testid="cat-delete-error">{{ errorMessage() }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="cat-delete-cancel">Cancel</button>
@if (canConfirm()) {
<button type="button" (click)="onConfirm()" data-testid="cat-delete-ok">OK</button>
}
</div>
</div>
</div>
}
`,
})
export class CategoryDeleteModalComponent {
readonly open = input(false);
readonly category = input<AdminCategory | null>(null);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly confirm = output<void>();
readonly canConfirm = computed(() => {
const c = this.category();
return !!c && !c.isSystem;
});
onCancel(): void {
this.cancel.emit();
}
onConfirm(): void {
this.confirm.emit();
}
}
@@ -0,0 +1,142 @@
import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AdminCategory } from '../../../core/services/admin.service';
export type CategoryFormMode = 'create' | 'edit';
export interface CategoryFormSubmit {
mode: CategoryFormMode;
name: string;
abbreviation: string;
description: string;
iconPath?: string;
iconFile?: File;
}
@Component({
selector: 'app-category-form-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
styles: [`
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
`],
template: `
@if (open()) {
<div class="modal-backdrop" data-testid="cat-form-backdrop" (click)="onCancel()">
<form class="modal" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal">
<h3>{{ mode() === 'create' ? 'Add category' : 'Edit category' }}</h3>
<div class="row">
<label for="cf-name">Name</label>
<input id="cf-name" type="text" formControlName="name" data-testid="cf-name" />
</div>
<div class="row">
<label for="cf-abbr">Abbreviation (uppercase)</label>
<input id="cf-abbr" type="text" formControlName="abbreviation"
[attr.readonly]="abbreviationReadonly() ? '' : null"
data-testid="cf-abbr" />
</div>
<div class="row">
<label for="cf-desc">Description</label>
<textarea id="cf-desc" rows="3" formControlName="description" data-testid="cf-desc"></textarea>
</div>
<div class="row">
<label for="cf-icon">Icon (image, will be normalized to 128x128)</label>
<input id="cf-icon" type="file" accept="image/*" (change)="onFile($event)" data-testid="cf-icon" />
@if (iconPreview()) {
<img [src]="iconPreview()" alt="icon preview" style="width:64px;height:64px;object-fit:cover;border-radius:4px;" />
}
</div>
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="cf-cancel">Cancel</button>
<button type="button" [disabled]="form.invalid || submitting()" (click)="onOk()" data-testid="cf-ok">OK</button>
</div>
</form>
</div>
}
`,
})
export class CategoryFormModalComponent {
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
readonly open = input(false);
readonly mode = input<CategoryFormMode>('create');
readonly category = input<AdminCategory | null>(null);
readonly cancel = output<void>();
readonly submit = output<CategoryFormSubmit>();
readonly iconPreview = signal<string | null>(null);
readonly iconFile = signal<File | null>(null);
readonly submitting = signal(false);
readonly abbreviationReadonly = signal(false);
readonly form = this.fb.nonNullable.group({
name: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]),
abbreviation: this.fb.nonNullable.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(6)]),
description: this.fb.nonNullable.control(''),
});
constructor() {
effect(() => {
const c = this.category();
const m = this.mode();
if (m === 'edit' && c) {
this.form.patchValue({
name: c.name,
abbreviation: c.abbreviation,
description: c.description,
});
this.abbreviationReadonly.set(c.isSystem);
this.iconPreview.set(c.iconPath || null);
} else {
this.form.patchValue({ name: '', abbreviation: '', description: '' });
this.abbreviationReadonly.set(false);
this.iconPreview.set(null);
}
this.iconFile.set(null);
});
}
onFile(ev: Event): void {
const input = ev.target as HTMLInputElement;
const f = input.files?.[0];
if (!f) return;
this.iconFile.set(f);
const reader = new FileReader();
reader.onload = () => this.iconPreview.set(String(reader.result));
reader.readAsDataURL(f);
}
onCancel(): void {
this.cancel.emit();
}
onOk(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const v = this.form.getRawValue();
const abbr = v.abbreviation.toUpperCase();
this.submit.emit({
mode: this.mode(),
name: v.name,
abbreviation: abbr,
description: v.description,
iconPath: this.iconPreview()?.startsWith('data:') ? undefined : (this.category()?.iconPath ?? ''),
iconFile: this.iconFile() ?? undefined,
});
}
}
@@ -0,0 +1,269 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AdminService, GeneralSettings, ThemeView } from '../../core/services/admin.service';
import { MarkdownService } from '../../core/services/markdown.service';
import { AdminCategoriesComponent } from './categories/categories.component';
import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from './general.pure';
@Component({
selector: 'app-admin-general',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule, AdminCategoriesComponent],
styles: [`
.general-section { display: flex; flex-direction: column; gap: 16px; }
.form-grid { display: grid; grid-template-columns: 200px 1fr; gap: 12px; align-items: start; }
.markdown-preview {
border: 1px solid var(--color-secondary, #ccc);
padding: 8px;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #fff);
min-height: 80px;
}
.event-state-running { color: var(--color-success, #0a0); }
.event-state-countdown { color: var(--color-warning, #fa0); }
.event-state-stopped { color: var(--color-danger, #f00); }
.event-state-unconfigured { color: var(--color-secondary, #888); }
.field-error { color: var(--color-danger, #f00); font-size: 12px; }
`],
template: `
<section class="general-section" data-testid="admin-general">
<h2>General settings</h2>
@if (loading()) {
<p data-testid="general-loading">Loading settings...</p>
} @else if (loadError()) {
<p data-testid="general-error" class="field-error">{{ loadError() }}</p>
} @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()" data-testid="general-form">
<div class="form-grid">
<label for="pageTitle">Page title</label>
<div>
<input id="pageTitle" type="text" formControlName="pageTitle" data-testid="general-pageTitle" />
</div>
<label for="logo">Logo</label>
<div>
<input
id="logo"
type="file"
accept="image/*"
(change)="onLogoFileChange($event)"
data-testid="general-logo-file"
/>
@if (form.controls.logo.value) {
<div style="font-size: 12px; opacity: 0.7;" data-testid="general-logo-current">
Current: {{ form.controls.logo.value }}
</div>
}
@if (uploadingLogo()) {
<span data-testid="general-logo-uploading">Uploading…</span>
}
@if (logoUploadError()) {
<span class="field-error" data-testid="general-logo-error">{{ logoUploadError() }}</span>
}
<!-- Hidden bound input so the existing reactive-form contract is preserved;
the publicUrl returned from the upload lands here and is what save() submits. -->
<input type="hidden" formControlName="logo" data-testid="general-logo" />
</div>
<label for="themeKey">Global theme</label>
<div>
<select id="themeKey" formControlName="themeKey" data-testid="general-themeKey">
@for (t of themes(); track t.id) {
<option [value]="t.key">{{ t.name }}</option>
}
</select>
</div>
<label for="eventStartUtc">Event start (UTC)</label>
<div>
<input id="eventStartUtc" type="datetime-local" formControlName="eventStartUtc" data-testid="general-eventStart" />
</div>
<label for="eventEndUtc">Event end (UTC)</label>
<div>
<input id="eventEndUtc" type="datetime-local" formControlName="eventEndUtc" data-testid="general-eventEnd" />
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) {
<div class="field-error" data-testid="general-endBeforeStart">Event end must be after event start.</div>
}
</div>
<label for="defaultChallengeIp">Default challenge IP</label>
<div>
<input id="defaultChallengeIp" type="text" formControlName="defaultChallengeIp" data-testid="general-defaultIp" />
</div>
<label for="registrationsEnabled">Enable registrations</label>
<div>
<input
id="registrationsEnabled"
type="checkbox"
formControlName="registrationsEnabled"
data-testid="general-registrations"
/>
</div>
<label>Welcome description</label>
<div>
<textarea
formControlName="welcomeMarkdown"
rows="6"
cols="60"
data-testid="general-welcome"
></textarea>
<div
class="markdown-preview"
data-testid="general-welcome-preview"
[innerHTML]="previewHtml()"
></div>
</div>
<label>Event controls</label>
<div>
<button type="button" disabled data-testid="general-event-toggle">
{{ eventStateLabel() }} (derived from timestamps)
</button>
<p style="font-size:12px; opacity:0.7;">
State is derived from the configured UTC timestamps above. Adjust Event End to "stop" the event; move Event Start into the past and Event End into the future to "start" it.
</p>
</div>
</div>
<div style="margin-top: 16px;">
<button type="submit" [disabled]="submitting() || form.invalid" data-testid="general-save">Save</button>
@if (saveError()) {
<span class="field-error" data-testid="general-save-error">{{ saveError() }}</span>
}
@if (saveOk()) {
<span data-testid="general-save-ok">Saved.</span>
}
</div>
</form>
}
<app-admin-categories />
</section>
`,
})
export class AdminGeneralComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly admin = inject(AdminService);
private readonly markdown = inject(MarkdownService);
private readonly destroyRef = inject(DestroyRef);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly submitting = signal(false);
readonly saveError = signal<string | null>(null);
readonly saveOk = signal(false);
readonly themes = signal<ThemeView[]>([]);
readonly uploadingLogo = signal(false);
readonly logoUploadError = signal<string | null>(null);
readonly form = this.fb.nonNullable.group(
{
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]),
logo: this.fb.nonNullable.control(''),
welcomeMarkdown: this.fb.nonNullable.control(''),
themeKey: this.fb.nonNullable.control('classic'),
eventStartUtc: this.fb.nonNullable.control(''),
eventEndUtc: this.fb.nonNullable.control(''),
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]),
registrationsEnabled: this.fb.nonNullable.control(false),
},
{ validators: endAfterStartValidator },
);
readonly previewHtml = signal<string>('');
readonly eventStateLabel = computed(() => {
const startRaw = this.form.controls.eventStartUtc.value;
const endRaw = this.form.controls.eventEndUtc.value;
const state = deriveEventState(startRaw, endRaw);
return state.toUpperCase();
});
constructor() {
this.form.controls.welcomeMarkdown.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => this.previewHtml.set(this.markdown.render(v)));
}
async ngOnInit(): Promise<void> {
try {
const [settings, themes] = await Promise.all([
this.admin.getGeneralSettings(),
this.admin.listAdminThemes(),
]);
this.applySettings(settings);
this.themes.set(themes);
this.previewHtml.set(this.markdown.render(settings.welcomeMarkdown));
this.loading.set(false);
} catch (e: any) {
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load settings');
this.loading.set(false);
}
}
private applySettings(s: GeneralSettings): void {
this.form.patchValue({
pageTitle: s.pageTitle,
logo: s.logo,
welcomeMarkdown: s.welcomeMarkdown,
themeKey: s.themeKey,
eventStartUtc: toDatetimeLocal(s.eventStartUtc),
eventEndUtc: toDatetimeLocal(s.eventEndUtc),
defaultChallengeIp: s.defaultChallengeIp,
registrationsEnabled: s.registrationsEnabled,
});
}
async onSubmit(): Promise<void> {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.submitting.set(true);
this.saveError.set(null);
this.saveOk.set(false);
try {
const v = this.form.getRawValue();
const updated = await this.admin.updateGeneralSettings({
pageTitle: v.pageTitle,
logo: v.logo,
welcomeMarkdown: v.welcomeMarkdown,
themeKey: v.themeKey,
eventStartUtc: toIsoUtc(v.eventStartUtc),
eventEndUtc: toIsoUtc(v.eventEndUtc),
defaultChallengeIp: v.defaultChallengeIp,
registrationsEnabled: v.registrationsEnabled,
});
this.applySettings(updated);
this.saveOk.set(true);
} catch (e: any) {
this.saveError.set(e?.error?.message ?? e?.message ?? 'Failed to save');
} finally {
this.submitting.set(false);
}
}
async onLogoFileChange(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.uploadingLogo.set(true);
this.logoUploadError.set(null);
try {
const res = await this.admin.uploadLogo(file);
this.form.controls.logo.setValue(res.publicUrl);
} catch (e: any) {
this.logoUploadError.set(e?.error?.message ?? e?.message ?? 'Logo upload failed');
} finally {
this.uploadingLogo.set(false);
}
}
}
@@ -0,0 +1,39 @@
export type EventDerivedState = 'running' | 'countdown' | 'stopped' | 'unconfigured';
export function deriveEventState(start: string, end: string): EventDerivedState {
if (!start || !end) return 'unconfigured';
const s = Date.parse(start);
const e = Date.parse(end);
if (!Number.isFinite(s) || !Number.isFinite(e)) return 'unconfigured';
const now = Date.now();
if (now < s) return 'countdown';
if (now >= s && now < e) return 'running';
return 'stopped';
}
export function endAfterStartValidator(group: any): { endBeforeStart: true } | null {
const start = group.get?.('eventStartUtc')?.value;
const end = group.get?.('eventEndUtc')?.value;
if (!start || !end) return null;
const s = Date.parse(start);
const e = Date.parse(end);
if (Number.isFinite(s) && Number.isFinite(e) && e <= s) {
return { endBeforeStart: true };
}
return null;
}
export function toDatetimeLocal(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
export function toIsoUtc(local: string): string {
if (!local) return '';
const d = new Date(local);
if (Number.isNaN(d.getTime())) return local;
return d.toISOString();
}