AI Implementation feature(859): Admin Area General Settings and Categories (#21)
This commit was merged in pull request #21.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
|
||||
interface AdminNavEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ENTRIES: AdminNavEntry[] = [
|
||||
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
|
||||
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: false },
|
||||
{ id: 'players', label: 'Players', path: '/admin/players', enabled: false },
|
||||
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
|
||||
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-shell',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet],
|
||||
styles: [`
|
||||
.admin-shell { display: flex; gap: 16px; align-items: flex-start; }
|
||||
.admin-aside {
|
||||
min-width: 180px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
.admin-aside ul { list-style: none; padding: 0; margin: 0; }
|
||||
.admin-aside li {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #000);
|
||||
}
|
||||
.admin-aside li.active {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
.admin-aside li.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.admin-body { flex: 1; min-width: 0; }
|
||||
`],
|
||||
template: `
|
||||
<section class="admin-shell">
|
||||
<aside class="admin-aside" data-testid="admin-aside">
|
||||
<h2>Admin area</h2>
|
||||
<ul data-testid="admin-nav">
|
||||
@for (e of entries; track e.id) {
|
||||
<li
|
||||
[class.active]="isActive(e)"
|
||||
[class.disabled]="!e.enabled"
|
||||
[attr.data-testid]="'admin-nav-' + e.id"
|
||||
(click)="go(e)"
|
||||
>
|
||||
{{ e.label }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="admin-body" data-testid="admin-body">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AdminShellComponent {
|
||||
readonly entries = ENTRIES;
|
||||
private readonly router = inject(Router);
|
||||
|
||||
isActive(e: AdminNavEntry): boolean {
|
||||
return this.router.url.startsWith(e.path);
|
||||
}
|
||||
|
||||
go(e: AdminNavEntry): void {
|
||||
if (!e.enabled) return;
|
||||
void this.router.navigateByUrl(e.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminService, AdminCategory } from '../../../core/services/admin.service';
|
||||
import { CategoryFormModalComponent, CategoryFormSubmit } from './category-form-modal.component';
|
||||
import { CategoryDeleteModalComponent } from './category-delete-modal.component';
|
||||
|
||||
export type ModalMode = 'create' | 'edit';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-categories',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent],
|
||||
styles: [`
|
||||
.cat-list { list-style: none; padding: 0; margin: 0; }
|
||||
.cat-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc);
|
||||
}
|
||||
.cat-row .icon { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
|
||||
.cat-row .abbr { font-weight: bold; min-width: 60px; }
|
||||
.cat-row .desc { flex: 1; }
|
||||
.cat-row button { margin-left: 4px; }
|
||||
.header-bar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.error { color: var(--color-danger, #f00); }
|
||||
`],
|
||||
template: `
|
||||
<section data-testid="admin-categories">
|
||||
<div class="header-bar">
|
||||
<h2>Categories</h2>
|
||||
<button type="button" (click)="openCreate()" data-testid="cat-add">+</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<p data-testid="cat-loading">Loading categories...</p>
|
||||
} @else if (loadError()) {
|
||||
<p class="error" data-testid="cat-error">{{ loadError() }}</p>
|
||||
} @else {
|
||||
<ul class="cat-list" data-testid="cat-list">
|
||||
@for (c of categories(); track c.id) {
|
||||
<li class="cat-row" [attr.data-testid]="'cat-row-' + c.abbreviation">
|
||||
<img class="icon" [src]="c.iconPath || '/uploads/icons/placeholder.png'" alt="" />
|
||||
<span class="abbr">{{ c.abbreviation }}</span>
|
||||
<span class="desc">{{ c.description }}</span>
|
||||
<button type="button" (click)="openEdit(c)" [attr.data-testid]="'cat-edit-' + c.abbreviation">✎</button>
|
||||
<button type="button" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">🗑</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<app-category-form-modal
|
||||
[open]="modalMode() !== null"
|
||||
[mode]="modalMode() === 'edit' ? 'edit' : 'create'"
|
||||
[category]="editing()"
|
||||
(cancel)="closeModal()"
|
||||
(submit)="onFormSubmit($event)"
|
||||
/>
|
||||
|
||||
<app-category-delete-modal
|
||||
[open]="deleteOpen()"
|
||||
[category]="deleting()"
|
||||
[errorMessage]="deleteError()"
|
||||
(cancel)="closeModal()"
|
||||
(confirm)="onDeleteConfirm()"
|
||||
/>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AdminCategoriesComponent implements OnInit {
|
||||
private readonly admin = inject(AdminService);
|
||||
|
||||
readonly categories = signal<AdminCategory[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
|
||||
readonly modalMode = signal<ModalMode | null>(null);
|
||||
readonly deleteOpen = signal(false);
|
||||
readonly editing = signal<AdminCategory | null>(null);
|
||||
readonly deleting = signal<AdminCategory | null>(null);
|
||||
readonly deleteError = signal<string | null>(null);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
try {
|
||||
const list = await this.admin.listCategories();
|
||||
list.sort((a, b) => a.abbreviation.toLowerCase().localeCompare(b.abbreviation.toLowerCase()));
|
||||
this.categories.set(list);
|
||||
} catch (e: any) {
|
||||
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
openCreate(): void {
|
||||
this.editing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.deleteError.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.modalMode.set('create');
|
||||
}
|
||||
|
||||
openEdit(c: AdminCategory): void {
|
||||
this.deleting.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.editing.set(c);
|
||||
this.modalMode.set('edit');
|
||||
}
|
||||
|
||||
openDelete(c: AdminCategory): void {
|
||||
this.editing.set(null);
|
||||
this.modalMode.set(null);
|
||||
this.deleting.set(c);
|
||||
this.deleteError.set(null);
|
||||
this.deleteOpen.set(true);
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
this.modalMode.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.editing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.deleteError.set(null);
|
||||
}
|
||||
|
||||
async onFormSubmit(payload: CategoryFormSubmit): Promise<void> {
|
||||
try {
|
||||
if (payload.mode === 'create') {
|
||||
const created = await this.admin.createCategory({
|
||||
name: payload.name,
|
||||
abbreviation: payload.abbreviation,
|
||||
description: payload.description,
|
||||
iconPath: payload.iconPath,
|
||||
});
|
||||
if (payload.iconFile && created.id) {
|
||||
const r = await this.admin.uploadCategoryIcon(created.id, payload.iconFile);
|
||||
await this.admin.updateCategory(created.id, { iconPath: r.publicUrl });
|
||||
}
|
||||
} else if (payload.mode === 'edit' && this.editing()) {
|
||||
const id = this.editing()!.id;
|
||||
let iconPath = payload.iconPath;
|
||||
if (payload.iconFile) {
|
||||
const r = await this.admin.uploadCategoryIcon(id, payload.iconFile);
|
||||
iconPath = r.publicUrl;
|
||||
}
|
||||
await this.admin.updateCategory(id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
iconPath,
|
||||
});
|
||||
}
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
} catch (e: any) {
|
||||
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed');
|
||||
}
|
||||
}
|
||||
|
||||
async onDeleteConfirm(): Promise<void> {
|
||||
const c = this.deleting();
|
||||
if (!c) return;
|
||||
try {
|
||||
await this.admin.deleteCategory(c.id);
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
} catch (e: any) {
|
||||
const code = e?.error?.code ?? e?.code;
|
||||
const msg = e?.error?.message ?? e?.message ?? 'Failed';
|
||||
if (code === 'CATEGORY_HAS_CHALLENGES') {
|
||||
this.deleteError.set(`Cannot delete: category has ${e?.error?.details?.count ?? ''} challenge(s) attached.`);
|
||||
} else if (code === 'SYSTEM_PROTECTED') {
|
||||
this.deleteError.set('System categories cannot be deleted.');
|
||||
} else {
|
||||
this.deleteError.set(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly deleteErrorText = computed(() => this.deleteError());
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminCategory } from '../../../core/services/admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-delete-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 320px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.note { color: var(--color-warning, #fa0); margin-top: 8px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="cat-delete-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()" data-testid="cat-delete-modal">
|
||||
<h3>Delete category</h3>
|
||||
@if (category(); as c) {
|
||||
@if (c.isSystem) {
|
||||
<p>This is a system category and cannot be deleted.</p>
|
||||
} @else {
|
||||
<p>Delete <b>{{ c.name }}</b> ({{ c.abbreviation }})? This cannot be undone.</p>
|
||||
}
|
||||
}
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="cat-delete-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="cat-delete-cancel">Cancel</button>
|
||||
@if (canConfirm()) {
|
||||
<button type="button" (click)="onConfirm()" data-testid="cat-delete-ok">OK</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CategoryDeleteModalComponent {
|
||||
readonly open = input(false);
|
||||
readonly category = input<AdminCategory | null>(null);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly confirm = output<void>();
|
||||
|
||||
readonly canConfirm = computed(() => {
|
||||
const c = this.category();
|
||||
return !!c && !c.isSystem;
|
||||
});
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
this.confirm.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AdminCategory } from '../../../core/services/admin.service';
|
||||
|
||||
export type CategoryFormMode = 'create' | 'edit';
|
||||
|
||||
export interface CategoryFormSubmit {
|
||||
mode: CategoryFormMode;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
description: string;
|
||||
iconPath?: string;
|
||||
iconFile?: File;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-form-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
|
||||
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="cat-form-backdrop" (click)="onCancel()">
|
||||
<form class="modal" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal">
|
||||
<h3>{{ mode() === 'create' ? 'Add category' : 'Edit category' }}</h3>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-name">Name</label>
|
||||
<input id="cf-name" type="text" formControlName="name" data-testid="cf-name" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-abbr">Abbreviation (uppercase)</label>
|
||||
<input id="cf-abbr" type="text" formControlName="abbreviation"
|
||||
[attr.readonly]="abbreviationReadonly() ? '' : null"
|
||||
data-testid="cf-abbr" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-desc">Description</label>
|
||||
<textarea id="cf-desc" rows="3" formControlName="description" data-testid="cf-desc"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-icon">Icon (image, will be normalized to 128x128)</label>
|
||||
<input id="cf-icon" type="file" accept="image/*" (change)="onFile($event)" data-testid="cf-icon" />
|
||||
@if (iconPreview()) {
|
||||
<img [src]="iconPreview()" alt="icon preview" style="width:64px;height:64px;object-fit:cover;border-radius:4px;" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="cf-cancel">Cancel</button>
|
||||
<button type="button" [disabled]="form.invalid || submitting()" (click)="onOk()" data-testid="cf-ok">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CategoryFormModalComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly open = input(false);
|
||||
readonly mode = input<CategoryFormMode>('create');
|
||||
readonly category = input<AdminCategory | null>(null);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly submit = output<CategoryFormSubmit>();
|
||||
|
||||
readonly iconPreview = signal<string | null>(null);
|
||||
readonly iconFile = signal<File | null>(null);
|
||||
readonly submitting = signal(false);
|
||||
readonly abbreviationReadonly = signal(false);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]),
|
||||
abbreviation: this.fb.nonNullable.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(6)]),
|
||||
description: this.fb.nonNullable.control(''),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const c = this.category();
|
||||
const m = this.mode();
|
||||
if (m === 'edit' && c) {
|
||||
this.form.patchValue({
|
||||
name: c.name,
|
||||
abbreviation: c.abbreviation,
|
||||
description: c.description,
|
||||
});
|
||||
this.abbreviationReadonly.set(c.isSystem);
|
||||
this.iconPreview.set(c.iconPath || null);
|
||||
} else {
|
||||
this.form.patchValue({ name: '', abbreviation: '', description: '' });
|
||||
this.abbreviationReadonly.set(false);
|
||||
this.iconPreview.set(null);
|
||||
}
|
||||
this.iconFile.set(null);
|
||||
});
|
||||
}
|
||||
|
||||
onFile(ev: Event): void {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const f = input.files?.[0];
|
||||
if (!f) return;
|
||||
this.iconFile.set(f);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => this.iconPreview.set(String(reader.result));
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onOk(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
const v = this.form.getRawValue();
|
||||
const abbr = v.abbreviation.toUpperCase();
|
||||
this.submit.emit({
|
||||
mode: this.mode(),
|
||||
name: v.name,
|
||||
abbreviation: abbr,
|
||||
description: v.description,
|
||||
iconPath: this.iconPreview()?.startsWith('data:') ? undefined : (this.category()?.iconPath ?? ''),
|
||||
iconFile: this.iconFile() ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type EventDerivedState = 'running' | 'countdown' | 'stopped' | 'unconfigured';
|
||||
|
||||
export function deriveEventState(start: string, end: string): EventDerivedState {
|
||||
if (!start || !end) return 'unconfigured';
|
||||
const s = Date.parse(start);
|
||||
const e = Date.parse(end);
|
||||
if (!Number.isFinite(s) || !Number.isFinite(e)) return 'unconfigured';
|
||||
const now = Date.now();
|
||||
if (now < s) return 'countdown';
|
||||
if (now >= s && now < e) return 'running';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
export function endAfterStartValidator(group: any): { endBeforeStart: true } | null {
|
||||
const start = group.get?.('eventStartUtc')?.value;
|
||||
const end = group.get?.('eventEndUtc')?.value;
|
||||
if (!start || !end) return null;
|
||||
const s = Date.parse(start);
|
||||
const e = Date.parse(end);
|
||||
if (Number.isFinite(s) && Number.isFinite(e) && e <= s) {
|
||||
return { endBeforeStart: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toDatetimeLocal(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function toIsoUtc(local: string): string {
|
||||
if (!local) return '';
|
||||
const d = new Date(local);
|
||||
if (Number.isNaN(d.getTime())) return local;
|
||||
return d.toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user