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: `
`, }) 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(); @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(null); readonly awarded = signal<{ basePoints: number; rankBonus: number; awardedPoints: number; awardedAtUtc: string } | null>(null); readonly notice = signal(null); private _solvers = signal([]); 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 { 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}`; } }