feat: Admin Area Challenges: List, Import/Export and Add/Edit Modal
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user