324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
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 {
|
|
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: `
|
|
<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" />
|
|
@if (showPageTitleError()) {
|
|
<div class="field-error" data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>
|
|
}
|
|
</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 bootstrap = inject(BootstrapService);
|
|
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);
|
|
|
|
private readonly pageTitleValue = signal<string>('');
|
|
private readonly pageTitleInvalid = signal<boolean>(false);
|
|
private readonly pageTitleTouchedOrDirty = signal<boolean>(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<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();
|
|
});
|
|
|
|
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<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 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<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: 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<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);
|
|
}
|
|
}
|
|
}
|
|
|