Spaces:
Running
Running
| <script lang="ts"> | |
| import * as Dialog from '$lib/components/ui/dialog'; | |
| import { Button } from '$lib/components/ui/button'; | |
| import { Label } from '$lib/components/ui/label'; | |
| import { | |
| addFlag, | |
| clearFlags, | |
| clearValidations, | |
| exportReviews, | |
| FLAG_REASONS, | |
| KNOWN_MINOR_ISSUES, | |
| loadFlags, | |
| loadValidations, | |
| type FlagReason | |
| } from '$lib/eval'; | |
| import ArrowSquareOutIcon from 'phosphor-svelte/lib/ArrowSquareOutIcon'; | |
| import ClipboardIcon from 'phosphor-svelte/lib/ClipboardIcon'; | |
| import TrashIcon from 'phosphor-svelte/lib/TrashIcon'; | |
| import { toast } from 'svelte-sonner'; | |
| import { untrack } from 'svelte'; | |
| interface Props { | |
| open?: boolean; | |
| matchId: number; | |
| mapName: string; | |
| round: number; | |
| queueLength: number; | |
| onChange?: () => void; | |
| } | |
| let { open = $bindable(false), matchId, mapName, round, queueLength, onChange }: Props = $props(); | |
| let reason = $state<FlagReason | null>(null); | |
| let note = $state(''); | |
| // Bumped to refresh the counts when the user copies/clears or saves a flag. | |
| let storeBump = $state(0); | |
| // Watch only `open`; everything inside is untracked so writes (especially | |
| // `storeBump++` which is a read+write) don't make the effect re-trigger | |
| // itself into an infinite loop. | |
| $effect(() => { | |
| const isOpen = open; | |
| untrack(() => { | |
| if (!isOpen) { | |
| reason = null; | |
| note = ''; | |
| } else { | |
| storeBump++; | |
| } | |
| }); | |
| }); | |
| const flagsCount = $derived.by(() => { | |
| void storeBump; | |
| return loadFlags().length; | |
| }); | |
| const validationsCount = $derived.by(() => { | |
| void storeBump; | |
| return loadValidations().length; | |
| }); | |
| function save() { | |
| if (!reason) return; | |
| const trimmed = note.trim(); | |
| addFlag({ matchId, mapName, round, reason, note: trimmed || undefined }); | |
| const label = FLAG_REASONS.find((r) => r.id === reason)?.label ?? 'Flagged'; | |
| toast.success(`Flagged: ${label}`, { | |
| description: `match ${matchId} · ${mapName} · round ${round}` | |
| }); | |
| onChange?.(); | |
| open = false; | |
| } | |
| async function copyAll() { | |
| const data = exportReviews(queueLength); | |
| try { | |
| await navigator.clipboard.writeText(JSON.stringify(data, null, 2)); | |
| toast.success('Review data copied to clipboard', { | |
| description: `${data.flags.length} flags · ${data.validations.length} validations` | |
| }); | |
| } catch { | |
| toast.error('Could not copy to clipboard'); | |
| } | |
| } | |
| function clearAll() { | |
| clearFlags(); | |
| clearValidations(); | |
| storeBump++; | |
| onChange?.(); | |
| toast.success('Cleared local review data'); | |
| } | |
| </script> | |
| <Dialog.Root bind:open> | |
| <Dialog.Content | |
| class="flex max-h-[min(80vh,40rem)] w-full max-w-xl flex-col gap-3 overflow-hidden p-0 sm:max-w-xl" | |
| > | |
| <Dialog.Header class="border-b px-4 pt-4 pb-3"> | |
| <Dialog.Title>Flag this candidate</Dialog.Title> | |
| <Dialog.Description> | |
| match {matchId} · {mapName} · round {round}. Pick a reason and (optionally) leave a note. | |
| </Dialog.Description> | |
| </Dialog.Header> | |
| <div class="flex min-h-0 flex-col gap-3 overflow-y-auto px-4"> | |
| <div class="grid gap-1.5"> | |
| {#each FLAG_REASONS as r (r.id)} | |
| <button | |
| type="button" | |
| data-active={reason === r.id || undefined} | |
| class="flex flex-col items-start gap-0.5 rounded-md border border-border px-2.5 py-1.5 text-left text-xs transition hover:bg-muted/50 data-active:border-amber-500 data-active:bg-amber-500/10" | |
| onclick={() => (reason = r.id)} | |
| > | |
| <span class="flex items-center gap-2"> | |
| <span class="font-medium text-foreground">{r.label}</span> | |
| <span | |
| data-severity={r.severity} | |
| class="rounded-sm border px-1 py-0 text-[9px] font-semibold tracking-wider text-muted-foreground/80 uppercase data-[severity=major]:border-rose-500/30 data-[severity=major]:text-rose-600 dark:data-[severity=major]:text-rose-400" | |
| >{r.severity}</span | |
| > | |
| </span> | |
| <span class="text-[11px] leading-snug text-muted-foreground">{r.description}</span> | |
| {#if r.examples?.length} | |
| <span class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px]"> | |
| <span class="tracking-wider text-muted-foreground/70 uppercase">Examples:</span> | |
| {#each r.examples as href, i (href)} | |
| <a | |
| {href} | |
| target="_blank" | |
| rel="noreferrer noopener" | |
| onclick={(e) => e.stopPropagation()} | |
| class="inline-flex items-center gap-0.5 text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" | |
| > | |
| #{i + 1} | |
| <ArrowSquareOutIcon size={10} weight="bold" /> | |
| </a> | |
| {/each} | |
| </span> | |
| {/if} | |
| </button> | |
| {/each} | |
| </div> | |
| <div class="grid gap-1.5"> | |
| <Label for="flag-note" class="text-xs">Notes (optional)</Label> | |
| <textarea | |
| id="flag-note" | |
| bind:value={note} | |
| placeholder="e.g. player 4 spawn position is at mid instead of T spawn from t=0" | |
| rows="3" | |
| class="w-full resize-y rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 dark:bg-input/30" | |
| ></textarea> | |
| </div> | |
| {#each KNOWN_MINOR_ISSUES as k (k.label)} | |
| <div class="rounded-md border border-dashed border-muted-foreground/20 p-2.5 text-[11px]"> | |
| <div | |
| class="mb-0.5 text-[10px] font-semibold tracking-wider text-muted-foreground/80 uppercase" | |
| > | |
| Don't flag — known minor | |
| </div> | |
| <div class="font-medium text-foreground">{k.label}</div> | |
| <div class="mt-0.5 leading-snug text-muted-foreground">{k.description}</div> | |
| {#if k.examples?.length} | |
| <div class="mt-1 flex flex-wrap gap-2 text-[10px]"> | |
| {#each k.examples as href, i (href)} | |
| <a | |
| {href} | |
| target="_blank" | |
| rel="noreferrer noopener" | |
| class="inline-flex items-center gap-0.5 text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" | |
| > | |
| #{i + 1} | |
| <ArrowSquareOutIcon size={10} weight="bold" /> | |
| </a> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| {/each} | |
| <div class="rounded-md border bg-muted/30 px-2.5 py-2 text-[11px]"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <div> | |
| <div class="font-medium text-foreground">Local review data</div> | |
| <div class="mt-0.5 text-[10px] text-muted-foreground"> | |
| <span class="tabular-nums">{flagsCount}</span> flags · | |
| <span class="tabular-nums">{validationsCount}</span> validations · stored in | |
| <code class="font-mono">localStorage</code> (persists across sessions) | |
| </div> | |
| </div> | |
| <div class="flex shrink-0 items-center gap-1"> | |
| <Button variant="outline" size="sm" onclick={copyAll}> | |
| <ClipboardIcon size={12} weight="duotone" /> Copy | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon-sm" | |
| onclick={clearAll} | |
| aria-label="Clear local review data" | |
| > | |
| <TrashIcon size={12} weight="duotone" /> | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <Dialog.Footer class="border-t px-4 pt-3 pb-4"> | |
| <Button variant="ghost" size="sm" onclick={() => (open = false)}>Cancel</Button> | |
| <Button size="sm" onclick={save} disabled={!reason}>Save flag</Button> | |
| </Dialog.Footer> | |
| </Dialog.Content> | |
| </Dialog.Root> | |