Files
HIPCTF2/frontend/src/app/features/admin/challenges/challenge-import-modal.component.ts
T

119 lines
5.0 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImportPreviewResponse, ImportResultResponse } from '../../../core/services/admin.service';
@Component({
selector: 'app-challenge-import-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(520px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.warnings { color: var(--color-warning, #b45309); margin-top: 8px; }
.result { color: var(--color-success, #065f46); margin-top: 8px; }
ul { margin: 0; padding-left: 18px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="challenge-import-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ci-title" data-testid="challenge-import-modal">
<h3 id="ci-title">Import challenges</h3>
@if (result(); as r) {
<p class="result" data-testid="challenge-import-result">
Imported {{ r.imported }} challenge{{ r.imported === 1 ? '' : 's' }}.
@if (r.skipped.length > 0) { (Skipped {{ r.skipped.length }}.) }
@if (r.warnings.length > 0) { ({{ r.warnings.length }} warning(s).) }
</p>
@if (r.skipped.length > 0) {
<ul data-testid="challenge-import-skipped">
@for (s of r.skipped; track s.name) { <li>{{ s.name }} — {{ s.reason }}</li> }
</ul>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
</div>
} @else if (preview()) {
@if (preview(); as p) {
<p data-testid="challenge-import-preview">
Import {{ p.total }} challenge{{ p.total === 1 ? '' : 's' }}.
Existing challenges with matching names will be overwritten.
New categories referenced but missing locally will be skipped with a warning.
Continue?
</p>
@if (p.conflicts.length > 0) {
<p>Will overwrite:</p>
<ul>
@for (name of p.conflicts; track name) { <li>{{ name }}</li> }
</ul>
}
@if (p.unknownCategories.length > 0) {
<p class="warnings">Missing categories (will be skipped):</p>
<ul>
@for (key of p.unknownCategories; track key) { <li>{{ key }}</li> }
</ul>
}
}
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Overwrite &amp; Import
</button>
</div>
} @else if (errorMessage()) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
</div>
} @else {
<p>Reading file…</p>
}
</div>
</div>
}
`,
})
export class ChallengeImportModalComponent {
readonly open = input(false);
readonly document = input<unknown>(null);
readonly preview = input<ImportPreviewResponse | null>(null);
readonly result = input<ImportResultResponse | null>(null);
readonly submitting = input(false);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly confirm = output<void>();
readonly summary = computed(() => this.preview());
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting() && !this.result()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting() || this.result()) return;
this.confirm.emit();
}
}