diff --git a/ui/src/features/clips/ClipsPage.test.tsx b/ui/src/features/clips/ClipsPage.test.tsx
index b2091272..b31b60ec 100644
--- a/ui/src/features/clips/ClipsPage.test.tsx
+++ b/ui/src/features/clips/ClipsPage.test.tsx
@@ -2,7 +2,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, within } from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, useLocation } from 'react-router-dom'
import type { ClipListSnapshot } from '../../api/client'
import type { CameraResponse } from '../../api/generated/types'
@@ -60,10 +61,16 @@ function renderEventsPage({
render(
+
,
)
}
+function LocationProbe() {
+ const location = useLocation()
+ return
{`${location.pathname}${location.search}`}
+}
+
describe('ClipsPage event list', () => {
afterEach(() => {
cleanup()
@@ -147,4 +154,33 @@ describe('ClipsPage event list', () => {
expect(emptyState).toBeTruthy()
expect(screen.queryByRole('table')).toBeNull()
})
+
+ it('maps quick filters to existing URL-synced clip query params', async () => {
+ // Given: The Events page is open with existing URL-backed filter state
+ const user = userEvent.setup()
+ renderEventsPage({
+ clipList: {
+ httpStatus: 200,
+ limit: 25,
+ next_cursor: null,
+ has_more: false,
+ clips: [],
+ },
+ route: '/events?detected=any',
+ })
+
+ // When: Applying quick filters and opening the advanced sheet
+ await user.click(screen.getByRole('button', { name: 'Alerts' }))
+ await user.click(screen.getByRole('button', { name: 'Packages' }))
+ await user.click(screen.getByRole('button', { name: 'High risk' }))
+ await user.click(screen.getByRole('button', { name: 'More filters' }))
+
+ // Then: Quick filters should use existing query params and full filters stay available
+ expect(screen.getByTestId('location').textContent).toContain('alerted=true')
+ expect(screen.getByTestId('location').textContent).toContain('activity_type=package')
+ expect(screen.getByTestId('location').textContent).toContain('risk_level=high')
+ expect(screen.getByLabelText('Alert status')).toBeTruthy()
+ expect(screen.getByLabelText('Detection')).toBeTruthy()
+ expect(screen.getByLabelText('Results per page')).toBeTruthy()
+ })
})
diff --git a/ui/src/features/clips/ClipsPage.tsx b/ui/src/features/clips/ClipsPage.tsx
index 2d557ebf..8376a596 100644
--- a/ui/src/features/clips/ClipsPage.tsx
+++ b/ui/src/features/clips/ClipsPage.tsx
@@ -6,9 +6,9 @@ import { useCamerasQuery } from '../../api/hooks/useCamerasQuery'
import { useClipsQuery } from '../../api/hooks/useClipsQuery'
import { ApiKeyGate } from '../../components/ui/ApiKeyGate'
import { Button } from '../../components/ui/Button'
-import { Card } from '../../components/ui/Card'
import { EmptyState } from '../../components/ui/EmptyState'
import { EventCard } from '../../components/ui/EventCard'
+import { FilterChips, type FilterChip } from '../../components/ui/FilterChips'
import { MediaPanel } from '../../components/ui/MediaPanel'
import { RiskBadge } from '../../components/ui/RiskBadge'
import { StatusBadge } from '../../components/ui/StatusBadge'
@@ -44,6 +44,7 @@ interface ClipsFilterPanelProps {
cameraOptions: string[]
initialFormState: ClipsFilterFormState
onApply: (form: ClipsFilterFormState) => void
+ onClose: () => void
onReset: () => void
}
@@ -51,6 +52,7 @@ function ClipsFilterPanel({
cameraOptions,
initialFormState,
onApply,
+ onClose,
onReset,
}: ClipsFilterPanelProps) {
const [formState, setFormState] = useState(initialFormState)
@@ -66,7 +68,14 @@ function ClipsFilterPanel({
}
return (
-
+
+
)
}
@@ -202,8 +211,31 @@ function eventDetailPath(clipId: string, routeSearch: string): string {
return `/events/${encodeURIComponent(clipId)}${suffix}`
}
+function startOfTodayIso(): string {
+ const date = new Date()
+ date.setHours(0, 0, 0, 0)
+ return date.toISOString()
+}
+
+function isTodayFilterActive(since: string | null | undefined): boolean {
+ if (!since) {
+ return false
+ }
+ const sinceDate = new Date(since)
+ if (Number.isNaN(sinceDate.valueOf())) {
+ return false
+ }
+ const today = new Date()
+ return (
+ sinceDate.getFullYear() === today.getFullYear()
+ && sinceDate.getMonth() === today.getMonth()
+ && sinceDate.getDate() === today.getDate()
+ )
+}
+
export function ClipsPage() {
const [searchParams, setSearchParams] = useSearchParams()
+ const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const routeSearch = searchParams.toString()
const query = useMemo(() => parseClipsQuery(searchParams), [searchParams])
const filterQuery = useMemo(() => queryWithoutCursor(query), [query])
@@ -246,6 +278,7 @@ export function ClipsPage() {
const nextParams = formStateToSearchParams(formState)
clearCursorHistory(queryToSearchParams(queryWithoutCursor(nextQuery)).toString())
setSearchParams(nextParams)
+ setShowAdvancedFilters(false)
}
function resetFilters(): void {
@@ -256,6 +289,34 @@ export function ClipsPage() {
const nextParams = queryToSearchParams(resetQuery)
clearCursorHistory(nextParams.toString())
setSearchParams(nextParams)
+ setShowAdvancedFilters(false)
+ }
+
+ function applyQuery(nextQuery: ReturnType): void {
+ const nextParams = queryToRouteSearchParams(queryWithoutCursor(nextQuery))
+ clearCursorHistory(nextParams.toString())
+ setSearchParams(nextParams)
+ }
+
+ function toggleQueryValue(
+ field: 'alerted' | 'activity_type' | 'risk_level',
+ value: boolean | string,
+ ): void {
+ const currentValue = query[field]
+ applyQuery({
+ ...query,
+ cursor: undefined,
+ [field]: currentValue === value ? undefined : value,
+ })
+ }
+
+ function toggleTodayFilter(): void {
+ applyQuery({
+ ...query,
+ cursor: undefined,
+ since: isTodayFilterActive(query.since) ? undefined : startOfTodayIso(),
+ until: undefined,
+ })
}
async function submitApiKey(apiKey: string): Promise {
@@ -298,6 +359,44 @@ export function ClipsPage() {
setSearchParams(queryToRouteSearchParams({ ...query, cursor: popped.previousCursor }))
}
+ const quickFilterChips: FilterChip[] = [
+ { id: 'today', label: 'Today', active: isTodayFilterActive(query.since) },
+ { id: 'alerts', label: 'Alerts', active: query.alerted === true },
+ { id: 'people', label: 'People', active: query.activity_type === 'person' },
+ { id: 'vehicles', label: 'Vehicles', active: query.activity_type === 'vehicle' },
+ { id: 'packages', label: 'Packages', active: query.activity_type === 'package' },
+ { id: 'high-risk', label: 'High risk', active: query.risk_level === 'high' },
+ { id: 'more', label: 'More filters', active: showAdvancedFilters },
+ ]
+
+ function selectQuickFilter(id: string): void {
+ switch (id) {
+ case 'today':
+ toggleTodayFilter()
+ break
+ case 'alerts':
+ toggleQueryValue('alerted', true)
+ break
+ case 'people':
+ toggleQueryValue('activity_type', 'person')
+ break
+ case 'vehicles':
+ toggleQueryValue('activity_type', 'vehicle')
+ break
+ case 'packages':
+ toggleQueryValue('activity_type', 'package')
+ break
+ case 'high-risk':
+ toggleQueryValue('risk_level', 'high')
+ break
+ case 'more':
+ setShowAdvancedFilters((current) => !current)
+ break
+ default:
+ break
+ }
+ }
+
return (
@@ -310,13 +409,22 @@ export function ClipsPage() {
-
+
+
+ {showAdvancedFilters ? (
+
+ setShowAdvancedFilters(false)}
+ onReset={resetFilters}
+ />
+
+ ) : null}
diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css
index 181d1196..0cded803 100644
--- a/ui/src/styles/global.css
+++ b/ui/src/styles/global.css
@@ -439,6 +439,31 @@ a:hover {
opacity: 0.62;
}
+.events-filter-bar {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.filters-sheet {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface-elevated);
+ padding: var(--space-4);
+ box-shadow: var(--shadow);
+}
+
+.advanced-filters {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.advanced-filters__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
.empty-state {
display: grid;
gap: var(--space-2);
@@ -1496,6 +1521,20 @@ a:hover {
grid-template-columns: 1fr;
}
+ .filters-sheet {
+ position: fixed;
+ left: var(--space-3);
+ right: var(--space-3);
+ bottom: calc(var(--space-7) + 4.75rem);
+ z-index: 12;
+ max-height: calc(100vh - 8rem);
+ overflow: auto;
+ }
+
+ .advanced-filters__header {
+ flex-direction: column;
+ }
+
.camera-form-grid {
grid-template-columns: 1fr;
}