File size: 7,231 Bytes
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e5c0f0
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e5c0f0
 
 
6d55c38
8e5c0f0
 
 
 
 
 
 
 
 
6d55c38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
<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>