Skip to content

Commit 35314ef

Browse files
authored
feat(detections): quick review (Correct/Incorrect) in action menu (#2809)
* feat(i18n): add markCorrect/markFalsePositive labels for action menu * test(actionmenu): align mock fixture with Detection type Replace snake_case fields and the `as Detection` cast with a fully typed object matching the camelCase Detection interface, adding the required beginTime, endTime, speciesCode, and verified fields. * feat(actionmenu): add variant prop for default/overlay trigger styling Adds variant='default'|'overlay' prop to ActionMenu. Default keeps light in-row styling with marker class am-trigger-default; overlay renders a semi-transparent dark rounded button for spectrogram use with marker class am-trigger-overlay. TDD: tests written first. * feat(actionmenu): add optional Download menu item * feat(actionmenu): add Correct/Incorrect quick-review items Adds onMarkCorrect and onMarkFalsePositive props with corresponding menu items at the top of the action menu. Items are hidden when detection is locked. Active state badges (tick/cross) appear next to the relevant item when the detection is already verified. Updates the i18n mock in test setup with the two new translation keys. * refactor(actionmenu): remove leftover DaisyUI classes, use native Tailwind * feat(detections): wire quick-review handlers to ActionMenu * refactor(dashboard): switch DetectionCard to merged ActionMenu (overlay variant) * feat(detections): wire quick-review handlers in card grid views Add handleMarkCorrect and handleMarkFalsePositive to the shared useDetectionActions composable and pass them through DetectionCardGrid and DetectionsCardView. The handlers fire the review API immediately without a confirmation modal, matching DetectionRow fast-triage UX. * refactor(dashboard): remove CardActionMenu, replaced by merged ActionMenu * docs(ui): document ActionMenu variant and quick-review props * fix(actionmenu): address review feedback on auth gating, a11y, theming - Restructure canEdit gate so trigger and Download are available to unauthenticated users (matches old CardActionMenu behavior) - Add hover/padding to menu items (previously relied on dead DaisyUI classes) - Restore focus to trigger button after an action is selected, matching the Escape handler - Use role="separator" on the quick-review divider - Drop leftover dropdown class from the root div - Switch overlay variant to theme-aware CSS vars with backdrop blur instead of hardcoded slate * fix(actionmenu): address PR review feedback - Gate quick-review items by handler presence (not just canEdit/locked) (Gemini: ActionMenu.svelte:240) - Restore verified badge on Review item so status stays visible when the detection is locked and quick-review items are hidden (Gemini: ActionMenu.svelte:301) - Extract setDetectionVerification into $lib/utils/reviewDetection and reuse from DetectionRow and useDetectionActions composable (Gemini: DetectionRow.svelte:207) - Use consistent Dutch wording Juist/Onjuist in nl.json (CodeRabbit: nl.json:660) - Update ActionMenu tests to supply both callbacks when expecting quick-review items to be rendered * fix(actionmenu): address second-round review feedback - Gate every menu item by its own callback presence, so a consumer that omits a handler does not render a no-op item (Gemini + CodeRabbit) - Make setDetectionVerification pure: accept detectionId, return boolean, let callers update detection.verified and trigger refresh themselves (Gemini) - Forward rest HTML attributes on the root div so callers can pass id, data-*, aria-* and other standard attributes, per the ui components convention (CodeRabbit)
1 parent 4d76a89 commit 35314ef

20 files changed

Lines changed: 474 additions & 407 deletions

File tree

frontend/src/lib/desktop/components/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@ The following components are located in their feature directories:
151151
- `DailySummaryCard.svelte` - Daily species summary with hourly heatmap
152152
- `DetectionCardGrid.svelte` - Card grid view of recent detections
153153
- `DetectionCard.svelte` - Individual detection card with spectrogram background
154-
- `CardActionMenu.svelte` - Dropdown action menu for detection cards
155154
- `PlayOverlay.svelte` - Audio play button overlay for detection cards
156155
- `ConfidenceBadge.svelte` - Confidence level badge display
157156
- `WeatherBadge.svelte` - Weather condition badge display

frontend/src/lib/desktop/components/ui/ActionMenu.svelte

Lines changed: 201 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,50 @@
11
<!--
22
ActionMenu Component
3-
3+
44
A dropdown menu component that provides action buttons for detection items.
55
Displays common actions like review, toggle species visibility, lock/unlock, and delete.
6-
6+
77
Features:
88
- Automatically positions menu to stay within viewport
99
- High z-index (9999) to appear above all other elements
1010
- Keyboard navigation support (Escape to close)
1111
- Click-outside-to-close behavior
1212
- Responsive positioning (above/below button based on available space)
1313
- Fixed positioning to handle scrollable containers
14-
14+
1515
@component
1616
-->
1717
<script lang="ts">
1818
import { cn } from '$lib/utils/cn';
1919
import type { Detection } from '$lib/types/detection.types';
20-
import { MoreVertical, SquarePen, Eye, EyeOff, Lock, LockOpen, Trash2 } from '@lucide/svelte';
20+
import type { HTMLAttributes } from 'svelte/elements';
21+
import {
22+
MoreVertical,
23+
SquarePen,
24+
Eye,
25+
EyeOff,
26+
Lock,
27+
LockOpen,
28+
Trash2,
29+
Download,
30+
CircleCheck,
31+
CircleX,
32+
} from '@lucide/svelte';
2133
import { dropdown } from '$lib/utils/transitions';
2234
import { auth } from '$lib/stores/auth';
2335
import { t } from '$lib/i18n';
2436
2537
let canEdit = $derived(!$auth.security.enabled || $auth.security.accessAllowed);
2638
27-
interface Props {
39+
interface Props extends HTMLAttributes<HTMLDivElement> {
2840
/** The detection object containing data for this action menu */
2941
detection: Detection;
3042
/** Whether the species is currently excluded from detection */
3143
isExcluded?: boolean;
44+
/** Callback fired when user marks the detection as correct */
45+
onMarkCorrect?: () => void;
46+
/** Callback fired when user marks the detection as false positive */
47+
onMarkFalsePositive?: () => void;
3248
/** Callback fired when user clicks review action */
3349
onReview?: () => void;
3450
/** Callback fired when user toggles species visibility */
@@ -37,8 +53,12 @@
3753
onToggleLock?: () => void;
3854
/** Callback fired when user deletes the detection */
3955
onDelete?: () => void;
56+
/** Callback fired when user downloads the detection audio */
57+
onDownload?: () => void;
4058
/** Additional CSS classes to apply to the component */
4159
className?: string;
60+
/** Visual variant - `default` for in-row use, `overlay` for spectrogram overlay */
61+
variant?: 'default' | 'overlay';
4262
/** Callback fired when menu opens */
4363
onMenuOpen?: () => void;
4464
/** Callback fired when menu closes */
@@ -48,13 +68,18 @@
4868
let {
4969
detection,
5070
isExcluded = false,
71+
onMarkCorrect,
72+
onMarkFalsePositive,
5173
onReview,
5274
onToggleSpecies,
5375
onToggleLock,
5476
onDelete,
77+
onDownload,
5578
className = '',
79+
variant = 'default',
5680
onMenuOpen,
5781
onMenuClose,
82+
...rest
5883
}: Props = $props();
5984
6085
let isOpen = $state(false);
@@ -114,6 +139,7 @@
114139
function handleAction(action: (() => void) | undefined) {
115140
isOpen = false;
116141
onMenuClose?.();
142+
buttonElement?.focus();
117143
if (action) {
118144
action();
119145
}
@@ -180,12 +206,17 @@
180206
});
181207
</script>
182208

183-
{#if canEdit}
184-
<div class={cn('dropdown relative', className)}>
209+
{#if canEdit || onDownload}
210+
<div {...rest} class={cn('relative', className)}>
185211
<button
186212
bind:this={buttonElement}
187213
onclick={handleOpen}
188-
class="btn btn-ghost btn-sm min-h-8 h-8 w-8 p-1"
214+
class={cn(
215+
'am-trigger inline-flex items-center justify-center w-8 h-8 p-1 transition-colors',
216+
variant === 'overlay'
217+
? 'am-trigger-overlay text-white bg-black/50 hover:bg-slate-700/80 backdrop-blur-sm rounded-full'
218+
: 'am-trigger-default text-[var(--color-base-content)] hover:bg-[var(--color-base-200)] rounded-md'
219+
)}
189220
aria-label="Actions menu"
190221
aria-haspopup="true"
191222
aria-expanded={isOpen}
@@ -194,78 +225,180 @@
194225
</button>
195226

196227
{#if isOpen}
228+
{@const itemHoverClass =
229+
variant === 'overlay' ? 'hover:bg-white/10' : 'hover:bg-[var(--color-base-200)]'}
197230
<ul
198231
bind:this={menuElement}
199232
in:dropdown
200233
out:dropdown={{ duration: 100 }}
201-
class="fixed menu p-2 shadow-lg bg-[var(--color-base-100)] rounded-box w-52 border border-[var(--color-base-300)]"
234+
class={cn(
235+
'fixed z-[1100] p-2 shadow-lg rounded-lg w-52 border',
236+
variant === 'overlay'
237+
? 'bg-[var(--color-base-100)]/95 border-[var(--color-base-300)]/60 backdrop-blur-md'
238+
: 'bg-[var(--color-base-100)] border-[var(--color-base-300)]'
239+
)}
202240
role="menu"
203241
>
204-
<li>
205-
<button
206-
onclick={() => handleAction(onReview)}
207-
class="text-sm w-full text-left"
208-
role="menuitem"
209-
>
210-
<div class="flex items-center gap-2">
211-
<SquarePen class="size-4" />
212-
<span>{t('dashboard.recentDetections.actions.review')}</span>
213-
{#if detection.verified === 'correct'}
214-
<span class="badge badge-success badge-sm">✓</span>
215-
{:else if detection.verified === 'false_positive'}
216-
<span class="badge badge-error badge-sm">✗</span>
217-
{/if}
218-
</div>
219-
</button>
220-
</li>
242+
{#if canEdit && !detection.locked && (onMarkCorrect || onMarkFalsePositive)}
243+
{#if onMarkCorrect}
244+
<li>
245+
<button
246+
onclick={() => handleAction(onMarkCorrect)}
247+
class={cn(
248+
'text-sm w-full text-left px-3 py-2 rounded-md transition-colors',
249+
itemHoverClass
250+
)}
251+
role="menuitem"
252+
>
253+
<div class="flex items-center gap-2">
254+
<CircleCheck class="size-4 text-[var(--color-success)]" />
255+
<span>{t('dashboard.recentDetections.actions.markCorrect')}</span>
256+
{#if detection.verified === 'correct'}
257+
<span
258+
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-[var(--color-success)]/15 text-[var(--color-success)]"
259+
>✓</span
260+
>
261+
{/if}
262+
</div>
263+
</button>
264+
</li>
265+
{/if}
266+
{#if onMarkFalsePositive}
267+
<li>
268+
<button
269+
onclick={() => handleAction(onMarkFalsePositive)}
270+
class={cn(
271+
'text-sm w-full text-left px-3 py-2 rounded-md transition-colors',
272+
itemHoverClass
273+
)}
274+
role="menuitem"
275+
>
276+
<div class="flex items-center gap-2">
277+
<CircleX class="size-4 text-[var(--color-error)]" />
278+
<span>{t('dashboard.recentDetections.actions.markFalsePositive')}</span>
279+
{#if detection.verified === 'false_positive'}
280+
<span
281+
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-[var(--color-error)]/15 text-[var(--color-error)]"
282+
>✗</span
283+
>
284+
{/if}
285+
</div>
286+
</button>
287+
</li>
288+
{/if}
289+
<li role="separator" class="my-1 h-px bg-[var(--color-base-300)]"></li>
290+
{/if}
291+
292+
{#if canEdit && onReview}
293+
<li>
294+
<button
295+
onclick={() => handleAction(onReview)}
296+
class={cn(
297+
'text-sm w-full text-left px-3 py-2 rounded-md transition-colors',
298+
itemHoverClass
299+
)}
300+
role="menuitem"
301+
>
302+
<div class="flex items-center gap-2">
303+
<SquarePen class="size-4" />
304+
<span>{t('dashboard.recentDetections.actions.review')}</span>
305+
{#if detection.verified === 'correct'}
306+
<span
307+
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-[var(--color-success)]/15 text-[var(--color-success)]"
308+
>✓</span
309+
>
310+
{:else if detection.verified === 'false_positive'}
311+
<span
312+
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-[var(--color-error)]/15 text-[var(--color-error)]"
313+
>✗</span
314+
>
315+
{/if}
316+
</div>
317+
</button>
318+
</li>
319+
{/if}
320+
321+
{#if canEdit && onToggleSpecies}
322+
<li>
323+
<button
324+
onclick={() => handleAction(onToggleSpecies)}
325+
class={cn(
326+
'text-sm w-full text-left px-3 py-2 rounded-md transition-colors',
327+
itemHoverClass
328+
)}
329+
role="menuitem"
330+
>
331+
<div class="flex items-center gap-2">
332+
{#if isExcluded}
333+
<Eye class="size-4" />
334+
{:else}
335+
<EyeOff class="size-4" />
336+
{/if}
337+
{#if isExcluded}
338+
<span>{t('dashboard.recentDetections.actions.showSpecies')}</span>
339+
{:else}
340+
<span>{t('dashboard.recentDetections.actions.ignoreSpecies')}</span>
341+
{/if}
342+
</div>
343+
</button>
344+
</li>
345+
{/if}
221346

222-
<li>
223-
<button
224-
onclick={() => handleAction(onToggleSpecies)}
225-
class="text-sm w-full text-left"
226-
role="menuitem"
227-
>
228-
<div class="flex items-center gap-2">
229-
{#if isExcluded}
230-
<Eye class="size-4" />
231-
{:else}
232-
<EyeOff class="size-4" />
233-
{/if}
234-
{#if isExcluded}
235-
<span>{t('dashboard.recentDetections.actions.showSpecies')}</span>
236-
{:else}
237-
<span>{t('dashboard.recentDetections.actions.ignoreSpecies')}</span>
238-
{/if}
239-
</div>
240-
</button>
241-
</li>
347+
{#if canEdit && onToggleLock}
348+
<li>
349+
<button
350+
onclick={() => handleAction(onToggleLock)}
351+
class={cn(
352+
'text-sm w-full text-left px-3 py-2 rounded-md transition-colors',
353+
itemHoverClass
354+
)}
355+
role="menuitem"
356+
>
357+
<div class="flex items-center gap-2">
358+
{#if detection.locked}
359+
<Lock class="size-4" />
360+
{:else}
361+
<LockOpen class="size-4" />
362+
{/if}
363+
{#if detection.locked}
364+
<span>{t('dashboard.recentDetections.actions.unlockDetection')}</span>
365+
{:else}
366+
<span>{t('dashboard.recentDetections.actions.lockDetection')}</span>
367+
{/if}
368+
</div>
369+
</button>
370+
</li>
371+
{/if}
242372

243-
<li>
244-
<button
245-
onclick={() => handleAction(onToggleLock)}
246-
class="text-sm w-full text-left"
247-
role="menuitem"
248-
>
249-
<div class="flex items-center gap-2">
250-
{#if detection.locked}
251-
<Lock class="size-4" />
252-
{:else}
253-
<LockOpen class="size-4" />
254-
{/if}
255-
{#if detection.locked}
256-
<span>{t('dashboard.recentDetections.actions.unlockDetection')}</span>
257-
{:else}
258-
<span>{t('dashboard.recentDetections.actions.lockDetection')}</span>
259-
{/if}
260-
</div>
261-
</button>
262-
</li>
373+
{#if onDownload}
374+
<li>
375+
<button
376+
onclick={() => handleAction(onDownload)}
377+
class={cn(
378+
'text-sm w-full text-left px-3 py-2 rounded-md transition-colors',
379+
itemHoverClass
380+
)}
381+
role="menuitem"
382+
>
383+
<div class="flex items-center gap-2">
384+
<Download class="size-4" />
385+
<span>{t('media.audio.download')}</span>
386+
</div>
387+
</button>
388+
</li>
389+
{/if}
263390

264-
{#if !detection.locked}
391+
{#if canEdit && !detection.locked && onDelete}
392+
<li role="separator" class="my-1 h-px bg-[var(--color-base-300)]"></li>
265393
<li>
266394
<button
267395
onclick={() => handleAction(onDelete)}
268-
class="text-sm w-full text-left text-[var(--color-error)]"
396+
class={cn(
397+
'text-sm w-full text-left px-3 py-2 rounded-md text-[var(--color-error)] transition-colors',
398+
variant === 'overlay'
399+
? 'hover:bg-[var(--color-error)]/20'
400+
: 'hover:bg-[var(--color-error)]/10'
401+
)}
269402
role="menuitem"
270403
>
271404
<div class="flex items-center gap-2">
@@ -279,10 +412,3 @@
279412
{/if}
280413
</div>
281414
{/if}
282-
283-
<style>
284-
.menu {
285-
/* Above cards and header dropdowns, below modals */
286-
z-index: 1100;
287-
}
288-
</style>

0 commit comments

Comments
 (0)