AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)
This commit was merged in pull request #40.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
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,
|
||||
ImportPreviewResponse,
|
||||
ImportResultResponse,
|
||||
} from '../../../core/services/admin.service';
|
||||
import {
|
||||
buildExportFilename,
|
||||
categoryIconSrc,
|
||||
deriveEmptyState,
|
||||
difficultyClass,
|
||||
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: any): Promise<void> {
|
||||
this.saving.set(true);
|
||||
this.formError.set(null);
|
||||
this.fieldErrors.set(null);
|
||||
try {
|
||||
if (this.formMode() === 'create') {
|
||||
await this.admin.createChallenge(payload);
|
||||
} else {
|
||||
const detail = this.formDetail();
|
||||
if (!detail) throw new Error('Missing challenge detail');
|
||||
await this.admin.updateChallenge(detail.id, payload);
|
||||
}
|
||||
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.importResult.set(result);
|
||||
this.statusMessage.set(summarizeImportResult(result));
|
||||
await this.load();
|
||||
} catch (e: any) {
|
||||
this.importError.set(e?.error?.message ?? e?.message ?? '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) : '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user