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: `
@if (statusMessage(); as msg) {

{{ msg }}

} @if (errorMessage(); as msg) { } @if (loading()) {

Loading challenges…

} @else if (emptyState()) { @if (emptyState(); as state) { @if (state.kind === 'empty-list') {

No challenges yet

} @else {

No challenges match "{{ state.search }}"

} } } @else { @for (row of rows(); track row.id) { }
Category Difficulty Protocol Actions
@if (row.categoryIconPath) { } @else { {{ row.categoryAbbreviation }} } {{ row.name }} {{ row.difficulty }} {{ row.protocol }}
}
`, }) export class AdminChallengesComponent implements OnInit, OnDestroy { private readonly admin = inject(AdminService); private readonly destroyRef = inject(DestroyRef); private readonly searchSubject = new Subject(); private readonly objectUrls = new Set(); private requestSeq = 0; readonly categories = signal([]); readonly rows = signal([]); 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(null); readonly formError = signal(null); readonly fieldErrors = signal | null>(null); readonly saving = signal(false); readonly deleteOpen = signal(false); readonly deleteTarget = signal(null); readonly deleteError = signal(null); readonly deleting = signal(false); readonly importOpen = signal(false); readonly importDoc = signal(null); readonly importPreview = signal(null); readonly importResult = signal(null); readonly importError = signal(null); readonly importing = signal(false); readonly statusMessage = signal(null); readonly errorMessage = signal(null); readonly exporting = signal(false); readonly globalFileLimit = signal(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 { try { const cats = await this.admin.listCategories(); this.categories.set(cats); } catch { // non-fatal; form will surface category load failure } } async load(): Promise { 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 { 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 { 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 = {}; 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 { 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 { 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 { 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 { 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) : ''; } }