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: `

General settings

@if (loading()) {

Loading settings...

} @else if (loadError()) {

{{ loadError() }}

} @else {
@if (form.controls.logo.value) {
Current: {{ form.controls.logo.value }}
} @if (uploadingLogo()) { Uploading… } @if (logoUploadError()) { {{ logoUploadError() }} }
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) {
Event end must be after event start.
}

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.

@if (saveError()) { {{ saveError() }} } @if (saveOk()) { Saved. }
}
`, }) 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(null); readonly submitting = signal(false); readonly saveError = signal(null); readonly saveOk = signal(false); readonly themes = signal([]); readonly uploadingLogo = signal(false); readonly logoUploadError = signal(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(''); 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 { 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 { 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 { 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); } } }