import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, HostListener, effect, inject, input, output, signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, 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, CATEGORY_ID_PATTERN, buildInitialFiles, defaultMinimumPoints, deriveMinimumPoints, emptyChallengeFormDefaults, firstErrorTab, partitionFilesBySize, prefillChallengeFormDefaults, toCreateBody, validateChallengeForm, } from './challenge-form.pure'; export type ChallengeFormMode = 'create' | 'edit'; export interface ChallengeFormSubmitPayload { body: ReturnType; } interface ChallengeFormControls { name: FormControl; descriptionMd: FormControl; categoryId: FormControl; difficulty: FormControl<'LOW' | 'MEDIUM' | 'HIGH'>; initialPoints: FormControl; minimumPoints: FormControl; decaySolves: FormControl; flag: FormControl; protocol: FormControl<'NC' | 'WEB'>; port: FormControl; ipAddress: FormControl; enabled: FormControl; } export function buildChallengeFormGroup(fb: FormBuilder): FormGroup { 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, categoryIdUuidValidator]), 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(null, [ Validators.pattern(/^\d+$/), ]), ipAddress: fb.nonNullable.control(''), enabled: fb.nonNullable.control(true), }); } export function syncChallengeForm( form: FormGroup, 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; } .drop-zone { border: 2px dashed var(--color-secondary, #ccc); border-radius: 6px; padding: 12px; text-align: center; transition: background 0.15s, border-color 0.15s; } .drop-zone.dragover { background: rgba(59,130,246,0.08); border-color: var(--color-primary, #3b82f6); } .drop-zone__hint { margin: 0 0 8px; font-size: 12px; color: var(--color-secondary, #666); } `], template: ` @if (open()) { } `, }) 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('create'); readonly detail = input(null); readonly categories = input([]); readonly globalFileLimit = input(100 * 1024 * 1024); readonly errorMessage = input(null); readonly fieldErrors = input | null>(null); readonly saving = input(false); readonly cancel = output(); readonly submit = output(); readonly form: FormGroup = buildChallengeFormGroup(this.fb); readonly tab = signal<'general' | 'connection' | 'files'>('general'); readonly flagVisible = signal(false); readonly showPreview = signal(false); readonly previewHtml = signal(''); readonly files = signal([]); readonly fileUploadError = signal(null); readonly submitting = signal(false); readonly clientFieldErrors = signal(null); readonly dragOver = 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.clientFieldErrors.set(null); this.dragOver.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 }); } }); } @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 fromClient = (this.clientFieldErrors() ?? {})[field as string]; if (fromClient) return fromClient; 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}`; } if (ctrl.errors?.['pattern']) { return `${humanLabel(field)} must be a whole number`; } if (ctrl.errors?.['uuid']) { return `${humanLabel(field)} is required`; } 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 { const input = ev.target as HTMLInputElement; const list = input.files ? Array.from(input.files) : []; input.value = ''; await this.stageFiles(list); } onDragOver(ev: DragEvent): void { ev.preventDefault(); if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'copy'; this.dragOver.set(true); } onDragLeave(ev: DragEvent): void { ev.preventDefault(); this.dragOver.set(false); } async onDrop(ev: DragEvent): Promise { ev.preventDefault(); this.dragOver.set(false); const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : []; if (list.length === 0) return; await this.stageFiles(list); } private async stageFiles(list: File[]): Promise { this.fileUploadError.set(null); const { accepted, rejected } = partitionFilesBySize(list, this.globalFileLimit()); for (const r of rejected) { this.fileUploadError.set(r.reason); } for (const a of accepted) { this.files.update((arr) => [...arr, a.placeholder]); try { const res = await this.admin.stageChallengeFile(a.file); this.files.update((arr) => arr.map((entry) => entry === a.placeholder ? { ...entry, token: res.token, uploading: false } : entry, ), ); } catch (e: any) { this.files.update((arr) => arr.filter((entry) => entry !== a.placeholder)); this.fileUploadError.set( `Failed to upload '${a.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 categoryRaw = this.form.controls.categoryId.value; if (!categoryRaw || !CATEGORY_ID_PATTERN.test(categoryRaw)) { this.clientFieldErrors.set({ categoryId: 'Category is required' }); this.form.controls.categoryId.markAsTouched(); this.form.controls.categoryId.markAsDirty(); this.form.controls.categoryId.setErrors({ ...(this.form.controls.categoryId.errors ?? {}), uuid: true }); this.tab.set('general'); return; } const portRaw = this.form.controls.port.value; const portAsNumber = portRaw === null || portRaw === undefined || portRaw === '' ? null : Number(portRaw); 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: Number.isFinite(portAsNumber) ? portAsNumber : null, ipAddress: this.form.controls.ipAddress.value, enabled: this.form.controls.enabled.value, }; const clientErrors = validateChallengeForm(values); if (Object.keys(clientErrors).length > 0) { this.clientFieldErrors.set(clientErrors); for (const f of Object.keys(clientErrors) as (keyof ChallengeFormControls)[]) { const ctrl = this.form.controls[f]; if (ctrl) { ctrl.markAsTouched(); ctrl.markAsDirty(); } } const tab = firstErrorTab(clientErrors); if (tab) this.tab.set(tab); return; } this.clientFieldErrors.set(null); 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'; } } function categoryIdUuidValidator(control: AbstractControl): ValidationErrors | null { const raw = control.value; if (raw === null || raw === undefined || raw === '') { return null; } return CATEGORY_ID_PATTERN.test(String(raw)) ? null : { uuid: true }; } // Re-export so tests can find default helpers. export { defaultMinimumPoints };