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, normalizePageTitle, pageTitleMessage, 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 (showPageTitleError()) {
{{ pageTitleMessage() }}
}
@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); private readonly pageTitleValue = signal(''); private readonly pageTitleInvalid = signal(false); private readonly pageTitleTouchedOrDirty = signal(false); readonly form = this.fb.nonNullable.group( { pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), 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(); }); readonly showPageTitleError = computed( () => this.pageTitleTouchedOrDirty() && this.pageTitleInvalid(), ); readonly pageTitleMessage = computed(() => pageTitleMessage(this.pageTitleValue(), this.form.controls.pageTitle.errors), ); constructor() { this.form.controls.welcomeMarkdown.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((v) => this.previewHtml.set(this.markdown.render(v))); const pt = this.form.controls.pageTitle; this.pageTitleValue.set(pt.value); this.pageTitleInvalid.set(pt.invalid); this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); pt.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((v) => { this.pageTitleValue.set(v); this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); }); pt.statusChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.pageTitleInvalid.set(pt.invalid); this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); }); } 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 pageTitleNotBlankValidator(): (ctrl: { value: string | null }) => { whitespace: true } | null { return (ctrl) => { const value = ctrl?.value ?? ''; if (value.length === 0) return null; if (value.trim().length === 0) return { whitespace: true }; return null; }; } 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, }); this.form.controls.pageTitle.markAsUntouched(); this.form.controls.pageTitle.markAsPristine(); this.pageTitleTouchedOrDirty.set(false); } 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: normalizePageTitle(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); } } }