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:
@@ -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 };
|
||||
Reference in New Issue
Block a user