AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user