AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)
This commit was merged in pull request #40.
This commit is contained in:
@@ -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 & 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user