feat: Admin Area General Settings and Categories

This commit is contained in:
OpenVelo Agent
2026-07-22 11:45:21 +00:00
parent de527ec6d6
commit f4648a3730
34 changed files with 7349 additions and 62 deletions
@@ -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);
}
}
}