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

222 lines
8.6 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
Output,
SimpleChanges,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MarkdownService } from '../../core/services/markdown.service';
import { ChallengesStore } from './challenges.store';
import {
BoardCard,
SolveEventLivePayload,
SolverRow,
formatUtcToLocal,
messageForSolveError,
parseSolveError,
} from './challenges.pure';
import { ApiErrorEnvelope } from './challenges.service';
@Component({
selector: 'app-challenge-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule],
styles: [`
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal {
width: min(720px, 92vw); max-height: 90vh; overflow: auto;
background: var(--color-surface, #1c1f26); color: var(--color-text, #f4f4f5);
border-radius: var(--radius-md, 8px);
padding: 16px; display: flex; flex-direction: column; gap: 12px;
}
.header { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.header h3 { margin: 0; flex: 1; }
.pill { padding: 2px 8px; border-radius: 999px; font-size: 12px; background: rgba(255,255,255,0.06); }
.pill.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; }
.pill.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; }
.pill.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; }
.body { white-space: normal; line-height: 1.45; }
.form { display: flex; gap: 8px; align-items: center; }
.form input { flex: 1; padding: 8px 10px; border-radius: var(--radius-sm, 4px); border: 1px solid var(--color-border, #2a2f3a); background: var(--color-input, rgba(255,255,255,0.04)); color: inherit; }
.actions { display: flex; gap: 8px; justify-content: flex-end; }
.error { color: var(--color-danger, #ef4444); font-size: 14px; }
.notice { color: var(--color-warning, #eab308); font-size: 14px; }
.solvers { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.solvers li { display: grid; grid-template-columns: 28px 1fr auto auto; gap: 8px; align-items: center; padding: 4px 8px; border-radius: var(--radius-sm, 4px); background: rgba(255,255,255,0.03); }
.rank-1 { color: #fbbf24; font-weight: 700; }
.rank-2 { color: #d4d4d8; font-weight: 700; }
.rank-3 { color: #b45309; font-weight: 700; }
.rank-other { color: var(--color-success, #22c55e); }
.points { font-variant-numeric: tabular-nums; }
.close { background: transparent; border: 1px solid var(--color-border, #2a2f3a); color: inherit; padding: 4px 10px; border-radius: var(--radius-sm, 4px); cursor: pointer; }
.solved-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(34,197,94,0.12); color: #16a34a; }
.awarded-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(59,130,246,0.12); color: #60a5fa; }
`],
template: `
<div class="backdrop" (click)="onBackdrop($event)">
<div class="modal" role="dialog" aria-modal="true" [attr.data-testid]="'challenge-modal-' + (card?.id ?? '')">
<div class="header" *ngIf="card">
<h3>{{ card.name }}</h3>
<span class="pill">{{ card.categoryAbbreviation }}</span>
<span class="pill" [class]="'pill diff-' + card.difficulty">{{ card.difficulty }}</span>
<span class="pill" data-testid="modal-points">{{ card.livePoints }} pts</span>
<button type="button" class="close" (click)="close.emit()" aria-label="Close">Close</button>
</div>
<div class="solved-banner" *ngIf="card?.solvedByMe" data-testid="modal-solved-banner">
You solved this challenge.
</div>
<div class="awarded-banner" *ngIf="awarded() as a" data-testid="modal-awarded-banner">
Awarded {{ a.awardedPoints }} pts
<span *ngIf="a.rankBonus > 0">(base {{ a.basePoints }} + rank bonus {{ a.rankBonus }})</span>
</div>
<div class="notice" *ngIf="notice() as n">{{ n }}</div>
<div class="body" [innerHTML]="renderedDescription()"></div>
<form class="form" (ngSubmit)="onSubmit()" *ngIf="card">
<input
type="text"
[(ngModel)]="flagValue"
name="flag"
autocomplete="off"
spellcheck="false"
placeholder="flag{...}"
[disabled]="submitting() || !canSubmit"
data-testid="flag-input"
/>
<button
type="submit"
[disabled]="submitting() || !canSubmit"
data-testid="solve-button"
>Solve</button>
</form>
<div class="error" *ngIf="errorMessage() as e" data-testid="modal-error">{{ e }}</div>
<h4>Solvers ({{ solvers().length }})</h4>
<ul class="solvers" data-testid="modal-solvers">
<li *ngFor="let s of solvers(); trackBy: trackBySolver">
<span [class]="rankClass(s.position)">#{{ s.position }}</span>
<span>{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span>
<span>{{ formatLocal(s.solvedAtUtc) }}</span>
<span class="points">{{ s.awardedPoints }} pts</span>
</li>
<li *ngIf="!solvers().length"><em>No solvers yet.</em></li>
</ul>
</div>
</div>
`,
})
export class ChallengeModalComponent implements OnChanges {
private readonly markdown = inject(MarkdownService);
private readonly store = inject(ChallengesStore);
@Input({ required: true }) card: BoardCard | null = null;
@Input() initialSolvers: SolverRow[] = [];
@Input() canSubmit: boolean = false;
@Input() noticeOverride: string | null = null;
@Output() close = new EventEmitter<void>();
@Output() submitted = new EventEmitter<{ flag: string }>();
private _flagValue = signal('');
get flagValue(): string {
return this._flagValue();
}
set flagValue(v: string) {
this._flagValue.set(v ?? '');
}
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly awarded = signal<{ basePoints: number; rankBonus: number; awardedPoints: number; awardedAtUtc: string } | null>(null);
readonly notice = signal<string | null>(null);
private _solvers = signal<SolverRow[]>([]);
readonly solvers = this._solvers.asReadonly();
readonly renderedDescription = computed(() => {
return this.markdown.render(this.card?.descriptionMd ?? '');
});
ngOnChanges(changes: SimpleChanges): void {
if (changes['card']) {
this._flagValue.set('');
this.submitting.set(false);
this.errorMessage.set(null);
this.awarded.set(null);
this.notice.set(this.noticeOverride ?? null);
this._solvers.set([...(this.initialSolvers ?? [])]);
}
if (changes['noticeOverride']) {
this.notice.set(this.noticeOverride ?? null);
}
}
onBackdrop(ev: MouseEvent): void {
if (ev.target === ev.currentTarget) this.close.emit();
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (!this.submitting()) this.close.emit();
}
async onSubmit(): Promise<void> {
if (!this.card || this.submitting() || !this.canSubmit) return;
const flag = this._flagValue().trim();
if (!flag) return;
this.submitting.set(true);
this.errorMessage.set(null);
try {
const resp = await this.store.submit(this.card.id, flag);
this._flagValue.set('');
this.awarded.set(resp.awarded);
this._solvers.set(this.store.buildSolversFromResponse(resp.solvers));
this.submitted.emit({ flag });
if (resp.status === 'already_solved') {
this.errorMessage.set(messageForSolveError('already_solved'));
}
} catch (err) {
const envelope = err as ApiErrorEnvelope;
const code = parseSolveError(envelope.status, envelope);
this.errorMessage.set(messageForSolveError(code));
} finally {
this.submitting.set(false);
}
}
applyLiveSolve(payload: SolveEventLivePayload): void {
this._solvers.set(this.store.mergeLiveSolver(this._solvers(), payload));
}
formatLocal(utc: string): string {
return formatUtcToLocal(utc);
}
rankClass(position: number): string {
if (position === 1) return 'rank-1';
if (position === 2) return 'rank-2';
if (position === 3) return 'rank-3';
return 'rank-other';
}
trackBySolver(_idx: number, s: SolverRow): string {
return `${s.playerId}-${s.solvedAtUtc}`;
}
}