505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
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,
|
|
CreateChallengeBody,
|
|
ImportPreviewResponse,
|
|
ImportResultResponse,
|
|
UpdateChallengeBody,
|
|
} from '../../../core/services/admin.service';
|
|
import {
|
|
buildExportFilename,
|
|
categoryIconSrc,
|
|
deriveEmptyState,
|
|
difficultyClass,
|
|
importErrorMessage,
|
|
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: { body: CreateChallengeBody | UpdateChallengeBody }): Promise<void> {
|
|
this.saving.set(true);
|
|
this.formError.set(null);
|
|
this.fieldErrors.set(null);
|
|
const body = payload.body;
|
|
try {
|
|
if (this.formMode() === 'create') {
|
|
await this.admin.createChallenge(body as CreateChallengeBody);
|
|
} else {
|
|
const detail = this.formDetail();
|
|
if (!detail) throw new Error('Missing challenge detail');
|
|
await this.admin.updateChallenge(detail.id, body as UpdateChallengeBody);
|
|
}
|
|
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.statusMessage.set(summarizeImportResult(result));
|
|
await this.load();
|
|
this.closeImport();
|
|
} catch (e: any) {
|
|
this.importError.set(importErrorMessage(e, '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) : '';
|
|
}
|
|
} |