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 { BootstrapService } from '../../core/services/bootstrap.service'; import { MarkdownService } from '../../core/services/markdown.service'; import { AdminCategoriesComponent } from './categories/categories.component'; import { datetimeMessage, deriveEventState, endAfterStartValidator, endBeforeStartMessage, eventEndFieldMessage, isoDatetimeValidator, 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 { Page title @if (showPageTitleError()) { {{ pageTitleMessage() }} } Logo @if (form.controls.logo.value) { Current: {{ form.controls.logo.value }} } @if (uploadingLogo()) { Uploading… } @if (logoUploadError()) { {{ logoUploadError() }} } Global theme @for (t of themes(); track t.id) { {{ t.name }} } Event start (UTC) @if (showEventStartError()) { {{ eventStartMessage() }} } Event end (UTC) @if (eventEndFieldMessageText(); as msg) { {{ msg }} } Default challenge IP Enable registrations Welcome description Event controls {{ eventStateLabel() }} (derived from timestamps) 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. Save @if (saveError()) { {{ saveError() }} } @if (saveOk()) { Saved. } } `, }) export class AdminGeneralComponent implements OnInit { private readonly fb = inject(FormBuilder); private readonly admin = inject(AdminService); private readonly bootstrap = inject(BootstrapService); 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); private readonly eventStartValue = signal(''); private readonly eventStartInvalid = signal(false); private readonly eventStartTouchedOrDirty = signal(false); private readonly eventEndValue = signal(''); private readonly eventEndInvalid = signal(false); private readonly eventEndTouchedOrDirty = 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('', [isoDatetimeValidator]), eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), 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), ); readonly showEventStartError = computed( () => this.eventStartTouchedOrDirty() && this.eventStartInvalid(), ); readonly eventStartMessage = computed(() => datetimeMessage('Event start', this.eventStartValue(), this.form.controls.eventStartUtc.errors), ); readonly showEventEndError = computed( () => this.eventEndTouchedOrDirty() && this.eventEndInvalid(), ); readonly eventEndMessage = computed(() => datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors), ); readonly eventEndFieldMessageText = computed(() => { const fieldErrs = this.eventEndTouchedOrDirty() ? this.form.controls.eventEndUtc.errors : null; const crossErrs = this.form.errors; return eventEndFieldMessage(fieldErrs, crossErrs); }); 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); }); const es = this.form.controls.eventStartUtc; this.eventStartValue.set(es.value); this.eventStartInvalid.set(es.invalid); this.eventStartTouchedOrDirty.set(es.touched || es.dirty); es.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((v) => { this.eventStartValue.set(v); this.eventStartTouchedOrDirty.set(es.touched || es.dirty); }); es.statusChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.eventStartInvalid.set(es.invalid); this.eventStartTouchedOrDirty.set(es.touched || es.dirty); }); const ee = this.form.controls.eventEndUtc; this.eventEndValue.set(ee.value); this.eventEndInvalid.set(ee.invalid); this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); ee.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((v) => { this.eventEndValue.set(v); this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); }); ee.statusChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.eventEndInvalid.set(ee.invalid); this.eventEndTouchedOrDirty.set(ee.touched || ee.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); this.form.controls.eventStartUtc.markAsUntouched(); this.form.controls.eventStartUtc.markAsPristine(); this.eventStartTouchedOrDirty.set(false); this.form.controls.eventEndUtc.markAsUntouched(); this.form.controls.eventEndUtc.markAsPristine(); this.eventEndTouchedOrDirty.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); await this.bootstrap.refresh(); 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); } } }
Loading settings...
{{ loadError() }}
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.