opencs2-dataset-viewer / src /lib /components /eval-flag-dialog.svelte
blanchon's picture
Fix eval-flag-dialog infinite effect loop on open
8e5c0f0
<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>