feat: Admin Area Challenges: List, Import/Export and Add/Edit Modal

This commit is contained in:
OpenVelo Agent
2026-07-22 20:26:27 +00:00
parent 6c18603a7d
commit 216795b5db
33 changed files with 4256 additions and 105 deletions
+5
View File
@@ -54,6 +54,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () =>
import('./features/admin/categories/categories.component').then((m) => m.AdminCategoriesComponent),
},
{
path: 'challenges',
loadComponent: () =>
import('./features/admin/challenges/challenges.component').then((m) => m.AdminChallengesComponent),
},
],
},
],
@@ -63,6 +63,110 @@ export interface LogoUploadResponse {
originalFilename: string;
}
export interface AdminChallengeFile {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface AdminChallengeListItem {
id: string;
name: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
protocol: 'NC' | 'WEB';
enabled: boolean;
createdAt: string;
updatedAt: string;
fileCount: number;
solveCount: number;
}
export interface AdminChallengeDetail {
id: string;
name: string;
descriptionMd: string;
categoryId: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
files: AdminChallengeFile[];
}
export interface ChallengeFileManifestPayload {
id?: string;
stagedToken?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
remove?: boolean;
}
export interface CreateChallengeBody {
name: string;
descriptionMd: string;
categoryId: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
files?: ChallengeFileManifestPayload[];
}
export interface UpdateChallengeBody {
name?: string;
descriptionMd?: string;
categoryId?: string;
difficulty?: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints?: number;
minimumPoints?: number;
decaySolves?: number;
flag?: string;
protocol?: 'NC' | 'WEB';
port?: number | null;
ipAddress?: string;
enabled?: boolean;
files?: ChallengeFileManifestPayload[];
}
export interface ImportPreviewResponse {
total: number;
conflicts: string[];
unknownCategories: string[];
warnings: string[];
}
export interface ImportResultResponse {
imported: number;
skipped: { name: string; reason: string }[];
warnings: string[];
}
export interface StageChallengeFileResponse {
token: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}
@Injectable({ providedIn: 'root' })
export class AdminService {
private readonly http = inject(HttpClient);
@@ -139,4 +243,99 @@ export class AdminService {
this.http.post<LogoUploadResponse>('/api/v1/uploads/logo', fd, { withCredentials: true }),
);
}
async listChallenges(query: { q?: string; categoryId?: string } = {}): Promise<AdminChallengeListItem[]> {
const params: Record<string, string> = {};
if (query.q && query.q.trim().length > 0) params['q'] = query.q.trim();
if (query.categoryId) params['categoryId'] = query.categoryId;
return firstValueFrom(
this.http.get<AdminChallengeListItem[]>('/api/v1/admin/challenges', {
params,
withCredentials: true,
}),
);
}
async getChallengeDetail(id: string): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.get<AdminChallengeDetail>(
`/api/v1/admin/challenges/${encodeURIComponent(id)}`,
{ withCredentials: true },
),
);
}
async createChallenge(body: CreateChallengeBody): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.post<AdminChallengeDetail>('/api/v1/admin/challenges', body, {
withCredentials: true,
}),
);
}
async updateChallenge(id: string, body: UpdateChallengeBody): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.put<AdminChallengeDetail>(
`/api/v1/admin/challenges/${encodeURIComponent(id)}`,
body,
{ withCredentials: true },
),
);
}
async deleteChallenge(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/challenges/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
);
}
async exportChallenges(): Promise<Blob> {
return firstValueFrom(
this.http.get('/api/v1/admin/challenges/export', {
withCredentials: true,
responseType: 'blob',
}),
);
}
async previewChallengeImport(doc: unknown): Promise<ImportPreviewResponse> {
return firstValueFrom(
this.http.post<ImportPreviewResponse>('/api/v1/admin/challenges/import', doc, {
withCredentials: true,
}),
);
}
async confirmChallengeImport(doc: unknown): Promise<ImportResultResponse> {
return firstValueFrom(
this.http.post<ImportResultResponse>(
'/api/v1/admin/challenges/import?confirm=overwrite',
doc,
{ withCredentials: true },
),
);
}
async stageChallengeFile(file: File): Promise<StageChallengeFileResponse> {
const fd = new FormData();
fd.append('file', file);
return firstValueFrom(
this.http.post<StageChallengeFileResponse>(
'/api/v1/admin/challenges/files/stage',
fd,
{ withCredentials: true },
),
);
}
async discardChallengeFile(token: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(
`/api/v1/admin/challenges/files/stage/${encodeURIComponent(token)}`,
{ withCredentials: true },
),
);
}
}
@@ -11,7 +11,7 @@ interface AdminNavEntry {
const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: false },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ 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 },
@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminChallengeListItem } from '../../../core/services/admin.service';
@Component({
selector: 'app-challenge-delete-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="challenge-delete-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="cd-title" data-testid="challenge-delete-modal">
<h3 id="cd-title">Delete challenge</h3>
@if (challenge(); as c) {
<p>Delete challenge '<b>{{ c.name }}</b>'? This will also remove all files and solve records associated with it.</p>
}
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="challenge-delete-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-delete-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-delete-ok">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Delete
</button>
</div>
</div>
</div>
}
`,
})
export class ChallengeDeleteModalComponent {
readonly open = input(false);
readonly challenge = input<AdminChallengeListItem | null>(null);
readonly errorMessage = input<string | null>(null);
readonly submitting = input(false);
readonly cancel = output<void>();
readonly confirm = output<void>();
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting()) return;
this.confirm.emit();
}
}
@@ -0,0 +1,556 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
HostListener,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AdminCategory,
AdminChallengeDetail,
AdminChallengeFile,
AdminService,
} from '../../../core/services/admin.service';
import { MarkdownService } from '../../../core/services/markdown.service';
import {
ChallengeFormDefaults,
ChallengeFormErrors,
ChallengeFormFileItem,
ChallengeStagedFileUi,
buildInitialFiles,
defaultMinimumPoints,
deriveMinimumPoints,
emptyChallengeFormDefaults,
prefillChallengeFormDefaults,
toCreateBody,
validateChallengeForm,
} from './challenge-form.pure';
export type ChallengeFormMode = 'create' | 'edit';
export interface ChallengeFormSubmitPayload {
body: ReturnType<typeof toCreateBody>;
}
interface ChallengeFormControls {
name: FormControl<string>;
descriptionMd: FormControl<string>;
categoryId: FormControl<string>;
difficulty: FormControl<'LOW' | 'MEDIUM' | 'HIGH'>;
initialPoints: FormControl<number>;
minimumPoints: FormControl<number>;
decaySolves: FormControl<number>;
flag: FormControl<string>;
protocol: FormControl<'NC' | 'WEB'>;
port: FormControl<number | null>;
ipAddress: FormControl<string>;
enabled: FormControl<boolean>;
}
export function buildChallengeFormGroup(fb: FormBuilder): FormGroup<ChallengeFormControls> {
return fb.nonNullable.group({
name: fb.nonNullable.control('', [Validators.required, Validators.maxLength(128)]),
descriptionMd: fb.nonNullable.control('', [Validators.required]),
categoryId: fb.nonNullable.control('', [Validators.required]),
difficulty: fb.nonNullable.control<'LOW' | 'MEDIUM' | 'HIGH'>('MEDIUM'),
initialPoints: fb.nonNullable.control(100, [Validators.required, Validators.min(1), Validators.max(100000)]),
minimumPoints: fb.nonNullable.control(50, [Validators.required, Validators.min(1)]),
decaySolves: fb.nonNullable.control(10, [Validators.required, Validators.min(1), Validators.max(10000)]),
flag: fb.nonNullable.control('', [Validators.required]),
protocol: fb.nonNullable.control<'NC' | 'WEB'>('WEB'),
port: fb.nonNullable.control<number | null>(null),
ipAddress: fb.nonNullable.control(''),
enabled: fb.nonNullable.control(true),
});
}
export function syncChallengeForm(
form: FormGroup<ChallengeFormControls>,
mode: ChallengeFormMode,
detail: AdminChallengeDetail | null,
): ChallengeFormDefaults {
if (mode === 'edit' && detail) {
const defaults = prefillChallengeFormDefaults(detail);
form.patchValue(
{
name: defaults.name,
descriptionMd: defaults.descriptionMd,
categoryId: defaults.categoryId,
difficulty: defaults.difficulty,
initialPoints: defaults.initialPoints,
minimumPoints: defaults.minimumPoints,
decaySolves: defaults.decaySolves,
flag: defaults.flag,
protocol: defaults.protocol,
port: defaults.port,
ipAddress: defaults.ipAddress,
enabled: defaults.enabled,
},
{ emitEvent: false },
);
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
form.controls[name].markAsPristine();
form.controls[name].markAsUntouched();
}
return defaults;
}
const empty = emptyChallengeFormDefaults();
form.reset({
name: empty.name,
descriptionMd: empty.descriptionMd,
categoryId: empty.categoryId,
difficulty: empty.difficulty,
initialPoints: empty.initialPoints,
minimumPoints: empty.minimumPoints,
decaySolves: empty.decaySolves,
flag: empty.flag,
protocol: empty.protocol,
port: empty.port,
ipAddress: empty.ipAddress,
enabled: empty.enabled,
});
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
form.controls[name].markAsPristine();
form.controls[name].markAsUntouched();
}
return empty;
}
@Component({
selector: 'app-challenge-form-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 60; padding: 16px; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(720px, 100%); max-height: 90vh; overflow: auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.tabs button { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--color-secondary, #ccc); background: transparent; cursor: pointer; }
.tabs button.active { background: var(--color-primary, #3b82f6); color: #fff; border-color: var(--color-primary, #3b82f6); }
.row { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
.row label { font-weight: 600; font-size: 13px; }
.row .error { color: var(--color-danger, #f00); font-size: 12px; }
.markdown-preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: 4px; min-height: 60px; }
.field-row { display: flex; gap: 8px; }
.field-row > * { flex: 1; }
.file-list { list-style: none; padding: 0; margin: 0; }
.file-list li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--color-secondary, #ccc); }
.file-list li .remove { margin-left: auto; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.flag-input { display: flex; gap: 4px; }
.flag-input input { flex: 1; }
.preview-toggle { font-size: 12px; }
.upload-error { color: var(--color-danger, #f00); font-size: 12px; }
`],
template: `
@if (open()) {
<div class="modal-overlay" role="presentation" (click)="onCancel()" data-testid="challenge-form-backdrop">
<form class="modal" role="dialog" aria-modal="true" [attr.aria-label]="mode() === 'create' ? 'Add challenge' : 'Edit challenge'" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="challenge-form-modal">
<h2>{{ mode() === 'create' ? 'Add challenge' : 'Edit challenge' }}</h2>
<div class="tabs" role="tablist">
<button type="button" role="tab" [class.active]="tab() === 'general'" [attr.aria-selected]="tab() === 'general'" (click)="setTab('general')" data-testid="cf-tab-general">General</button>
<button type="button" role="tab" [class.active]="tab() === 'connection'" [attr.aria-selected]="tab() === 'connection'" (click)="setTab('connection')" data-testid="cf-tab-connection">Connection</button>
<button type="button" role="tab" [class.active]="tab() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">Files</button>
</div>
<div [formGroup]="form">
@if (tab() === 'general') {
<div class="row">
<label for="cf-name">Name</label>
<input id="cf-name" type="text" formControlName="name" maxlength="128" data-testid="cf-name" />
@if (showError('name'); as msg) { <span class="error" data-testid="cf-error-name">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-desc">Description (Markdown)</label>
<textarea id="cf-desc" rows="4" formControlName="descriptionMd" data-testid="cf-desc"></textarea>
<button type="button" class="preview-toggle" (click)="togglePreview()" data-testid="cf-desc-preview-toggle">
{{ showPreview() ? 'Hide preview' : 'Preview' }}
</button>
@if (showPreview()) {
<div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div>
}
@if (showError('descriptionMd'); as msg) { <span class="error" data-testid="cf-error-description">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-category">Category</label>
<select id="cf-category" formControlName="categoryId" data-testid="cf-category">
<option value="">Select a category…</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.abbreviation }} — {{ cat.name }}</option>
}
</select>
@if (showError('categoryId'); as msg) { <span class="error" data-testid="cf-error-category">{{ msg }}</span> }
</div>
<div class="row">
<label>Difficulty</label>
<div role="radiogroup" aria-label="Difficulty">
@for (d of difficulties; track d) {
<label>
<input type="radio" [value]="d" formControlName="difficulty" [attr.data-testid]="'cf-difficulty-' + d" /> {{ d }}
</label>
}
</div>
</div>
<div class="field-row">
<div class="row">
<label for="cf-initial">Initial points</label>
<input id="cf-initial" type="number" min="1" max="100000" formControlName="initialPoints" data-testid="cf-initial" />
@if (showError('initialPoints'); as msg) { <span class="error" data-testid="cf-error-initial">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-minimum">Minimum points</label>
<input id="cf-minimum" type="number" min="1" max="100000" formControlName="minimumPoints" data-testid="cf-minimum" />
@if (showError('minimumPoints'); as msg) { <span class="error" data-testid="cf-error-minimum">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-decay">Decay solves</label>
<input id="cf-decay" type="number" min="1" max="10000" formControlName="decaySolves" data-testid="cf-decay" />
@if (showError('decaySolves'); as msg) { <span class="error" data-testid="cf-error-decay">{{ msg }}</span> }
</div>
</div>
<div class="row">
<label for="cf-flag">Secret flag</label>
<div class="flag-input">
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" data-testid="cf-flag" />
<button type="button" (click)="toggleFlag()" [attr.aria-label]="flagVisible() ? 'Hide flag' : 'Reveal flag'" data-testid="cf-flag-toggle">{{ flagVisible() ? '🙈' : '👁' }}</button>
</div>
@if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> }
</div>
}
@if (tab() === 'connection') {
<div class="row">
<label>Protocol</label>
<div role="radiogroup" aria-label="Protocol">
@for (p of protocols; track p) {
<label>
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }}
</label>
}
</div>
</div>
<div class="row">
<label for="cf-port">Port</label>
<input id="cf-port" type="number" min="1" max="65535" formControlName="port" data-testid="cf-port" />
@if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-ip">IP address (optional; falls back to Default Challenge IP)</label>
<input id="cf-ip" type="text" formControlName="ipAddress" data-testid="cf-ip" />
@if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
</div>
}
@if (tab() === 'files') {
<ul class="file-list" data-testid="cf-files">
@for (file of files(); track $index) {
<li>
<span>{{ file.originalFilename }}</span>
<small>{{ file.mimeType }} · {{ formatSize(file.sizeBytes) }}</small>
<button type="button" class="remove" (click)="removeFile(file)" [attr.aria-label]="'Remove ' + file.originalFilename" data-testid="cf-file-remove">🗑</button>
</li>
}
</ul>
<div class="row">
<label for="cf-upload">Upload files (any file type)</label>
<input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
@if (fileUploadError(); as err) {
<span class="upload-error" data-testid="cf-upload-error">{{ err }}</span>
}
<small>Limit: {{ formatSize(globalFileLimit()) }} per file</small>
</div>
}
@if (errorMessage(); as msg) {
<p class="error" data-testid="cf-error" role="alert" style="margin-top: 8px;">{{ msg }}</p>
}
</div>
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">Cancel</button>
<button type="button" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
{{ submitting() ? 'Saving…' : (mode() === 'create' ? 'Create' : 'Save') }}
</button>
</div>
</form>
</div>
}
`,
})
export class ChallengeFormModalComponent {
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
private readonly cdr = inject(ChangeDetectorRef);
private readonly markdown = inject(MarkdownService);
private readonly admin = inject(AdminService);
readonly open = input(false);
readonly mode = input<ChallengeFormMode>('create');
readonly detail = input<AdminChallengeDetail | null>(null);
readonly categories = input<AdminCategory[]>([]);
readonly globalFileLimit = input<number>(100 * 1024 * 1024);
readonly errorMessage = input<string | null>(null);
readonly fieldErrors = input<ChallengeFormErrors | Record<string, string> | null>(null);
readonly saving = input(false);
readonly cancel = output<void>();
readonly submit = output<ChallengeFormSubmitPayload>();
readonly form: FormGroup<ChallengeFormControls> = buildChallengeFormGroup(this.fb);
readonly tab = signal<'general' | 'connection' | 'files'>('general');
readonly flagVisible = signal(false);
readonly showPreview = signal(false);
readonly previewHtml = signal('');
readonly files = signal<ChallengeStagedFileUi[]>([]);
readonly fileUploadError = signal<string | null>(null);
readonly submitting = signal(false);
readonly difficulties = ['LOW', 'MEDIUM', 'HIGH'] as const;
readonly protocols = ['NC', 'WEB'] as const;
private minimumTouched = false;
private previousInitial = 100;
private pendingRemoval: string[] = [];
constructor() {
effect(
() => {
const result = syncChallengeForm(this.form, this.mode(), this.detail());
this.minimumTouched = false;
this.previousInitial = result.initialPoints;
this.flagVisible.set(false);
this.showPreview.set(false);
this.previewHtml.set(this.markdown.render(result.descriptionMd));
this.files.set(buildInitialFiles(this.detail()));
this.pendingRemoval = [];
this.fileUploadError.set(null);
this.submitting.set(false);
this.cdr.markForCheck();
},
{ allowSignalWrites: true },
);
this.form.controls.descriptionMd.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.previewHtml.set(this.markdown.render(v));
});
this.form.controls.initialPoints.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
const next = Number(value);
if (Number.isFinite(next)) {
const currentMin = this.form.controls.minimumPoints.value;
const newMin = deriveMinimumPoints(next, currentMin, this.previousInitial, this.minimumTouched);
this.form.controls.minimumPoints.setValue(newMin, { emitEvent: false });
this.previousInitial = next;
}
});
this.form.controls.minimumPoints.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.minimumTouched = true;
});
this.form.controls.protocol.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((p) => {
if (p === 'WEB') {
this.form.controls.port.setValue(null, { emitEvent: false });
} else if (p === 'NC' && (this.form.controls.port.value === null || this.form.controls.port.value === undefined)) {
this.form.controls.port.setValue(1337, { emitEvent: false });
}
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open()) this.onCancel();
}
setTab(tab: 'general' | 'connection' | 'files'): void {
this.tab.set(tab);
}
toggleFlag(): void {
this.flagVisible.set(!this.flagVisible());
}
togglePreview(): void {
this.showPreview.set(!this.showPreview());
}
showError(field: keyof ChallengeFormErrors): string | null {
const fromServer = (this.fieldErrors() ?? {})[field as string];
if (fromServer) return fromServer;
const ctrl = this.form.controls[field as keyof ChallengeFormControls];
if (!ctrl || (!ctrl.touched && !ctrl.dirty)) return null;
if (ctrl.errors?.['required']) {
return `${humanLabel(field)} is required`;
}
if (ctrl.errors?.['maxlength']) {
return `${humanLabel(field)} is too long`;
}
if (ctrl.errors?.['min']) {
return `${humanLabel(field)} must be at least ${ctrl.errors['min'].min}`;
}
if (ctrl.errors?.['max']) {
return `${humanLabel(field)} must be at most ${ctrl.errors['max'].max}`;
}
return null;
}
onCancel(): void {
// discard any pending staged tokens
for (const f of this.files()) {
if (f.kind === 'staged' && f.token) {
void this.admin.discardChallengeFile(f.token).catch(() => undefined);
}
}
this.files.set([]);
this.pendingRemoval = [];
this.cancel.emit();
}
async onFileChange(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const list = input.files ? Array.from(input.files) : [];
input.value = '';
this.fileUploadError.set(null);
for (const file of list) {
if (file.size > this.globalFileLimit()) {
this.fileUploadError.set(
`File '${file.name}' exceeds the size limit of ${this.formatSize(this.globalFileLimit())}.`,
);
continue;
}
const placeholder: ChallengeStagedFileUi = {
kind: 'staged',
token: undefined,
originalFilename: file.name,
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
uploading: true,
};
this.files.update((arr) => [...arr, placeholder]);
try {
const res = await this.admin.stageChallengeFile(file);
this.files.update((arr) =>
arr.map((entry) =>
entry === placeholder
? { ...entry, token: res.token, uploading: false }
: entry,
),
);
} catch (e: any) {
this.files.update((arr) => arr.filter((entry) => entry !== placeholder));
this.fileUploadError.set(
`Failed to upload '${file.name}': ${e?.error?.message ?? e?.message ?? 'unknown error'}`,
);
}
}
}
removeFile(file: ChallengeStagedFileUi): void {
if (file.kind === 'staged' && file.token) {
void this.admin.discardChallengeFile(file.token).catch(() => undefined);
this.files.update((arr) => arr.filter((f) => f !== file));
} else if (file.kind === 'existing' && file.id) {
this.pendingRemoval.push(file.id);
this.files.update((arr) => arr.filter((f) => f !== file));
}
}
onOk(): void {
if (this.files().some((f) => f.uploading)) {
this.fileUploadError.set('Wait for uploads to finish before saving.');
return;
}
const values: ChallengeFormDefaults = {
name: this.form.controls.name.value,
descriptionMd: this.form.controls.descriptionMd.value,
categoryId: this.form.controls.categoryId.value,
difficulty: this.form.controls.difficulty.value,
initialPoints: this.form.controls.initialPoints.value,
minimumPoints: this.form.controls.minimumPoints.value,
decaySolves: this.form.controls.decaySolves.value,
flag: this.form.controls.flag.value,
protocol: this.form.controls.protocol.value,
port: this.form.controls.port.value,
ipAddress: this.form.controls.ipAddress.value,
enabled: this.form.controls.enabled.value,
};
const clientErrors = validateChallengeForm(values);
if (Object.keys(clientErrors).length > 0) {
this.form.markAllAsTouched();
return;
}
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.form.markAllAsTouched();
const filePayload: ChallengeFormFileItem[] = this.files().map((f) => ({
id: f.kind === 'existing' ? f.id : undefined,
stagedToken: f.kind === 'staged' ? f.token : undefined,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
}));
for (const id of this.pendingRemoval) {
filePayload.push({
id,
originalFilename: '',
mimeType: '',
sizeBytes: 0,
remove: true,
});
}
const body = toCreateBody(values, filePayload);
this.submit.emit({ body });
}
formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
function humanLabel(field: keyof ChallengeFormErrors): string {
switch (field) {
case 'name': return 'Name';
case 'descriptionMd': return 'Description';
case 'categoryId': return 'Category';
case 'initialPoints': return 'Initial points';
case 'minimumPoints': return 'Minimum points';
case 'decaySolves': return 'Decay solves';
case 'flag': return 'Flag';
case 'port': return 'Port';
case 'ipAddress': return 'IP address';
case 'protocol': return 'Protocol';
default: return 'Field';
}
}
// Re-export so tests can find default helpers.
export { defaultMinimumPoints };
@@ -0,0 +1,242 @@
import {
AdminChallengeDetail,
AdminChallengeFile,
CreateChallengeBody,
UpdateChallengeBody,
} from '../../../core/services/admin.service';
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
export type Protocol = 'NC' | 'WEB';
export const DEFAULT_INITIAL_POINTS = 100;
export const DEFAULT_DECAY_SOLVES = 10;
export function defaultMinimumPoints(initial: number): number {
if (!Number.isFinite(initial) || initial < 1) return 1;
return Math.max(1, Math.floor(initial / 2));
}
export interface ChallengeFormDefaults {
name: string;
descriptionMd: string;
categoryId: string;
difficulty: Difficulty;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: Protocol;
port: number | null;
ipAddress: string;
enabled: boolean;
}
export function emptyChallengeFormDefaults(): ChallengeFormDefaults {
return {
name: '',
descriptionMd: '',
categoryId: '',
difficulty: 'MEDIUM',
initialPoints: DEFAULT_INITIAL_POINTS,
minimumPoints: defaultMinimumPoints(DEFAULT_INITIAL_POINTS),
decaySolves: DEFAULT_DECAY_SOLVES,
flag: '',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
};
}
export function prefillChallengeFormDefaults(detail: AdminChallengeDetail): ChallengeFormDefaults {
return {
name: detail.name,
descriptionMd: detail.descriptionMd,
categoryId: detail.categoryId,
difficulty: detail.difficulty,
initialPoints: detail.initialPoints,
minimumPoints: detail.minimumPoints,
decaySolves: detail.decaySolves,
flag: detail.flag,
protocol: detail.protocol,
port: detail.port,
ipAddress: detail.ipAddress,
enabled: detail.enabled,
};
}
export interface ChallengeFormErrors {
name?: string;
descriptionMd?: string;
categoryId?: string;
initialPoints?: string;
minimumPoints?: string;
decaySolves?: string;
flag?: string;
protocol?: string;
port?: string;
ipAddress?: string;
}
export function validateChallengeForm(values: ChallengeFormDefaults): ChallengeFormErrors {
const errs: ChallengeFormErrors = {};
if (!values.name.trim()) errs.name = 'Name is required';
else if (values.name.length > 128) errs.name = 'Name must be 128 characters or fewer';
if (!values.descriptionMd.trim()) errs.descriptionMd = 'Description is required';
if (!values.categoryId) errs.categoryId = 'Category is required';
if (!Number.isInteger(values.initialPoints) || values.initialPoints < 1 || values.initialPoints > 100000) {
errs.initialPoints = 'Initial points must be between 1 and 100000';
}
if (!Number.isInteger(values.minimumPoints) || values.minimumPoints < 1) {
errs.minimumPoints = 'Minimum points must be at least 1';
} else if (values.minimumPoints > values.initialPoints) {
errs.minimumPoints = 'Minimum points must be less than or equal to initial points';
}
if (!Number.isInteger(values.decaySolves) || values.decaySolves < 1 || values.decaySolves > 10000) {
errs.decaySolves = 'Decay solves must be between 1 and 10000';
}
if (!values.flag.trim()) errs.flag = 'Flag is required';
if (values.protocol === 'NC') {
if (values.port === null || values.port === undefined || !Number.isInteger(values.port)) {
errs.port = 'Port is required for NC';
} else if (values.port < 1 || values.port > 65535) {
errs.port = 'Port must be between 1 and 65535';
}
} else if (values.protocol === 'WEB' && values.port !== null && values.port !== undefined) {
if (!Number.isInteger(values.port) || values.port < 1 || values.port > 65535) {
errs.port = 'Port must be between 1 and 65535';
}
}
if (values.ipAddress && !isValidIpOrHostname(values.ipAddress)) {
errs.ipAddress = 'Must be a valid IPv4/IPv6 address or hostname';
}
return errs;
}
const IPV4_PART = /^(0|[1-9][0-9]{0,2})$/;
export function isValidIpOrHostname(value: string): boolean {
if (!value) return true;
if (value.length > 255) return false;
if (value.includes(':')) {
return /^[0-9A-Fa-f:.]+$/.test(value);
}
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) {
return value.split('.').every((p) => IPV4_PART.test(p) && Number(p) <= 255);
}
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (/^[0-9]+$/.test(labels[0])) return false;
return labels.every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/.test(label));
}
export function deriveMinimumPoints(
initial: number,
previousMinimum: number,
previousInitial: number,
minimumTouched: boolean,
): number {
if (minimumTouched) {
return previousMinimum;
}
if (!minimumTouched && previousMinimum === defaultMinimumPoints(previousInitial)) {
return defaultMinimumPoints(initial);
}
return previousMinimum;
}
export interface ChallengeFormFileItem {
id?: string;
stagedToken?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
remove?: boolean;
}
export function toCreateBody(
values: ChallengeFormDefaults,
files: ChallengeFormFileItem[],
): CreateChallengeBody {
const protocolPort =
values.protocol === 'NC'
? values.port ?? null
: values.port === null || values.port === undefined
? null
: values.port;
return {
name: values.name.trim(),
descriptionMd: values.descriptionMd,
categoryId: values.categoryId,
difficulty: values.difficulty,
initialPoints: values.initialPoints,
minimumPoints: values.minimumPoints,
decaySolves: values.decaySolves,
flag: values.flag,
protocol: values.protocol,
port: protocolPort,
ipAddress: values.ipAddress ?? '',
enabled: values.enabled,
files: files.map((f) => ({
id: f.id,
stagedToken: f.stagedToken,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
remove: f.remove,
})),
};
}
export function toUpdateBody(
values: ChallengeFormDefaults,
files: ChallengeFormFileItem[],
): UpdateChallengeBody {
return toCreateBody(values, files) as UpdateChallengeBody;
}
export function mapServerFieldErrors(details: any[] | undefined): ChallengeFormErrors {
if (!details || !Array.isArray(details)) return {};
const out: ChallengeFormErrors = {};
for (const d of details) {
const path = String(d.path ?? '');
const message = String(d.message ?? '');
switch (path) {
case 'name': out.name = message; break;
case 'descriptionMd': out.descriptionMd = message; break;
case 'categoryId': out.categoryId = message; break;
case 'initialPoints': out.initialPoints = message; break;
case 'minimumPoints': out.minimumPoints = message; break;
case 'decaySolves': out.decaySolves = message; break;
case 'flag': out.flag = message; break;
case 'protocol': out.protocol = message; break;
case 'port': out.port = message; break;
case 'ipAddress': out.ipAddress = message; break;
default: break;
}
}
return out;
}
export interface ChallengeStagedFileUi {
kind: 'existing' | 'staged';
id?: string;
token?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
error?: string;
uploading?: boolean;
}
export function buildInitialFiles(detail?: AdminChallengeDetail | null): ChallengeStagedFileUi[] {
if (!detail) return [];
return detail.files.map((f: AdminChallengeFile) => ({
kind: 'existing' as const,
id: f.id,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
}));
}
@@ -0,0 +1,119 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImportPreviewResponse, ImportResultResponse } from '../../../core/services/admin.service';
@Component({
selector: 'app-challenge-import-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(520px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.warnings { color: var(--color-warning, #b45309); margin-top: 8px; }
.result { color: var(--color-success, #065f46); margin-top: 8px; }
ul { margin: 0; padding-left: 18px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="challenge-import-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ci-title" data-testid="challenge-import-modal">
<h3 id="ci-title">Import challenges</h3>
@if (result(); as r) {
<p class="result" data-testid="challenge-import-result">
Imported {{ r.imported }} challenge{{ r.imported === 1 ? '' : 's' }}.
@if (r.skipped.length > 0) { (Skipped {{ r.skipped.length }}.) }
@if (r.warnings.length > 0) { ({{ r.warnings.length }} warning(s).) }
</p>
@if (r.skipped.length > 0) {
<ul data-testid="challenge-import-skipped">
@for (s of r.skipped; track s.name) { <li>{{ s.name }} — {{ s.reason }}</li> }
</ul>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
</div>
} @else if (preview()) {
@if (preview(); as p) {
<p data-testid="challenge-import-preview">
Import {{ p.total }} challenge{{ p.total === 1 ? '' : 's' }}.
Existing challenges with matching names will be overwritten.
New categories referenced but missing locally will be skipped with a warning.
Continue?
</p>
@if (p.conflicts.length > 0) {
<p>Will overwrite:</p>
<ul>
@for (name of p.conflicts; track name) { <li>{{ name }}</li> }
</ul>
}
@if (p.unknownCategories.length > 0) {
<p class="warnings">Missing categories (will be skipped):</p>
<ul>
@for (key of p.unknownCategories; track key) { <li>{{ key }}</li> }
</ul>
}
}
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Overwrite &amp; Import
</button>
</div>
} @else if (errorMessage()) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
</div>
} @else {
<p>Reading file…</p>
}
</div>
</div>
}
`,
})
export class ChallengeImportModalComponent {
readonly open = input(false);
readonly document = input<unknown>(null);
readonly preview = input<ImportPreviewResponse | null>(null);
readonly result = input<ImportResultResponse | null>(null);
readonly submitting = input(false);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly confirm = output<void>();
readonly summary = computed(() => this.preview());
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting() && !this.result()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting() || this.result()) return;
this.confirm.emit();
}
}
@@ -0,0 +1,501 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import {
AdminService,
AdminCategory,
AdminChallengeListItem,
ImportPreviewResponse,
ImportResultResponse,
} from '../../../core/services/admin.service';
import {
buildExportFilename,
categoryIconSrc,
deriveEmptyState,
difficultyClass,
summarizeImportPreview,
summarizeImportResult,
} from './challenges.pure';
import { ChallengeFormModalComponent } from './challenge-form-modal.component';
import { ChallengeDeleteModalComponent } from './challenge-delete-modal.component';
import { ChallengeImportModalComponent } from './challenge-import-modal.component';
@Component({
selector: 'app-admin-challenges',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ChallengeFormModalComponent,
ChallengeDeleteModalComponent,
ChallengeImportModalComponent,
],
styles: [`
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.toolbar input[type="search"] { flex: 1; min-width: 220px; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; }
.icon-cell img { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
.icon-cell .fallback { display: inline-block; min-width: 32px; text-align: center; font-weight: bold; }
.badge { padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.difficulty-LOW { background: #d1fae5; color: #065f46; }
.difficulty-MEDIUM { background: #fef3c7; color: #92400e; }
.difficulty-HIGH { background: #fee2e2; color: #991b1b; }
.empty { text-align: center; padding: 32px; }
.row-actions button { margin-left: 4px; }
.status-banner { padding: 8px; margin: 8px 0; border-radius: 4px; }
.status-banner.success { background: #ecfdf5; color: #065f46; }
.status-banner.error { background: #fee2e2; color: #991b1b; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
`],
template: `
<section data-testid="admin-challenges">
<div class="toolbar">
<input
type="search"
placeholder="Search challenges…"
[value]="searchInput()"
(input)="onSearchInput($event)"
data-testid="challenges-search"
aria-label="Search challenges"
/>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="challenges-add">
Add challenge
</button>
<button type="button" (click)="openImport()" [disabled]="actionInFlight()" data-testid="challenges-import">
Import challenges
</button>
<button type="button" (click)="onExport()" [disabled]="actionInFlight()" data-testid="challenges-export">
<span *ngIf="exporting()" class="spinner" aria-hidden="true"></span>
Export challenges
</button>
</div>
@if (statusMessage(); as msg) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="challenges-status">{{ msg }}</p>
}
@if (errorMessage(); as msg) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="challenges-error">{{ msg }}</p>
}
@if (loading()) {
<p data-testid="challenges-loading">Loading challenges…</p>
} @else if (emptyState()) {
@if (emptyState(); as state) {
@if (state.kind === 'empty-list') {
<div class="empty" data-testid="challenges-empty">
<p>No challenges yet</p>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="challenges-empty-add">
Add challenge
</button>
</div>
} @else {
<div class="empty" data-testid="challenges-empty-search">
<p>No challenges match "{{ state.search }}"</p>
</div>
}
}
} @else {
<table class="table" data-testid="challenges-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">
<button type="button" (click)="toggleSort()" data-testid="challenges-sort-name">
Name {{ sortDirection() === 'asc' ? '↑' : '↓' }}
</button>
</th>
<th scope="col">Difficulty</th>
<th scope="col">Protocol</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows(); track row.id) {
<tr [attr.data-testid]="'challenge-row-' + row.id">
<td class="icon-cell">
@if (row.categoryIconPath) {
<img [src]="iconSrc(row)" [alt]="row.categoryAbbreviation" (error)="onIconError($event)" />
} @else {
<span class="fallback">{{ row.categoryAbbreviation }}</span>
}
</td>
<td>{{ row.name }}</td>
<td><span [class]="difficultyClass(row.difficulty)">{{ row.difficulty }}</span></td>
<td>{{ row.protocol }}</td>
<td class="row-actions">
<button type="button" (click)="openEdit(row)" aria-label="Edit" data-testid="challenges-row-edit">✎</button>
<button type="button" (click)="openDelete(row)" aria-label="Delete" data-testid="challenges-row-delete">🗑</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-challenge-form-modal
[open]="formOpen()"
[mode]="formMode()"
[detail]="formDetail()"
[categories]="categories()"
[globalFileLimit]="globalFileLimit()"
[errorMessage]="formError()"
[fieldErrors]="fieldErrors()"
[saving]="saving()"
(cancel)="closeForm()"
(submit)="onFormSubmit($event)"
/>
<app-challenge-delete-modal
[open]="deleteOpen()"
[challenge]="deleteTarget()"
[errorMessage]="deleteError()"
[submitting]="deleting()"
(cancel)="closeDelete()"
(confirm)="onDeleteConfirm()"
/>
<app-challenge-import-modal
[open]="importOpen()"
[document]="importDoc()"
[preview]="importPreview()"
[submitting]="importing()"
[errorMessage]="importError()"
[result]="importResult()"
(cancel)="closeImport()"
(confirm)="onImportConfirm()"
/>
</section>
`,
})
export class AdminChallengesComponent implements OnInit, OnDestroy {
private readonly admin = inject(AdminService);
private readonly destroyRef = inject(DestroyRef);
private readonly searchSubject = new Subject<string>();
private readonly objectUrls = new Set<string>();
private requestSeq = 0;
readonly categories = signal<AdminCategory[]>([]);
readonly rows = signal<AdminChallengeListItem[]>([]);
readonly loading = signal(true);
readonly searchInput = signal('');
readonly activeSearch = signal('');
readonly sortDirection = signal<'asc' | 'desc'>('asc');
readonly formOpen = signal(false);
readonly formMode = signal<'create' | 'edit'>('create');
readonly formDetail = signal<any>(null);
readonly formError = signal<string | null>(null);
readonly fieldErrors = signal<Record<string, string> | null>(null);
readonly saving = signal(false);
readonly deleteOpen = signal(false);
readonly deleteTarget = signal<AdminChallengeListItem | null>(null);
readonly deleteError = signal<string | null>(null);
readonly deleting = signal(false);
readonly importOpen = signal(false);
readonly importDoc = signal<any>(null);
readonly importPreview = signal<ImportPreviewResponse | null>(null);
readonly importResult = signal<ImportResultResponse | null>(null);
readonly importError = signal<string | null>(null);
readonly importing = signal(false);
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly exporting = signal(false);
readonly globalFileLimit = signal<number>(100 * 1024 * 1024);
readonly emptyState = computed(() =>
deriveEmptyState(this.rows(), this.activeSearch(), this.loading()),
);
readonly actionInFlight = computed(
() => this.saving() || this.deleting() || this.exporting() || this.importing(),
);
ngOnInit(): void {
this.searchSubject
.pipe(debounceTime(250), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.activeSearch.set(value);
void this.load();
});
void this.loadCategories();
void this.load();
}
ngOnDestroy(): void {
for (const url of this.objectUrls) {
try {
URL.revokeObjectURL(url);
} catch {
// ignore
}
}
}
async loadCategories(): Promise<void> {
try {
const cats = await this.admin.listCategories();
this.categories.set(cats);
} catch {
// non-fatal; form will surface category load failure
}
}
async load(): Promise<void> {
const seq = ++this.requestSeq;
this.loading.set(true);
this.errorMessage.set(null);
try {
const q = this.activeSearch();
const list = await this.admin.listChallenges({ q: q.length > 0 ? q : undefined });
if (seq !== this.requestSeq) return;
const sorted = this.applySort(list);
this.rows.set(sorted);
} catch (e: any) {
if (seq !== this.requestSeq) return;
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to load challenges');
} finally {
if (seq === this.requestSeq) this.loading.set(false);
}
}
applySort(list: AdminChallengeListItem[]): AdminChallengeListItem[] {
const sorted = [...list].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
return this.sortDirection() === 'desc' ? sorted.reverse() : sorted;
}
toggleSort(): void {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
this.rows.set(this.applySort(this.rows()));
}
onSearchInput(ev: Event): void {
const value = (ev.target as HTMLInputElement).value;
this.searchInput.set(value);
this.searchSubject.next(value);
}
difficultyClass(d: 'LOW' | 'MEDIUM' | 'HIGH'): string {
return difficultyClass(d);
}
iconSrc(row: AdminChallengeListItem): string {
return categoryIconSrc(row.categoryIconPath, row.categoryAbbreviation);
}
onIconError(ev: Event): void {
const img = ev.target as HTMLImageElement;
img.style.display = 'none';
}
openCreate(): void {
this.formMode.set('create');
this.formDetail.set(null);
this.formError.set(null);
this.fieldErrors.set(null);
this.formOpen.set(true);
}
async openEdit(row: AdminChallengeListItem): Promise<void> {
this.formMode.set('edit');
this.formError.set(null);
this.fieldErrors.set(null);
this.formOpen.set(true);
this.formDetail.set(null);
try {
const detail = await this.admin.getChallengeDetail(row.id);
this.formDetail.set(detail);
} catch (e: any) {
this.formOpen.set(false);
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to load challenge');
}
}
closeForm(): void {
this.formOpen.set(false);
this.formDetail.set(null);
this.formError.set(null);
this.fieldErrors.set(null);
}
async onFormSubmit(payload: any): Promise<void> {
this.saving.set(true);
this.formError.set(null);
this.fieldErrors.set(null);
try {
if (this.formMode() === 'create') {
await this.admin.createChallenge(payload);
} else {
const detail = this.formDetail();
if (!detail) throw new Error('Missing challenge detail');
await this.admin.updateChallenge(detail.id, payload);
}
this.closeForm();
await this.load();
this.statusMessage.set(this.formMode() === 'create' ? 'Challenge created.' : 'Challenge updated.');
} catch (e: any) {
const code = e?.error?.code ?? e?.code;
const message = e?.error?.message ?? e?.message ?? 'Failed to save challenge';
this.formError.set(this.mapFormErrorMessage(code, message));
const details = e?.error?.details;
if (Array.isArray(details)) {
const map: Record<string, string> = {};
for (const d of details) {
if (d && typeof d === 'object' && d.path && d.message) {
map[String(d.path)] = String(d.message);
}
}
this.fieldErrors.set(Object.keys(map).length > 0 ? map : null);
}
} finally {
this.saving.set(false);
}
}
mapFormErrorMessage(code: string | undefined, fallback: string): string {
if (code === 'CHALLENGE_NAME_TAKEN') return 'A challenge with this name already exists.';
if (code === 'CHALLENGE_INVALID_CATEGORY') return 'Selected category does not exist.';
return fallback;
}
openDelete(row: AdminChallengeListItem): void {
this.deleteTarget.set(row);
this.deleteError.set(null);
this.deleteOpen.set(true);
}
closeDelete(): void {
this.deleteOpen.set(false);
this.deleteTarget.set(null);
this.deleteError.set(null);
}
async onDeleteConfirm(): Promise<void> {
const target = this.deleteTarget();
if (!target) return;
this.deleting.set(true);
this.deleteError.set(null);
try {
await this.admin.deleteChallenge(target.id);
this.closeDelete();
await this.load();
this.statusMessage.set(`Challenge '${target.name}' deleted.`);
} catch (e: any) {
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed to delete challenge');
} finally {
this.deleting.set(false);
}
}
openImport(): void {
this.importDoc.set(null);
this.importPreview.set(null);
this.importResult.set(null);
this.importError.set(null);
this.importOpen.set(true);
void this.pickImportFile();
}
async pickImportFile(): Promise<void> {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) {
this.closeImport();
return;
}
const text = await file.text();
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
this.importError.set('Selected file is not valid JSON.');
return;
}
try {
const preview = await this.admin.previewChallengeImport(parsed);
this.importDoc.set(parsed);
this.importPreview.set(preview);
} catch (e: any) {
this.importError.set(e?.error?.message ?? e?.message ?? 'Failed to validate import');
}
});
input.click();
}
closeImport(): void {
this.importOpen.set(false);
this.importDoc.set(null);
this.importPreview.set(null);
this.importResult.set(null);
this.importError.set(null);
}
async onImportConfirm(): Promise<void> {
const doc = this.importDoc();
if (!doc) return;
this.importing.set(true);
this.importError.set(null);
try {
const result = await this.admin.confirmChallengeImport(doc);
this.importResult.set(result);
this.statusMessage.set(summarizeImportResult(result));
await this.load();
} catch (e: any) {
this.importError.set(e?.error?.message ?? e?.message ?? 'Failed to import challenges');
} finally {
this.importing.set(false);
}
}
async onExport(): Promise<void> {
this.exporting.set(true);
this.errorMessage.set(null);
try {
const blob = await this.admin.exportChallenges();
const url = URL.createObjectURL(blob);
this.objectUrls.add(url);
const a = document.createElement('a');
a.href = url;
a.download = buildExportFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
try {
URL.revokeObjectURL(url);
this.objectUrls.delete(url);
} catch {
// ignore
}
}, 60_000);
this.statusMessage.set('Export ready.');
} catch (e: any) {
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to export challenges');
} finally {
this.exporting.set(false);
}
}
summarizePreview(p: ImportPreviewResponse | null): string {
return p ? summarizeImportPreview(p) : '';
}
}
@@ -0,0 +1,74 @@
import { AdminChallengeListItem, ImportPreviewResponse, ImportResultResponse } from '../../../core/services/admin.service';
export function normalizeSearch(value: string | null | undefined): string {
return (value ?? '').trim();
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function buildExportFilename(now: Date = new Date()): string {
const iso = now.toISOString();
const date = iso.slice(0, 10);
return `challenges-export-${date}.json`;
}
export type EmptyState =
| { kind: 'no-results-search'; search: string }
| { kind: 'empty-list' };
export function deriveEmptyState(
rows: AdminChallengeListItem[],
search: string,
loading: boolean,
): EmptyState | null {
if (loading) return null;
if (rows.length > 0) return null;
const trimmed = search.trim();
if (trimmed.length > 0) return { kind: 'no-results-search', search: trimmed };
return { kind: 'empty-list' };
}
export function summarizeImportPreview(p: ImportPreviewResponse): string {
if (p.total === 0) return 'Import file contains no challenges.';
const lines: string[] = [`Import ${p.total} challenge${p.total === 1 ? '' : 's'}.`];
if (p.conflicts.length > 0) {
lines.push(
`${p.conflicts.length} existing challenge${p.conflicts.length === 1 ? '' : 's'} will be overwritten.`,
);
}
if (p.unknownCategories.length > 0) {
lines.push(
`${p.unknownCategories.length} challenge(s) reference missing categories and will be skipped.`,
);
}
return lines.join(' ');
}
export function summarizeImportResult(r: ImportResultResponse): string {
const lines: string[] = [];
lines.push(`Imported ${r.imported} challenge${r.imported === 1 ? '' : 's'}.`);
if (r.skipped.length > 0) {
lines.push(`Skipped ${r.skipped.length}.`);
}
if (r.warnings.length > 0) {
lines.push(`${r.warnings.length} warning(s).`);
}
return lines.join(' ');
}
export function difficultyClass(difficulty: 'LOW' | 'MEDIUM' | 'HIGH'): string {
return `difficulty-badge difficulty-${difficulty.toLowerCase()}`;
}
export function categoryIconSrc(iconPath: string, abbreviation: string): string {
return iconPath && iconPath.length > 0 ? iconPath : '';
}
export function categoryAltText(abbreviation: string, name: string): string {
return `${abbreviation}${name}`;
}