Skip to content

Commit c648753

Browse files
Implement Recording List View with search, sort, and export (#272)
* Initial plan * Add RecordingCard and RecordingList components with tests Co-authored-by: alexthemitchell <[email protected]> * Implement Recording List View with search, sort, and export Co-authored-by: alexthemitchell <[email protected]> * Address PR review comments: fix dependencies, error handling, accessibility, and code duplication Co-authored-by: alexthemitchell <[email protected]> * Improve filename sanitization and remove unnecessary defensive check Co-authored-by: alexthemitchell <[email protected]> * Fix prettier formatting for timestamp parsing Co-authored-by: alexthemitchell <[email protected]> * Improve formatBytes safety and use Date object for timestamp formatting Co-authored-by: alexthemitchell <[email protected]> * Add focus trap to modal, fix formatBytes bounds, and handle future timestamps Co-authored-by: alexthemitchell <[email protected]> * Add robustness checks: focus restoration, negative duration, invalid timestamps Co-authored-by: alexthemitchell <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: alexthemitchell <[email protected]> Co-authored-by: Alex Mitchell <[email protected]>
1 parent b8e5cf4 commit c648753

File tree

8 files changed

+1881
-33
lines changed

8 files changed

+1881
-33
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Recording List View Component Implementation
2+
3+
## Overview
4+
5+
Implemented comprehensive UI for viewing, searching, sorting, and managing IQ recordings stored in IndexedDB (Issue #258).
6+
7+
## Components Created
8+
9+
### RecordingCard (`src/components/Recordings/RecordingCard.tsx`)
10+
11+
- Displays individual recording with metadata (frequency, label, date, duration, size)
12+
- Action buttons: Play (▶️), Export (⬇️), Edit Tags (✏️), Delete (🗑️)
13+
- Full accessibility: ARIA labels, keyboard navigation, 44×44px touch targets
14+
- Formatting utilities for frequency (Hz/kHz/MHz/GHz), bytes (B/KB/MB/GB), duration (mm:ss/hh:mm:ss), timestamps
15+
16+
### RecordingList (`src/components/Recordings/RecordingList.tsx`)
17+
18+
- Grid layout (responsive: auto-fill minmax(300px, 1fr))
19+
- Search: filters by label or frequency (case-insensitive, real-time)
20+
- Sort: Date/Frequency/Size/Duration with ascending/descending toggle
21+
- States: Loading (EmptyState), Empty (helpful message), Results (grid of cards)
22+
- Accessibility: keyboard controls, ARIA roles, screen reader support
23+
24+
### Recordings Page Updates (`src/pages/Recordings.tsx`)
25+
26+
- Loads recordings from RecordingManager on mount
27+
- Delete confirmation modal (Escape key support, focus management)
28+
- Export downloads binary .iq file (Float32 I/Q interleaved)
29+
- Play button placeholder (sets selectedRecordingId for future playback)
30+
31+
## Key Patterns
32+
33+
### State Management
34+
35+
```typescript
36+
const [recordings, setRecordings] = useState<RecordingMeta[]>([]);
37+
const [isLoading, setIsLoading] = useState(true);
38+
const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(
39+
null,
40+
);
41+
```
42+
43+
### Search/Filter/Sort Logic
44+
45+
- useMemo for derived state (filtered + sorted recordings)
46+
- Default sort: date descending (newest first)
47+
- Toggle sort direction on same field click
48+
- Search matches label OR frequency string
49+
50+
### Accessibility Wins
51+
52+
- Semantic HTML: article, section, aside, role="dialog"
53+
- ARIA: labelledby, describedby, live regions, pressed states
54+
- Keyboard: Tab navigation, Enter/Space activation, Escape closes dialogs
55+
- Reduced motion: `@media (prefers-reduced-motion: reduce)` disables transitions
56+
- Touch targets: min-width/height 44px per WCAG 2.1 AA
57+
58+
## Testing Strategy
59+
60+
- Component tests (RecordingCard, RecordingList): Mock RecordingManager, test interactions, formatting, accessibility
61+
- Page tests (Recordings.tsx): Mock hooks and manager, test async loading, delete flow, integration
62+
- 30 new tests added, all passing
63+
64+
## Future Enhancements
65+
66+
- Tag editing modal (button exists, handler TBD)
67+
- Actual playback controls (Audio/IQ visualization)
68+
- Virtual scrolling (react-virtuoso) if >1000 recordings
69+
- Batch operations (multi-select, bulk delete)
70+
- Advanced filters (date range, size range, signal type)
71+
72+
## Dependencies
73+
74+
- Requires RecordingManager from Issue #257 (IndexedDB Storage Layer)
75+
- No new npm packages added (avoided react-window/react-virtuoso for minimal changes)
76+
- Uses existing EmptyState, type definitions from lib/recording/types.ts
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import React from "react";
2+
import {
3+
formatBytes,
4+
formatDuration,
5+
formatTimestamp,
6+
} from "../../utils/format";
7+
import { formatFrequency } from "../../utils/frequency";
8+
import type { RecordingMeta } from "../../lib/recording/types";
9+
10+
export interface RecordingCardProps {
11+
recording: RecordingMeta;
12+
onRecordingSelect: (id: string) => void;
13+
onPlay: (id: string) => void;
14+
onDelete: (id: string) => void;
15+
onExport: (id: string) => void;
16+
onEditTags?: (id: string) => void;
17+
}
18+
19+
/**
20+
* RecordingCard component - displays a single recording with metadata and actions
21+
*/
22+
function RecordingCard({
23+
recording,
24+
onRecordingSelect,
25+
onPlay,
26+
onDelete,
27+
onExport,
28+
onEditTags,
29+
}: RecordingCardProps): React.JSX.Element {
30+
const handlePlay = (): void => {
31+
onRecordingSelect(recording.id);
32+
onPlay(recording.id);
33+
};
34+
35+
const handleDelete = (): void => {
36+
onDelete(recording.id);
37+
};
38+
39+
const handleExport = (): void => {
40+
onExport(recording.id);
41+
};
42+
43+
const handleEditTags = (): void => {
44+
if (onEditTags) {
45+
onEditTags(recording.id);
46+
}
47+
};
48+
49+
const cardId = `recording-card-${recording.id}`;
50+
const titleId = `${cardId}-title`;
51+
52+
return (
53+
<article
54+
className="recording-card"
55+
aria-labelledby={titleId}
56+
data-recording-id={recording.id}
57+
>
58+
<div className="recording-card-header">
59+
<h3 id={titleId} className="recording-card-title">
60+
{recording.label ?? formatFrequency(recording.frequency)}
61+
</h3>
62+
<div className="recording-card-frequency">
63+
{formatFrequency(recording.frequency)}
64+
</div>
65+
</div>
66+
67+
<div className="recording-card-metadata">
68+
<div className="recording-card-meta-item">
69+
<span className="recording-card-meta-label">Date:</span>
70+
<span className="recording-card-meta-value">
71+
{formatTimestamp(recording.timestamp)}
72+
</span>
73+
</div>
74+
<div className="recording-card-meta-item">
75+
<span className="recording-card-meta-label">Duration:</span>
76+
<span className="recording-card-meta-value">
77+
{formatDuration(recording.duration)}
78+
</span>
79+
</div>
80+
<div className="recording-card-meta-item">
81+
<span className="recording-card-meta-label">Size:</span>
82+
<span className="recording-card-meta-value">
83+
{formatBytes(recording.size)}
84+
</span>
85+
</div>
86+
</div>
87+
88+
<div className="recording-card-actions" role="group" aria-label="Actions">
89+
<button
90+
type="button"
91+
className="recording-card-action"
92+
onClick={handlePlay}
93+
aria-label={`Play recording ${recording.label ?? formatFrequency(recording.frequency)}`}
94+
title="Play"
95+
>
96+
▶️
97+
</button>
98+
<button
99+
type="button"
100+
className="recording-card-action"
101+
onClick={handleExport}
102+
aria-label={`Export recording ${recording.label ?? formatFrequency(recording.frequency)}`}
103+
title="Export"
104+
>
105+
⬇️
106+
</button>
107+
{onEditTags && (
108+
<button
109+
type="button"
110+
className="recording-card-action"
111+
onClick={handleEditTags}
112+
aria-label={`Edit tags for ${recording.label ?? formatFrequency(recording.frequency)}`}
113+
title="Edit tags"
114+
>
115+
✏️
116+
</button>
117+
)}
118+
<button
119+
type="button"
120+
className="recording-card-action recording-card-action-delete"
121+
onClick={handleDelete}
122+
aria-label={`Delete recording ${recording.label ?? formatFrequency(recording.frequency)}`}
123+
title="Delete"
124+
>
125+
🗑️
126+
</button>
127+
</div>
128+
129+
<style>{`
130+
.recording-card {
131+
background: var(--panel-background, #1a1a1a);
132+
border: 1px solid var(--border-color, #333);
133+
border-radius: 8px;
134+
padding: 16px;
135+
display: flex;
136+
flex-direction: column;
137+
gap: 12px;
138+
transition: border-color 0.2s ease;
139+
}
140+
141+
.recording-card:hover {
142+
border-color: var(--accent-cyan, #00bcd4);
143+
}
144+
145+
.recording-card:focus-within {
146+
border-color: var(--accent-cyan, #00bcd4);
147+
outline: 2px solid var(--accent-cyan, #00bcd4);
148+
outline-offset: 2px;
149+
}
150+
151+
.recording-card-header {
152+
display: flex;
153+
justify-content: space-between;
154+
align-items: flex-start;
155+
gap: 8px;
156+
}
157+
158+
.recording-card-title {
159+
margin: 0;
160+
font-size: 16px;
161+
font-weight: 600;
162+
color: var(--text-primary, #fff);
163+
flex: 1;
164+
min-width: 0;
165+
overflow: hidden;
166+
text-overflow: ellipsis;
167+
white-space: nowrap;
168+
}
169+
170+
.recording-card-frequency {
171+
font-size: 14px;
172+
font-weight: 600;
173+
color: var(--accent-cyan, #00bcd4);
174+
font-family: "Courier New", monospace;
175+
white-space: nowrap;
176+
}
177+
178+
.recording-card-metadata {
179+
display: flex;
180+
flex-direction: column;
181+
gap: 6px;
182+
}
183+
184+
.recording-card-meta-item {
185+
display: flex;
186+
justify-content: space-between;
187+
font-size: 13px;
188+
}
189+
190+
.recording-card-meta-label {
191+
color: var(--text-secondary, #888);
192+
font-weight: 500;
193+
}
194+
195+
.recording-card-meta-value {
196+
color: var(--text-primary, #fff);
197+
font-family: "Courier New", monospace;
198+
}
199+
200+
.recording-card-actions {
201+
display: flex;
202+
gap: 8px;
203+
margin-top: 4px;
204+
}
205+
206+
.recording-card-action {
207+
flex: 1;
208+
min-width: 44px;
209+
min-height: 44px;
210+
padding: 8px;
211+
background: var(--button-background, #252525);
212+
border: 1px solid var(--border-color, #333);
213+
border-radius: 4px;
214+
color: var(--text-primary, #fff);
215+
font-size: 16px;
216+
cursor: pointer;
217+
transition: all 0.2s ease;
218+
display: flex;
219+
align-items: center;
220+
justify-content: center;
221+
}
222+
223+
.recording-card-action:hover {
224+
background: var(--button-hover-background, #333);
225+
border-color: var(--accent-cyan, #00bcd4);
226+
}
227+
228+
.recording-card-action:focus {
229+
outline: 2px solid var(--accent-cyan, #00bcd4);
230+
outline-offset: 2px;
231+
}
232+
233+
.recording-card-action:active {
234+
transform: scale(0.95);
235+
}
236+
237+
.recording-card-action-delete:hover {
238+
background: rgb(244 67 54 / 10%);
239+
border-color: var(--accent-red, #f44336);
240+
}
241+
242+
@media (prefers-reduced-motion: reduce) {
243+
.recording-card,
244+
.recording-card-action {
245+
transition-duration: 0s;
246+
}
247+
.recording-card-action:active {
248+
transform: none;
249+
}
250+
}
251+
`}</style>
252+
</article>
253+
);
254+
}
255+
256+
export default RecordingCard;

0 commit comments

Comments
 (0)