Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 7 additions & 21 deletions frontend/components/ui-header/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
import { ChevronUpIcon } from '@radix-ui/react-icons';
import { MoveUpRight } from 'lucide-react';
import { ArrowUpRight } from 'lucide-react';

import {
DropdownMenu,
Expand All @@ -22,7 +22,7 @@ export default function AppHeader() {
const { activeSessionId } = useGlobalContext();

return (
<header className="flex justify-between items-center p-2 border-b h-25">
<header className="flex justify-between items-center px-2 py-3 border-b h-25">
{/* logo */}
<div className="flex items-center">
<Image
Expand All @@ -34,41 +34,27 @@ export default function AppHeader() {
/>
</div>

{/* update, issues, import, export, help */}
{/* import, export, help */}
<div className="flex h-full items-center space-x-4">
<Button
variant="link"
className="flex items-center space-x-1 px-3 py-2"
>
<span>Update</span>
<MoveUpRight style={{ height: '10px', width: '10px', marginLeft: '-5px' }} />
</Button>
<Button
variant="link"
className="flex items-center space-x-1 px-3 py-2"
>
<span>Issues</span>
<MoveUpRight style={{ height: '10px', width: '10px', marginLeft: '-5px' }} />
</Button>

{/* Export Data */}
<Button
variant="link"
className="flex items-center space-x-1 px-3 py-2"
className="flex items-center gap-1 px-3 py-2"
onClick={() => setIsExportOpen(true)}
>
<span>Export Data</span>
<MoveUpRight style={{ height: '10px', width: '10px', marginLeft: '-5px' }} />
<ArrowUpRight size={14} className="transition-transform duration-200 hover:scale-110" />
</Button>

{/* Import Data */}
<Button
variant="link"
className="flex items-center space-x-1 px-3 py-2"
className="flex items-center gap-1 px-3 py-2"
onClick={() => setIsImportOpen(true)}
>
<span>Import Data</span>
<MoveUpRight style={{ height: '10px', width: '10px', marginLeft: '-5px' }} />
<ArrowUpRight size={14} className="transition-transform duration-200 hover:scale-110" />
</Button>

{/* help */}
Expand Down
143 changes: 88 additions & 55 deletions frontend/components/ui/export-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,21 @@ import {
import { Button } from '@/components/ui/button';
import { useNotifications } from '@/components/notifications';
import { exportEEGData, downloadCSV } from '@/lib/eeg-api';
import { ExitIcon } from '@radix-ui/react-icons';

type ExportDialogProps = {
open: boolean;
sessionId: number | null;
onOpenChange: (open: boolean) => void;
};

const ExportIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="mr-3 h-6 w-6">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
<path fill="#fff" stroke="#fff" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" d="M8 12h8M12 8l4 4-4 4" />
</svg>
);

export default function ExportDialog({
open,
sessionId,
onOpenChange,
}: ExportDialogProps) {
const notifications = useNotifications();
const [exportMode, setExportMode] = useState<'range' | 'all'>('range');
const [durationValue, setDurationValue] = useState('30');
const [durationUnit, setDurationUnit] = useState('Minutes');
const [isExporting, setIsExporting] = useState(false);
Expand All @@ -49,29 +44,30 @@ export default function ExportDialog({
return;
}

const value = parseFloat(durationValue);
if (isNaN(value) || value <= 0) {
notifications.error({
title: 'Invalid duration',
description: 'Please enter a valid number greater than 0.',
});
return;
}
const options: Record<string, string> = {};

setIsExporting(true);
try {
const options: Record<string, string> = {};
if (exportMode === 'range') {
const value = parseFloat(durationValue);
if (isNaN(value) || value <= 0) {
notifications.error({
title: 'Invalid duration',
description: 'Please enter a valid number greater than 0.',
});
return;
}

let multiplier = 1000; // default to seconds
let multiplier = 1000;
if (durationUnit === 'Minutes') multiplier = 60 * 1000;
if (durationUnit === 'Hours') multiplier = 60 * 60 * 1000;
if (durationUnit === 'Days') multiplier = 24 * 60 * 60 * 1000;

const durationMs = value * multiplier;
const now = new Date();

options.start_time = new Date(now.getTime() - durationMs).toISOString();
options.start_time = new Date(now.getTime() - value * multiplier).toISOString();
options.end_time = now.toISOString();
}

setIsExporting(true);
try {

const csvContent = await exportEEGData(sessionId, options);
downloadCSV(csvContent, sessionId);
Expand All @@ -92,47 +88,84 @@ export default function ExportDialog({
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[400px] p-8">
<DialogHeader className="mb-4 text-left">
<DialogTitle className="flex items-center text-2xl font-bold mb-0">
<ExportIcon />
<DialogTitle className="flex items-center text-2xl font-bold mb-2">
<ExitIcon className="mr-2" width={24} height={24} />
Export Data
</DialogTitle>
</DialogHeader>

<div className="py-2">
<p className="text-sm font-medium text-gray-900 mb-2">
Export data from the last:
</p>

<div className="flex gap-3 mb-4">
<input
type="number"
min="1"
value={durationValue}
onChange={(e) => setDurationValue(e.target.value)}
{/* Mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden mb-4">
<button
type="button"
onClick={() => setExportMode('range')}
disabled={isExporting}
className="flex h-10 w-24 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="relative flex-1">
<select
value={durationUnit}
onChange={(e) => setDurationUnit(e.target.value)}
disabled={isExporting}
className="appearance-none flex h-10 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-8 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<option value="Seconds">Seconds</option>
<option value="Minutes">Minutes</option>
<option value="Hours">Hours</option>
<option value="Days">Days</option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-500">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${exportMode === 'range' ? 'bg-gray-900 text-white' : 'bg-transparent text-gray-600 hover:bg-gray-50'} disabled:cursor-not-allowed disabled:opacity-50`}
>
Last duration
</button>
<button
type="button"
onClick={() => setExportMode('all')}
disabled={isExporting}
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${exportMode === 'all' ? 'bg-gray-900 text-white' : 'bg-transparent text-gray-600 hover:bg-gray-50'} disabled:cursor-not-allowed disabled:opacity-50`}
>
All data
</button>
</div>

<div className="h-20">
{exportMode === 'range' && (
<>
<p className="text-sm font-medium text-gray-900 mb-2">
Export data from the last:
</p>
<div className="flex gap-3">
<input
type="number"
min="1"
value={durationValue}
onChange={(e) => setDurationValue(e.target.value)}
disabled={isExporting}
className="flex h-10 w-24 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="relative flex-1">
<select
value={durationUnit}
onChange={(e) => setDurationUnit(e.target.value)}
disabled={isExporting}
className="appearance-none flex h-10 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-8 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<option value="Seconds">Seconds</option>
<option value="Minutes">Minutes</option>
<option value="Hours">Hours</option>
<option value="Days">Days</option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-500">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
</>
)}

{exportMode === 'all' && (
<p className="text-sm text-gray-500">
Exports all recorded data for this session, from the earliest timestamp to now.
</p>
)}
</div>

<hr className="my-6 border-gray-100" />
<hr className="my-4 border-gray-100" />

{sessionId === null && (
<p className="text-sm text-red-600 font-medium mb-3">
No active session - please start or load a session before exporting.
</p>
)}

<p className="text-sm text-gray-400 font-medium mb-3">
Data will be exported as CSV format.
Expand Down
28 changes: 19 additions & 9 deletions frontend/components/ui/import-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
import { useNotifications } from '@/components/notifications';
import { importEEGData } from '@/lib/eeg-api';
import { Folder } from 'lucide-react';
import { EnterIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';

type ImportDialogProps = {
open: boolean;
Expand All @@ -20,13 +22,6 @@ type ImportDialogProps = {
onImportSuccess?: () => void;
};

const ImportIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="mr-3 h-6 w-6">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
<path fill="#fff" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M12 12H4M8 8l-4 4 4 4" />
</svg>
);

export default function ImportDialog({
open,
sessionId,
Expand Down Expand Up @@ -119,11 +114,26 @@ export default function ImportDialog({
<DialogContent className="sm:max-w-[425px] p-8">
<DialogHeader className="mb-4 text-left">
<DialogTitle className="flex items-center text-2xl font-bold mb-2">
<ImportIcon />
<EnterIcon className="mr-2" width={24} height={24} />
Import Data
</DialogTitle>
<DialogDescription className="text-gray-500 text-sm">
<DialogDescription className="text-gray-500 text-sm flex items-center gap-1.5">
Only CSV files are accepted.
<Popover>
<PopoverTrigger asChild>
<button type="button" className="inline-flex items-center text-gray-400 hover:text-gray-600 transition-colors">
<InfoCircledIcon width={14} height={14} />
</button>
</PopoverTrigger>
<PopoverContent className="w-90 text-sm" side="bottom" align="start">
<p className="font-semibold mb-2">Expected CSV format</p>
<p className="text-gray-500 mb-2">The file must have a header row followed by data rows in this shape:</p>
<code className="block bg-gray-100 rounded px-2 py-1.5 text-xs font-mono mb-2">
Time,Channel1,Channel2,Channel3,Channel4
</code>
<p className="text-gray-500">The <span className="font-medium text-gray-700">Time</span> column must be in <span className="font-medium text-gray-700">RFC 3339</span> format (e.g. <code className="text-xs font-mono">2024-01-15T13:45:00Z</code>).</p>
</PopoverContent>
</Popover>
</DialogDescription>
</DialogHeader>

Expand Down
Loading