feat: enhance web extension with export functionality and utility improvements
Some checks failed
Tests / Tests (push) Has been cancelled
ESLint Check / ESLint Check and Report Upload (push) Has been cancelled
Prettier Check / Format Check (push) Has been cancelled
Prettier Check / Format Code (push) Has been cancelled
ESLint Check / Build Base for Bundle Size Comparison (push) Has been cancelled
Some checks failed
Tests / Tests (push) Has been cancelled
ESLint Check / ESLint Check and Report Upload (push) Has been cancelled
Prettier Check / Format Check (push) Has been cancelled
Prettier Check / Format Code (push) Has been cancelled
ESLint Check / Build Base for Bundle Size Comparison (push) Has been cancelled
- Add export functionality to SessionList and Player pages - Add new utility modules: dataOperations, format, path, settings - Update manifest with export and download permissions - Enhance storage utility with new data operations - Add various test scripts and documentation files
This commit is contained in:
@@ -22,6 +22,14 @@ import { isFirefox } from '~/utils';
|
||||
import { addSession } from '~/utils/storage';
|
||||
|
||||
void (async () => {
|
||||
// Handle keyboard shortcuts
|
||||
Browser.commands.onCommand.addListener((command) => {
|
||||
if (command === 'start-recording') {
|
||||
channel.emit(EventName.StartButtonClicked, {});
|
||||
} else if (command === 'stop-recording') {
|
||||
channel.emit(EventName.StopButtonClicked, {});
|
||||
}
|
||||
});
|
||||
// assign default value to settings of this extension
|
||||
const result =
|
||||
((await Browser.storage.sync.get(SyncDataKey.settings)) as SyncData) ||
|
||||
|
||||
@@ -14,7 +14,23 @@
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
},
|
||||
"permissions": ["activeTab", "storage", "unlimitedStorage"]
|
||||
"permissions": ["activeTab", "storage", "unlimitedStorage"],
|
||||
"commands": {
|
||||
"start-recording": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+R",
|
||||
"mac": "Command+Shift+R"
|
||||
},
|
||||
"description": "Start Recording"
|
||||
},
|
||||
"stop-recording": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+S",
|
||||
"mac": "Command+Shift+S"
|
||||
},
|
||||
"description": "Stop Recording"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v2": {
|
||||
"common": {
|
||||
|
||||
@@ -1,31 +1,660 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Route, Routes, useNavigate, useLocation } from 'react-router-dom';
|
||||
import SidebarWithHeader from '~/components/SidebarWithHeader';
|
||||
import { FiList, FiSettings } from 'react-icons/fi';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { FiList, FiSettings, FiShield, FiDatabase, FiDownload, FiCamera, FiMonitor, FiTrash2, FiSave } from 'react-icons/fi';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Divider,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Switch,
|
||||
Input,
|
||||
Select,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Browser } from 'webextension-polyfill';
|
||||
import { Settings, SyncDataKey } from '~/types';
|
||||
import {
|
||||
getStorageSettings,
|
||||
saveStorageSettings,
|
||||
clearAllSessions,
|
||||
exportSettings,
|
||||
importSettings,
|
||||
} from '~/utils/settings';
|
||||
|
||||
// Settings sections
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: 'general', label: 'General', icon: FiSettings },
|
||||
{ id: 'recording', label: 'Recording', icon: FiCamera },
|
||||
{ id: 'privacy', label: 'Privacy', icon: FiShield },
|
||||
{ id: 'storage', label: 'Storage', icon: FiDatabase },
|
||||
{ id: 'paths', label: 'File Paths', icon: FiMonitor },
|
||||
{ id: 'export', label: 'Export', icon: FiDownload },
|
||||
] as const;
|
||||
|
||||
export default function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [storageInfo, setStorageInfo] = useState({ used: 0, total: 0 });
|
||||
const { isOpen: isClearModalOpen, onOpen: onClearModalOpen, onClose: onClearModalClose } = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const savedSettings = await getStorageSettings();
|
||||
setSettings(savedSettings);
|
||||
|
||||
// Get storage info
|
||||
if (chrome?.storage?.local) {
|
||||
const storage = await chrome.storage.local.getBytesInUse();
|
||||
setStorageInfo({ used: storage, total: 5 * 1024 * 1024 }); // 5MB limit
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load settings',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
await saveStorageSettings(settings);
|
||||
toast({
|
||||
title: 'Settings saved',
|
||||
description: 'Your settings have been saved successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to save settings',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllData = async () => {
|
||||
try {
|
||||
await clearAllSessions();
|
||||
toast({
|
||||
title: 'Data cleared',
|
||||
description: 'All recordings have been permanently deleted',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onClearModalClose();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to clear data',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportSettings = () => {
|
||||
exportSettings(settings);
|
||||
toast({
|
||||
title: 'Settings exported',
|
||||
description: 'Settings file downloaded',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleImportSettings = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const importedSettings = JSON.parse(content) as Settings;
|
||||
setSettings(importedSettings);
|
||||
toast({
|
||||
title: 'Settings imported',
|
||||
description: 'Settings have been imported successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Invalid settings file',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const getStoragePercentage = () => {
|
||||
return Math.round((storageInfo.used / storageInfo.total) * 100);
|
||||
};
|
||||
|
||||
const renderSettingsSection = (sectionId: string) => {
|
||||
const section = SETTINGS_SECTIONS.find(s => s.id === sectionId);
|
||||
if (!section) return null;
|
||||
|
||||
switch (sectionId) {
|
||||
case 'general':
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="lg">{section.label}</Heading>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Default Recording Quality</FormLabel>
|
||||
<Select
|
||||
value={settings.recordingQuality || 'balanced'}
|
||||
onChange={(e) => setSettings({...settings, recordingQuality: e.target.value as any})}
|
||||
>
|
||||
<option value="balanced">Balanced (Recommended)</option>
|
||||
<option value="high">High Quality (More Events)</option>
|
||||
<option value="low">Low Memory (Fewer Events)</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Auto-start Recording</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.autoStart || false}
|
||||
onChange={(e) => setSettings({...settings, autoStart: e.target.checked})}
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Automatically start recording when opening a new tab
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Enable Notifications</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.enableNotifications || true}
|
||||
onChange={(e) => setSettings({...settings, enableNotifications: e.target.checked})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select
|
||||
value={settings.theme || 'system'}
|
||||
onChange={(e) => setSettings({...settings, theme: e.target.value as any})}
|
||||
>
|
||||
<option value="system">System Default</option>
|
||||
<option value="light">Light Mode</option>
|
||||
<option value="dark">Dark Mode</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
case 'recording':
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="lg">{section.label}</Heading>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Record Canvas Elements</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.recordCanvas || true}
|
||||
onChange={(e) => setSettings({...settings, recordCanvas: e.target.checked})}
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Record canvas and canvas-based drawing events
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Record Input Fields</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.recordInputs || true}
|
||||
onChange={(e) => setSettings({...settings, recordInputs: e.target.checked})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Record Mouse Movements</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.recordMouse || true}
|
||||
onChange={(e) => setSettings({...settings, recordMouse: e.target.checked})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Record Scroll Events</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.recordScroll || true}
|
||||
onChange={(e) => setSettings({...settings, recordScroll: e.target.checked})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Recording Shortcuts</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={settings.shortcuts?.start || 'Ctrl+Shift+R'}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
shortcuts: {...settings.shortcuts, start: e.target.value}
|
||||
})}
|
||||
placeholder="Start recording"
|
||||
/>
|
||||
<Input
|
||||
value={settings.shortcuts?.stop || 'Ctrl+Shift+S'}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
shortcuts: {...settings.shortcuts, stop: e.target.value}
|
||||
})}
|
||||
placeholder="Stop recording"
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
case 'privacy':
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="lg">{section.label}</Heading>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Block Sensitive Data</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.blockSensitiveData || true}
|
||||
onChange={(e) => setSettings({...settings, blockSensitiveData: e.target.checked})}
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Automatically mask passwords, credit cards, and other sensitive information
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Mask Input Fields</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.maskInputs || false}
|
||||
onChange={(e) => setSettings({...settings, maskInputs: e.target.checked})}
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Replace input text with asterisks during recording
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Exclude Specific Domains</FormLabel>
|
||||
<VStack spacing={2}>
|
||||
{settings.excludedDomains?.map((domain, index) => (
|
||||
<HStack key={index} w="100%">
|
||||
<Input
|
||||
value={domain}
|
||||
onChange={(e) => {
|
||||
const newDomains = [...(settings.excludedDomains || [])];
|
||||
newDomains[index] = e.target.value;
|
||||
setSettings({...settings, excludedDomains: newDomains});
|
||||
}}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Remove domain"
|
||||
icon={<FiTrash2 />}
|
||||
onClick={() => {
|
||||
const newDomains = [...(settings.excludedDomains || [])];
|
||||
newDomains.splice(index, 1);
|
||||
setSettings({...settings, excludedDomains: newDomains});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
<Button
|
||||
leftIcon={<FiPlus />}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSettings({
|
||||
...settings,
|
||||
excludedDomains: [...(settings.excludedDomains || []), '']
|
||||
})}
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
case 'storage':
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="lg">{section.label}</Heading>
|
||||
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Storage Usage</AlertTitle>
|
||||
<AlertDescription>
|
||||
Using {getStoragePercentage()}% of your storage limit
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Box>
|
||||
<VStack spacing={2}>
|
||||
<Flex justify="space-between">
|
||||
<Text fontSize="sm">Used</Text>
|
||||
<Text fontSize="sm">{Math.round(storageInfo.used / 1024 / 1024)} MB</Text>
|
||||
</Flex>
|
||||
<Box height="2" bg={useColorModeValue('gray.200', 'gray.700')} borderRadius="full" overflow="hidden">
|
||||
<Box
|
||||
height="100%"
|
||||
bg={getStoragePercentage() > 80 ? 'red.500' : getStoragePercentage() > 60 ? 'yellow.500' : 'green.500'}
|
||||
borderRadius="full"
|
||||
width={`${getStoragePercentage()}%`}
|
||||
/>
|
||||
</Box>
|
||||
<Flex justify="space-between">
|
||||
<Text fontSize="sm">Total</Text>
|
||||
<Text fontSize="sm">5 MB</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Auto-cleanup Old Recordings</FormLabel>
|
||||
<Select
|
||||
value={settings.autoCleanupDays || 30}
|
||||
onChange={(e) => setSettings({...settings, autoCleanupDays: Number(e.target.value)})}
|
||||
>
|
||||
<option value={7}>After 7 days</option>
|
||||
<option value={30}>After 30 days (Recommended)</option>
|
||||
<option value={90}>After 90 days</option>
|
||||
<option value={0}>Never</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Max Recording Size</FormLabel>
|
||||
<Select
|
||||
value={settings.maxRecordingSize || '100'}
|
||||
onChange={(e) => setSettings({...settings, maxRecordingSize: e.target.value as any})}
|
||||
>
|
||||
<option value="50">50 MB</option>
|
||||
<option value="100">100 MB (Recommended)</option>
|
||||
<option value="200">200 MB</option>
|
||||
<option value="500">500 MB</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
case 'paths':
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="lg">{section.label}</Heading>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Default Save Location</FormLabel>
|
||||
<Input
|
||||
value={settings.savePath || 'recordings'}
|
||||
onChange={(e) => setSettings({...settings, savePath: e.target.value})}
|
||||
placeholder="recordings"
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Base folder for saving recordings (relative to extension directory)
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Create Subfolders</FormLabel>
|
||||
<Switch
|
||||
isChecked={settings.createSubfolders || true}
|
||||
onChange={(e) => setSettings({...settings, createSubfolders: e.target.checked})}
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Create separate folders for each recording session
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>File Name Format</FormLabel>
|
||||
<Select
|
||||
value={settings.fileNameFormat || 'timestamp'}
|
||||
onChange={(e) => setSettings({...settings, fileNameFormat: e.target.value as any})}
|
||||
>
|
||||
<option value="timestamp">Timestamp-based</option>
|
||||
<option value="custom">Custom format</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{settings.fileNameFormat === 'custom' && (
|
||||
<FormControl>
|
||||
<FormLabel>Custom Name Format</FormLabel>
|
||||
<Input
|
||||
value={settings.customNameFormat || 'recording-{date}-{time}'}
|
||||
onChange={(e) => setSettings({...settings, customNameFormat: e.target.value})}
|
||||
placeholder="recording-{date}-{time}"
|
||||
/>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')} mt={2}>
|
||||
Use {date}, {time}, {session} as variables
|
||||
</Text>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} rounded="lg">
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>Format Examples:</Text>
|
||||
<VStack spacing={2} align="stretch" fontSize="xs">
|
||||
<Box><Text color={useColorModeValue('gray.700', 'gray.300')}>Timestamp:</Text> <Text color={useColorModeValue('blue.600', 'blue.200')}>20240115_143022</Text></Box>
|
||||
<Box><Text color={useColorModeValue('gray.700', 'gray.300')}>Custom:</Text> <Text color={useColorModeValue('blue.600', 'blue.200')}>my-recording-2024-01-15</Text></Box>
|
||||
<Box><Text color={useColorModeValue('gray.700', 'gray.300')}>Custom with time:</Text> <Text color={useColorModeValue('blue.600', 'blue.200')}>session-{date}-{time}</Text></Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
case 'export':
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="lg">{section.label}</Heading>
|
||||
|
||||
<VStack spacing={4}>
|
||||
<Button
|
||||
leftIcon={<FiDownload />}
|
||||
onClick={handleExportSettings}
|
||||
colorScheme="blue"
|
||||
>
|
||||
Export Settings
|
||||
</Button>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Import Settings</FormLabel>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImportSettings}
|
||||
multiple={false}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
|
||||
<Box>
|
||||
<Heading size="md" mb={4}>Export Options</Heading>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Default Export Format</FormLabel>
|
||||
<Select
|
||||
value={settings.defaultExportFormat || 'json'}
|
||||
onChange={(e) => setSettings({...settings, defaultExportFormat: e.target.value as any})}
|
||||
>
|
||||
<option value="json">JSON</option>
|
||||
<option value="html">HTML with Player</option>
|
||||
<option value="zip">ZIP Package</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl as={Flex} alignItems="center" marginTop={4}>
|
||||
<Switch
|
||||
isChecked={settings.includeMetadata || true}
|
||||
onChange={(e) => setSettings({...settings, includeMetadata: e.target.checked})}
|
||||
marginRight={2}
|
||||
/>
|
||||
<FormLabel marginBottom={0}>Include Metadata in Exports</FormLabel>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarWithHeader
|
||||
title="Settings"
|
||||
headBarItems={[
|
||||
{ label: 'Sessions', icon: FiList, href: '/pages/index.html#' },
|
||||
{ label: 'Settings', icon: FiSettings, href: '#' },
|
||||
]}
|
||||
sideBarItems={SETTINGS_SECTIONS.map(section => ({
|
||||
label: section.label,
|
||||
icon: section.icon,
|
||||
href: `#${section.id}`,
|
||||
}))}
|
||||
>
|
||||
<Box padding="10">
|
||||
<Text>Loading settings...</Text>
|
||||
</Box>
|
||||
</SidebarWithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
const currentSection = location.hash.slice(1) || 'general';
|
||||
|
||||
return (
|
||||
<SidebarWithHeader
|
||||
title="Settings"
|
||||
headBarItems={[
|
||||
{
|
||||
label: 'Sessions',
|
||||
icon: FiList,
|
||||
href: '/pages/index.html#',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: FiSettings,
|
||||
href: '#',
|
||||
},
|
||||
{ label: 'Sessions', icon: FiList, href: '/pages/index.html#' },
|
||||
{ label: 'Settings', icon: FiSettings, href: '#' },
|
||||
]}
|
||||
sideBarItems={[]}
|
||||
sideBarItems={SETTINGS_SECTIONS.map(section => ({
|
||||
label: section.label,
|
||||
icon: section.icon,
|
||||
href: `#${section.id}`,
|
||||
}))}
|
||||
>
|
||||
<Box p="10">
|
||||
<Routes>
|
||||
<Route path="/" element={<></>} />
|
||||
</Routes>
|
||||
<Box padding="6">
|
||||
<Flex justifyContent="space-between" alignItems="center" marginBottom={6}>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Heading size="lg">Settings</Heading>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
Customize your recording experience
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
leftIcon={<FiSave />}
|
||||
colorScheme="blue"
|
||||
onClick={handleSaveSettings}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Divider marginBottom={6} />
|
||||
|
||||
{renderSettingsSection(currentSection)}
|
||||
|
||||
<Divider marginTop={6} />
|
||||
|
||||
<Box>
|
||||
<Heading size="lg" marginBottom={4}>Danger Zone</Heading>
|
||||
<VStack spacing={4}>
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
These actions cannot be undone.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftIcon={<FiTrash2 />}
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={onClearModalOpen}
|
||||
>
|
||||
Clear All Data
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Clear All Data Modal */}
|
||||
<Modal isOpen={isClearModalOpen} onClose={onClearModalClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Clear All Data</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<Text>
|
||||
This action will permanently delete all your recordings and cannot be undone.
|
||||
</Text>
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Confirm Deletion</AlertTitle>
|
||||
</Alert>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" marginRight={3} onClick={onClearModalClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme="red" onClick={handleClearAllData}>
|
||||
Delete All Data
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</SidebarWithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// Plus icon component
|
||||
function FiPlus() {
|
||||
return <svg style={{ width: '16px', height: '16px' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>;
|
||||
}
|
||||
@@ -8,53 +8,225 @@ import {
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Center,
|
||||
Flex,
|
||||
Button,
|
||||
IconButton,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Code,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { getEvents, getSession } from '~/utils/storage';
|
||||
import {
|
||||
FiPlay,
|
||||
FiPause,
|
||||
FiSkipBack,
|
||||
FiSkipForward,
|
||||
FiRotateCcw,
|
||||
FiDownload,
|
||||
FiShare2,
|
||||
FiX,
|
||||
FiInfo,
|
||||
FiClock,
|
||||
FiCalendar,
|
||||
FiDatabase,
|
||||
} from 'react-icons/fi';
|
||||
import { getEvents, getSession, deleteSession } from '~/utils/storage';
|
||||
import { formatFileSize } from '~/utils/format';
|
||||
|
||||
export default function Player() {
|
||||
const playerElRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Replayer | null>(null);
|
||||
const { sessionId } = useParams();
|
||||
const [sessionName, setSessionName] = useState('');
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [totalTime, setTotalTime] = useState(0);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
getSession(sessionId)
|
||||
.then((session) => {
|
||||
setSessionName(session.name);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
getEvents(sessionId)
|
||||
.then((events) => {
|
||||
if (!playerElRef.current) return;
|
||||
if (playerRef.current) return;
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const rrwebPlayerVersion = manifest.version_name || manifest.version;
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.href = `https://cdn.jsdelivr.net/npm/rrweb-player@${rrwebPlayerVersion}/dist/style.min.css`;
|
||||
linkEl.rel = 'stylesheet';
|
||||
document.head.appendChild(linkEl);
|
||||
playerRef.current = new Replayer({
|
||||
target: playerElRef.current as HTMLElement,
|
||||
props: {
|
||||
events,
|
||||
autoPlay: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const loadSession = async () => {
|
||||
try {
|
||||
const sessionData = await getSession(sessionId);
|
||||
const eventsData = await getEvents(sessionId);
|
||||
|
||||
setSession(sessionData);
|
||||
setEvents(eventsData);
|
||||
setTotalTime(eventsData.length > 0 ? eventsData[eventsData.length - 1].timestamp : 0);
|
||||
|
||||
setSessionName(sessionData.name);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
});
|
||||
return () => {
|
||||
// eslint-disable-next-line
|
||||
playerRef.current?.pause();
|
||||
// eslint-disable-next-line
|
||||
playerRef.current?.$destroy();
|
||||
toast({
|
||||
title: 'Error loading session',
|
||||
description: (err as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
loadSession();
|
||||
}, [sessionId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !events.length || isLoading) return;
|
||||
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const rrwebPlayerVersion = manifest.version_name || manifest.version;
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.href = `https://cdn.jsdelivr.net/npm/rrweb-player@${rrwebPlayerVersion}/dist/style.min.css`;
|
||||
linkEl.rel = 'stylesheet';
|
||||
document.head.appendChild(linkEl);
|
||||
|
||||
playerRef.current = new Replayer({
|
||||
target: playerElRef.current as HTMLElement,
|
||||
props: {
|
||||
events,
|
||||
autoPlay: false,
|
||||
speed: 1,
|
||||
width: '100%',
|
||||
height: '600px',
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for player events
|
||||
playerRef.current.on('play', () => {
|
||||
setIsPlaying(true);
|
||||
});
|
||||
|
||||
playerRef.current.on('pause', () => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
playerRef.current.on('timeupdate', (time) => {
|
||||
setCurrentTime(time);
|
||||
});
|
||||
|
||||
playerRef.current.on('finish', () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(totalTime);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.pause();
|
||||
playerRef.current.$destroy();
|
||||
}
|
||||
// Remove the CSS link
|
||||
const existingLink = document.querySelector(`link[href="${linkEl.href}"]`);
|
||||
if (existingLink) {
|
||||
existingLink.remove();
|
||||
}
|
||||
};
|
||||
}, [sessionId, events, isLoading, totalTime]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (playerRef.current) {
|
||||
if (isPlaying) {
|
||||
playerRef.current.pause();
|
||||
} else {
|
||||
playerRef.current.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.reset();
|
||||
setCurrentTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!session || !events) return;
|
||||
|
||||
const blob = new Blob([JSON.stringify({ session, events }, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${session.name}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: 'Download started',
|
||||
description: `Recording saved as ${session.name}.json`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!session) return;
|
||||
|
||||
if (window.confirm(`Are you sure you want to delete "${session.name}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
await deleteSession(session.id);
|
||||
toast({
|
||||
title: 'Session deleted',
|
||||
description: 'The session has been permanently deleted.',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
// Navigate back to session list
|
||||
window.location.href = '#/';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
title: 'Error deleting session',
|
||||
description: (err as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const seconds = Math.floor(timestamp / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h="600px">
|
||||
<Text fontSize="lg">Loading recording...</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Session not found</AlertTitle>
|
||||
<AlertDescription>The requested session could not be loaded.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,12 +235,160 @@ export default function Player() {
|
||||
<BreadcrumbLink href="#">Sessions</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink>{sessionName}</BreadcrumbLink>
|
||||
<BreadcrumbLink>{session.name}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Center>
|
||||
<Box ref={playerElRef}></Box>
|
||||
|
||||
{/* Session Info */}
|
||||
<VStack spacing={3} mb={6} align="stretch">
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.800')} p={4} rounded="lg">
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontSize="lg" fontWeight="bold">{session.name}</Text>
|
||||
<HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<FiDownload />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
leftIcon={<FiX />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
Created: {new Date(session.createTimestamp).toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Flex gap={4}>
|
||||
<Box flex={1} bg={useColorModeValue('blue.50', 'blue.900')} p={3} rounded="lg">
|
||||
<HStack>
|
||||
<FiClock color="blue.500" />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.300')}>Duration</Text>
|
||||
<Text fontWeight="bold">{formatTime(totalTime)}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box flex={1} bg={useColorModeValue('green.50', 'green.900')} p={3} rounded="lg">
|
||||
<HStack>
|
||||
<FiDatabase color="green.500" />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.300')}>Events</Text>
|
||||
<Text fontWeight="bold">{events.length}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box flex={1} bg={useColorModeValue('purple.50', 'purple.900')} p={3} rounded="lg">
|
||||
<HStack>
|
||||
<FiCalendar color="purple.500" />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.300')}>Size</Text>
|
||||
<Text fontWeight="bold">{formatFileSize(JSON.stringify({ session, events }).length)}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
</VStack>
|
||||
|
||||
{/* Player Container */}
|
||||
<Center mb={4}>
|
||||
<Box
|
||||
ref={playerElRef}
|
||||
w="100%"
|
||||
h="600px"
|
||||
bg="white"
|
||||
border="1px solid"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
/>
|
||||
</Center>
|
||||
|
||||
{/* Custom Controls */}
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.800')} p={4} rounded="lg">
|
||||
<Flex justify="center" align="center" gap={2} mb={3}>
|
||||
<IconButton
|
||||
aria-label="Restart"
|
||||
icon={<FiSkipBack />}
|
||||
onClick={handleRestart}
|
||||
isDisabled={!events.length}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
icon={isPlaying ? <FiPause /> : <FiPlay />}
|
||||
onClick={handlePlayPause}
|
||||
isDisabled={!events.length}
|
||||
size="lg"
|
||||
bg={useColorModeValue('blue.500', 'blue.600')}
|
||||
color="white"
|
||||
_hover={{ bg: useColorModeValue('blue.600', 'blue.700') }}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Skip to end"
|
||||
icon={<FiSkipForward />}
|
||||
onClick={() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.play(totalTime);
|
||||
}
|
||||
}}
|
||||
isDisabled={!events.length}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Timeline */}
|
||||
{events.length > 0 && (
|
||||
<VStack spacing={2}>
|
||||
<Flex justify="space-between" fontSize="xs" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(totalTime)}</span>
|
||||
</Flex>
|
||||
<Box
|
||||
w="100%"
|
||||
h="2"
|
||||
bg={useColorModeValue('gray.300', 'gray.600')}
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
if (!playerRef.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const newTime = percentage * totalTime;
|
||||
playerRef.current.play(newTime);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
h="100%"
|
||||
bg={useColorModeValue('blue.500', 'blue.600')}
|
||||
rounded="full"
|
||||
w={`${(currentTime / totalTime) * 100}%`}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} mt={4} textAlign="center">
|
||||
<InfoIcon /> Click on the timeline to seek to a specific moment
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for info icon
|
||||
function InfoIcon() {
|
||||
return <FiInfo style={{ marginRight: '4px', color: '#718096' }} />;
|
||||
}
|
||||
|
||||
527
packages/web-extension/src/pages/SessionList.debug.tsx
Normal file
527
packages/web-extension/src/pages/SessionList.debug.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
chakra,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Editable,
|
||||
EditableInput,
|
||||
EditablePreview,
|
||||
Flex,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spacer,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useEditableControls,
|
||||
useToast,
|
||||
HStack,
|
||||
VStack,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
useColorModeValue,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
useReactTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
type SortingState,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
} from '@tanstack/react-table';
|
||||
import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc';
|
||||
import { FiEdit3 as EditIcon } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { eventWithTime } from 'rrweb';
|
||||
import { type Session, EventName } from '~/types';
|
||||
import Channel from '~/utils/channel';
|
||||
import {
|
||||
deleteSessions,
|
||||
getAllSessions,
|
||||
downloadSessions,
|
||||
addSession,
|
||||
updateSession,
|
||||
} from '~/utils/storage';
|
||||
import {
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiChevronsLeft,
|
||||
FiChevronsRight,
|
||||
FiDownload,
|
||||
FiFileText,
|
||||
FiFile,
|
||||
FiArchive,
|
||||
FiSearch,
|
||||
FiFilter,
|
||||
FiCalendar,
|
||||
FiTag,
|
||||
FiX,
|
||||
FiInfo,
|
||||
FiPlus,
|
||||
FiScissors,
|
||||
FiMinimize2,
|
||||
FiBarChart2,
|
||||
FiEdit3,
|
||||
FiTag as FiTagIcon,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
const columnHelper = createColumnHelper<Session>();
|
||||
const channel = new Channel();
|
||||
|
||||
export function SessionList() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState<'all' | 'today' | 'week' | 'month'>('all');
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [showDataOpsModal, setShowDataOpsModal] = useState(false);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'createTimestamp',
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'zip'>('json');
|
||||
|
||||
// Debug state
|
||||
const [debugInfo, setDebugInfo] = useState('');
|
||||
|
||||
// Data operations state
|
||||
const [mergeName, setMergeName] = useState('');
|
||||
const [mergeTags, setMergeTags] = useState('');
|
||||
const [splitMaxEvents, setSplitMaxEvents] = useState(1000);
|
||||
const [splitDuration, setSplitDuration] = useState(60000);
|
||||
const [compressSampleRate, setCompressSampleRate] = useState(0.5);
|
||||
const [bulkRenameInput, setBulkRenameInput] = useState('');
|
||||
const [bulkTagsInput, setBulkTagsInput] = useState('');
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const fetchDataOptions = {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
|
||||
return {
|
||||
rows: filteredSessions.slice(
|
||||
options.pageIndex * options.pageSize,
|
||||
(options.pageIndex + 1) * options.pageSize,
|
||||
),
|
||||
pageCount: Math.ceil(filteredSessions.length / options.pageSize),
|
||||
};
|
||||
};
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
isChecked={table.getIsAllRowsSelected()}
|
||||
isIndeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
isChecked={row.getIsSelected()}
|
||||
isIndeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
cell: (info) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
function EditableControls() {
|
||||
const { isEditing, getEditButtonProps } = useEditableControls();
|
||||
return (
|
||||
isHovered &&
|
||||
!isEditing && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="edit name"
|
||||
size="sm"
|
||||
icon={<EditIcon />}
|
||||
variant="ghost"
|
||||
{...getEditButtonProps()}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
>
|
||||
<Editable
|
||||
defaultValue={info.getValue()}
|
||||
isPreviewFocusable={false}
|
||||
onSubmit={(nextValue) => {
|
||||
const newSession = { ...info.row.original, name: nextValue };
|
||||
setSessions(
|
||||
sessions.map((s) =>
|
||||
s.id === newSession.id ? newSession : s,
|
||||
),
|
||||
);
|
||||
void updateSession(newSession);
|
||||
}}
|
||||
>
|
||||
<EditablePreview cursor="pointer" />
|
||||
<EditableControls />
|
||||
<EditableInput onClick={(e) => e.stopPropagation()} />
|
||||
</Editable>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
header: 'Name',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.createTimestamp, {
|
||||
id: 'createTimestamp',
|
||||
cell: (info) => new Date(info.getValue()).toLocaleString(),
|
||||
header: 'Created Time',
|
||||
sortDescFirst: true,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.recorderVersion, {
|
||||
cell: (info) => info.getValue(),
|
||||
header: 'RRWEB Version',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.tags, {
|
||||
cell: (info) => (
|
||||
<Flex flexWrap="wrap" gap={1}>
|
||||
{info.getValue().map((tag, index) => (
|
||||
<Tag key={index} size="sm" variant="subtle">
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
header: 'Tags',
|
||||
}),
|
||||
],
|
||||
[sessions],
|
||||
);
|
||||
const table = useReactTable<Session>({
|
||||
columns,
|
||||
data: fetchData(fetchDataOptions).rows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
manualPagination: true,
|
||||
pageCount: fetchData(fetchDataOptions).pageCount,
|
||||
});
|
||||
|
||||
const updateSessions = async () => {
|
||||
try {
|
||||
console.log('Debug: Getting all sessions...');
|
||||
const sessions = await getAllSessions();
|
||||
console.log('Debug: Retrieved sessions:', sessions);
|
||||
setSessions(sessions);
|
||||
setDebugInfo(`Found ${sessions.length} sessions`);
|
||||
} catch (error) {
|
||||
console.error('Debug: Error getting sessions:', error);
|
||||
setDebugInfo(`Error: ${error}`);
|
||||
setSessions([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply filtering
|
||||
useEffect(() => {
|
||||
let filtered = [...sessions];
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(session =>
|
||||
session.name.toLowerCase().includes(term) ||
|
||||
session.tags.some(tag => tag.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
// Date filter
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
filtered = filtered.filter(session => new Date(session.createTimestamp) >= today);
|
||||
break;
|
||||
case 'week':
|
||||
filtered = filtered.filter(session => new Date(session.createTimestamp) >= weekAgo);
|
||||
break;
|
||||
case 'month':
|
||||
filtered = filtered.filter(session => new Date(session.createTimestamp) >= monthAgo);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log('Debug: Filtered sessions:', filtered.length);
|
||||
setFilteredSessions(filtered);
|
||||
}, [sessions, searchTerm, dateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void updateSessions();
|
||||
channel.on(EventName.SessionUpdated, () => {
|
||||
console.log('Debug: Session updated event received');
|
||||
void updateSessions();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const data = JSON.parse(content) as {
|
||||
session: Session;
|
||||
events: eventWithTime[];
|
||||
};
|
||||
const id = nanoid();
|
||||
data.session.id = id;
|
||||
await addSession(data.session, data.events);
|
||||
toast({
|
||||
title: 'Session imported',
|
||||
description: 'The session was successfully imported.',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
await updateSessions();
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
toast({
|
||||
title: 'Error importing session',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const getDateFilterLabel = () => {
|
||||
switch (dateFilter) {
|
||||
case 'today': return 'Today';
|
||||
case 'week': return 'Last 7 days';
|
||||
case 'month': return 'Last 30 days';
|
||||
default: return 'All time';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack spacing={4} mb={4} align="stretch">
|
||||
{/* Search and Filter Bar */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={4}>
|
||||
<HStack>
|
||||
<FiSearch color="gray.500" />
|
||||
<Input
|
||||
placeholder="Search sessions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
w={300}
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
leftIcon={<FiFilter />}
|
||||
variant={dateFilter !== 'all' ? 'solid' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
>
|
||||
{getDateFilterLabel()}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setDateFilter('all');
|
||||
}}
|
||||
leftIcon={<FiX />}
|
||||
isDisabled={!searchTerm && dateFilter === 'all'}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiFileText />}
|
||||
>
|
||||
Import Session
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Debug Info */}
|
||||
<Box bg="yellow.50" p={3} rounded="md">
|
||||
<Text fontSize="sm" fontWeight="bold">Debug Info:</Text>
|
||||
<Text fontSize="sm">{debugInfo}</Text>
|
||||
<Text fontSize="sm">Total sessions: {sessions.length}</Text>
|
||||
<Text fontSize="sm">Filtered sessions: {filteredSessions.length}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Results Summary */}
|
||||
<Flex justify="space-between" align="center" fontSize="sm" color="gray.600">
|
||||
<Text>
|
||||
Showing {filteredSessions.length} of {sessions.length} sessions
|
||||
</Text>
|
||||
{searchTerm && (
|
||||
<Text>Search: "{searchTerm}"</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
<TableContainer fontSize="md">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta as
|
||||
| {
|
||||
isNumeric: boolean;
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<Th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
isNumeric={meta?.isNumeric}
|
||||
verticalAlign="center"
|
||||
userSelect="none"
|
||||
>
|
||||
<Flex align="center">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<chakra.span pl={4}>
|
||||
{{
|
||||
asc: (
|
||||
<VscTriangleUp aria-label="sorted ascending" />
|
||||
),
|
||||
desc: (
|
||||
<VscTriangleDown aria-label="sorted descending" />
|
||||
),
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</chakra.span>
|
||||
</Flex>
|
||||
</Th>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Tr key={row.id} _hover={{ cursor: 'pointer' }}>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| {
|
||||
isNumeric: boolean;
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<Td
|
||||
key={cell.id}
|
||||
isNumeric={meta?.isNumeric}
|
||||
onClick={() => {
|
||||
if (index !== 0)
|
||||
navigate(`/session/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text fontSize="lg" color="gray.500">No sessions found</Text>
|
||||
<Text fontSize="sm" color="gray.400" mt={2}>Try recording a new session or check your recordings</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,21 @@ import {
|
||||
Tr,
|
||||
useEditableControls,
|
||||
useToast,
|
||||
HStack,
|
||||
VStack,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
useColorModeValue,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
@@ -47,11 +62,35 @@ import {
|
||||
addSession,
|
||||
updateSession,
|
||||
} from '~/utils/storage';
|
||||
import {
|
||||
mergeSessions,
|
||||
splitSession,
|
||||
compressSession,
|
||||
bulkRenameSessions,
|
||||
bulkAddTags,
|
||||
getSessionStats,
|
||||
} from '~/utils/dataOperations';
|
||||
import {
|
||||
FiChevronLeft,
|
||||
FiChevronRight,
|
||||
FiChevronsLeft,
|
||||
FiChevronsRight,
|
||||
FiDownload,
|
||||
FiFileText,
|
||||
FiFile,
|
||||
FiArchive,
|
||||
FiSearch,
|
||||
FiFilter,
|
||||
FiCalendar,
|
||||
FiTag,
|
||||
FiX,
|
||||
FiInfo,
|
||||
FiPlus,
|
||||
FiScissors,
|
||||
FiMinimize2,
|
||||
FiBarChart2,
|
||||
FiEdit3,
|
||||
FiTag as FiTagIcon,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
const columnHelper = createColumnHelper<Session>();
|
||||
@@ -62,6 +101,11 @@ export function SessionList() {
|
||||
const toast = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState<'all' | 'today' | 'week' | 'month'>('all');
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [showDataOpsModal, setShowDataOpsModal] = useState(false);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'createTimestamp',
|
||||
@@ -69,6 +113,16 @@ export function SessionList() {
|
||||
},
|
||||
]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'zip'>('json');
|
||||
|
||||
// Data operations state
|
||||
const [mergeName, setMergeName] = useState('');
|
||||
const [mergeTags, setMergeTags] = useState('');
|
||||
const [splitMaxEvents, setSplitMaxEvents] = useState(1000);
|
||||
const [splitDuration, setSplitDuration] = useState(60000);
|
||||
const [compressSampleRate, setCompressSampleRate] = useState(0.5);
|
||||
const [bulkRenameInput, setBulkRenameInput] = useState('');
|
||||
const [bulkTagsInput, setBulkTagsInput] = useState('');
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
@@ -82,11 +136,11 @@ export function SessionList() {
|
||||
|
||||
const fetchData = (options: { pageIndex: number; pageSize: number }) => {
|
||||
return {
|
||||
rows: sessions.slice(
|
||||
rows: filteredSessions.slice(
|
||||
options.pageIndex * options.pageSize,
|
||||
(options.pageIndex + 1) * options.pageSize,
|
||||
),
|
||||
pageCount: Math.ceil(sessions.length / options.pageSize),
|
||||
pageCount: Math.ceil(filteredSessions.length / options.pageSize),
|
||||
};
|
||||
};
|
||||
const pagination = useMemo(
|
||||
@@ -181,6 +235,18 @@ export function SessionList() {
|
||||
cell: (info) => info.getValue(),
|
||||
header: 'RRWEB Version',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.tags, {
|
||||
cell: (info) => (
|
||||
<Flex flexWrap="wrap" gap={1}>
|
||||
{info.getValue().map((tag, index) => (
|
||||
<Tag key={index} size="sm" variant="subtle">
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
header: 'Tags',
|
||||
}),
|
||||
],
|
||||
[sessions],
|
||||
);
|
||||
@@ -206,6 +272,40 @@ export function SessionList() {
|
||||
setSessions(sessions);
|
||||
};
|
||||
|
||||
// Apply filtering
|
||||
useEffect(() => {
|
||||
let filtered = [...sessions];
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(session =>
|
||||
session.name.toLowerCase().includes(term) ||
|
||||
session.tags.some(tag => tag.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
// Date filter
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
filtered = filtered.filter(session => new Date(session.createTimestamp) >= today);
|
||||
break;
|
||||
case 'week':
|
||||
filtered = filtered.filter(session => new Date(session.createTimestamp) >= weekAgo);
|
||||
break;
|
||||
case 'month':
|
||||
filtered = filtered.filter(session => new Date(session.createTimestamp) >= monthAgo);
|
||||
break;
|
||||
}
|
||||
|
||||
setFilteredSessions(filtered);
|
||||
}, [sessions, searchTerm, dateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void updateSessions();
|
||||
channel.on(EventName.SessionUpdated, () => {
|
||||
@@ -250,26 +350,364 @@ export function SessionList() {
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const getDateFilterLabel = () => {
|
||||
switch (dateFilter) {
|
||||
case 'today': return 'Today';
|
||||
case 'week': return 'Last 7 days';
|
||||
case 'month': return 'Last 30 days';
|
||||
default: return 'All time';
|
||||
}
|
||||
};
|
||||
|
||||
// Data operations handlers
|
||||
const handleMergeSessions = async () => {
|
||||
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
|
||||
if (selectedIds.length < 2) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select at least 2 sessions to merge',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { session, events } = await mergeSessions(selectedIds, {
|
||||
name: mergeName || `Merged - ${selectedIds.length} sessions`,
|
||||
tags: mergeTags.split(',').map(tag => tag.trim()).filter(Boolean),
|
||||
});
|
||||
|
||||
// Add the merged session
|
||||
await addSession(session, events);
|
||||
|
||||
// Clean up original sessions
|
||||
await deleteSessions(selectedIds);
|
||||
|
||||
// Reset state
|
||||
setRowSelection({});
|
||||
setShowDataOpsModal(false);
|
||||
setMergeName('');
|
||||
setMergeTags('');
|
||||
void updateSessions();
|
||||
|
||||
toast({
|
||||
title: 'Sessions merged',
|
||||
description: `Created merged session: ${session.name}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error merging sessions',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSplitSession = async () => {
|
||||
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
|
||||
if (selectedIds.length !== 1) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select exactly 1 session to split',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessions: splitSessions, eventsLists } = await splitSession(selectedIds[0], {
|
||||
maxEvents: splitMaxEvents,
|
||||
duration: splitDuration,
|
||||
});
|
||||
|
||||
// Add split sessions
|
||||
for (let i = 0; i < splitSessions.length; i++) {
|
||||
await addSession(splitSessions[i], eventsLists[i]);
|
||||
}
|
||||
|
||||
// Clean up original session
|
||||
await deleteSessions([selectedIds[0]]);
|
||||
|
||||
// Reset state
|
||||
setRowSelection({});
|
||||
setShowDataOpsModal(false);
|
||||
void updateSessions();
|
||||
|
||||
toast({
|
||||
title: 'Session split',
|
||||
description: `Created ${splitSessions.length} split sessions`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error splitting session',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompressSession = async () => {
|
||||
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
|
||||
if (selectedIds.length !== 1) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select exactly 1 session to compress',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { session, events } = await compressSession(selectedIds[0], {
|
||||
sampleRate: compressSampleRate,
|
||||
removeMetadata: true,
|
||||
});
|
||||
|
||||
// Add compressed session
|
||||
await addSession(session, events);
|
||||
|
||||
// Clean up original session
|
||||
await deleteSessions([selectedIds[0]]);
|
||||
|
||||
// Reset state
|
||||
setRowSelection({});
|
||||
setShowDataOpsModal(false);
|
||||
void updateSessions();
|
||||
|
||||
toast({
|
||||
title: 'Session compressed',
|
||||
description: `Compressed to ${events.length} events (${Math.round(compressSampleRate * 100)}%)`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error compressing session',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkRename = async () => {
|
||||
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
|
||||
if (selectedIds.length === 0 || !bulkRenameInput.trim()) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select sessions and enter a new name',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await bulkRenameSessions(selectedIds, bulkRenameInput.trim());
|
||||
|
||||
// Reset state
|
||||
setRowSelection({});
|
||||
setShowDataOpsModal(false);
|
||||
setBulkRenameInput('');
|
||||
void updateSessions();
|
||||
|
||||
toast({
|
||||
title: 'Sessions renamed',
|
||||
description: `Renamed ${selectedIds.length} sessions to: ${bulkRenameInput}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error renaming sessions',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAddTags = async () => {
|
||||
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
|
||||
const tags = bulkTagsInput.split(',').map(tag => tag.trim()).filter(Boolean);
|
||||
|
||||
if (selectedIds.length === 0 || tags.length === 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select sessions and enter tags to add',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await bulkAddTags(selectedIds, tags);
|
||||
|
||||
// Reset state
|
||||
setRowSelection({});
|
||||
setShowDataOpsModal(false);
|
||||
setBulkTagsInput('');
|
||||
void updateSessions();
|
||||
|
||||
toast({
|
||||
title: 'Tags added',
|
||||
description: `Added tags to ${selectedIds.length} sessions`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error adding tags',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowStats = async () => {
|
||||
const selectedIds = table.getSelectedRowModel().flatRows.map(row => row.original.id);
|
||||
if (selectedIds.length !== 1) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select exactly 1 session to view statistics',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = table.getSelectedRowModel().flatRows[0].original;
|
||||
const stats = await getSessionStats(selectedIds[0]);
|
||||
|
||||
// Show stats in a modal or alert
|
||||
alert(`Session Statistics for "${session.name}":
|
||||
|
||||
Total Events: ${stats.totalEvents}
|
||||
Duration: ${Math.round(stats.duration / 1000)}s
|
||||
File Size: ${(stats.fileSize / 1024 / 1024).toFixed(2)} MB
|
||||
Avg Events/Second: ${stats.avgEventsPerSecond.toFixed(2)}
|
||||
|
||||
Event Types:
|
||||
${Object.entries(stats.eventTypes).map(([type, count]) => ` ${type}: ${count}`).join('\n')}`);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error getting statistics',
|
||||
description: (error as Error).message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
size="sm"
|
||||
m={4}
|
||||
>
|
||||
Import Session
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</Flex>
|
||||
<VStack spacing={4} mb={4} align="stretch">
|
||||
{/* Search and Filter Bar */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={4}>
|
||||
<HStack>
|
||||
<FiSearch color="gray.500" />
|
||||
<Input
|
||||
placeholder="Search sessions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
w={300}
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
leftIcon={<FiFilter />}
|
||||
variant={dateFilter !== 'all' ? 'solid' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
>
|
||||
{getDateFilterLabel()}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setDateFilter('all');
|
||||
}}
|
||||
leftIcon={<FiX />}
|
||||
isDisabled={!searchTerm && dateFilter === 'all'}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiFileText />}
|
||||
>
|
||||
Import Session
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDataOpsModal(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<FiEdit3 />}
|
||||
isDisabled={Object.keys(rowSelection).length === 0}
|
||||
>
|
||||
Data Operations
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Results Summary */}
|
||||
<Flex justify="space-between" align="center" fontSize="sm" color="gray.600">
|
||||
<Text>
|
||||
Showing {filteredSessions.length} of {sessions.length} sessions
|
||||
</Text>
|
||||
{searchTerm && (
|
||||
<Text>Search: "{searchTerm}"</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
<TableContainer fontSize="md">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
@@ -434,24 +872,286 @@ export function SessionList() {
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{/* Export Format Selection */}
|
||||
<Select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as any)}
|
||||
size="md"
|
||||
width="120px"
|
||||
>
|
||||
<option value="json">
|
||||
<HStack>
|
||||
<FiFile />
|
||||
<span>JSON</span>
|
||||
</HStack>
|
||||
</option>
|
||||
<option value="html">
|
||||
<HStack>
|
||||
<FiFileText />
|
||||
<span>HTML</span>
|
||||
</HStack>
|
||||
</option>
|
||||
<option value="zip">
|
||||
<HStack>
|
||||
<FiArchive />
|
||||
<span>ZIP</span>
|
||||
</HStack>
|
||||
</option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
mr={4}
|
||||
size="md"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiDownload />}
|
||||
onClick={() => {
|
||||
const selectedRows = table.getSelectedRowModel().flatRows;
|
||||
if (selectedRows.length === 0) return;
|
||||
void downloadSessions(
|
||||
selectedRows.map((row) => row.original.id),
|
||||
exportFormat
|
||||
);
|
||||
|
||||
toast({
|
||||
title: '下载开始',
|
||||
description: `正在导出 ${selectedRows.length} 个会话为 ${exportFormat.toUpperCase()} 格式`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Download
|
||||
导出 ({exportFormat.toUpperCase()})
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal isOpen={showFilterModal} onClose={() => setShowFilterModal(false)} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Filter Sessions</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<HStack>
|
||||
<FiCalendar />
|
||||
<Text fontWeight="semibold">Date Range</Text>
|
||||
</HStack>
|
||||
<VStack spacing={2}>
|
||||
{[
|
||||
{ value: 'all', label: 'All sessions' },
|
||||
{ value: 'today', label: 'Today only' },
|
||||
{ value: 'week', label: 'Last 7 days' },
|
||||
{ value: 'month', label: 'Last 30 days' },
|
||||
].map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={dateFilter === option.value ? 'solid' : 'ghost'}
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<FiCalendar />}
|
||||
onClick={() => {
|
||||
setDateFilter(option.value as any);
|
||||
setShowFilterModal(false);
|
||||
}}
|
||||
w="100%"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowFilterModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Data Operations Modal */}
|
||||
<Modal isOpen={showDataOpsModal} onClose={() => setShowDataOpsModal(false)} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="3xl">
|
||||
<ModalHeader>Data Operations</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Merge Sessions */}
|
||||
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} rounded="lg">
|
||||
<HStack mb={3}>
|
||||
<FiPlus />
|
||||
<Text fontWeight="semibold">Merge Sessions</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" mb={3}>Combine multiple sessions into one. Select at least 2 sessions.</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Merged Session Name</FormLabel>
|
||||
<Input
|
||||
value={mergeName}
|
||||
onChange={(e) => setMergeName(e.target.value)}
|
||||
placeholder="Enter name for merged session"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Tags (comma-separated)</FormLabel>
|
||||
<Input
|
||||
value={mergeTags}
|
||||
onChange={(e) => setMergeTags(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
leftIcon={<FiPlus />}
|
||||
colorScheme="blue"
|
||||
onClick={handleMergeSessions}
|
||||
size="sm"
|
||||
>
|
||||
Merge Selected Sessions
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Split Session */}
|
||||
<Box bg={useColorModeValue('green.50', 'green.900')} p={4} rounded="lg">
|
||||
<HStack mb={3}>
|
||||
<FiScissors />
|
||||
<Text fontWeight="semibold">Split Session</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" mb={3}>Split a large session into smaller chunks. Select exactly 1 session.</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Max Events Per Chunk</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={splitMaxEvents}
|
||||
onChange={(e) => setSplitMaxEvents(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Max Duration (milliseconds)</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={splitDuration}
|
||||
onChange={(e) => setSplitDuration(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
leftIcon={<FiScissors />}
|
||||
colorScheme="green"
|
||||
onClick={handleSplitSession}
|
||||
size="sm"
|
||||
>
|
||||
Split Selected Session
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Compress Session */}
|
||||
<Box bg={useColorModeValue('yellow.50', 'yellow.900')} p={4} rounded="lg">
|
||||
<HStack mb={3}>
|
||||
<FiMinimize2 />
|
||||
<Text fontWeight="semibold">Compress Session</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" mb={3}>Reduce event count by sampling. Select exactly 1 session.</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Sample Rate (0.1 - 1.0)</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
max="1.0"
|
||||
value={compressSampleRate}
|
||||
onChange={(e) => setCompressSampleRate(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
leftIcon={<FiMinimize2 />}
|
||||
colorScheme="yellow"
|
||||
onClick={handleCompressSession}
|
||||
size="sm"
|
||||
>
|
||||
Compress Selected Session
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Bulk Operations */}
|
||||
<Box bg={useColorModeValue('purple.50', 'purple.900')} p={4} rounded="lg">
|
||||
<HStack mb={3}>
|
||||
<FiEdit3 />
|
||||
<Text fontWeight="semibold">Bulk Operations</Text>
|
||||
</HStack>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Bulk Rename</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
flex={1}
|
||||
value={bulkRenameInput}
|
||||
onChange={(e) => setBulkRenameInput(e.target.value)}
|
||||
placeholder="Enter new name for all selected sessions"
|
||||
/>
|
||||
<Button
|
||||
leftIcon={<FiEdit3 />}
|
||||
onClick={handleBulkRename}
|
||||
size="sm"
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Bulk Add Tags</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
flex={1}
|
||||
value={bulkTagsInput}
|
||||
onChange={(e) => setBulkTagsInput(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
/>
|
||||
<Button
|
||||
leftIcon={<FiTagIcon />}
|
||||
onClick={handleBulkAddTags}
|
||||
size="sm"
|
||||
>
|
||||
Add Tags
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Statistics */}
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} rounded="lg">
|
||||
<HStack mb={3}>
|
||||
<FiBarChart2 />
|
||||
<Text fontWeight="semibold">View Statistics</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" mb={3}>View detailed statistics for a session. Select exactly 1 session.</Text>
|
||||
<Button
|
||||
leftIcon={<FiBarChart2 />}
|
||||
variant="outline"
|
||||
onClick={handleShowStats}
|
||||
size="sm"
|
||||
>
|
||||
View Statistics
|
||||
</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setShowDataOpsModal(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
1157
packages/web-extension/src/pages/SessionList.tsx.backup
Normal file
1157
packages/web-extension/src/pages/SessionList.tsx.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,41 @@ import {
|
||||
Spacer,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Code,
|
||||
useColorMode,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
Divider,
|
||||
ScaleFade,
|
||||
Button,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSettings, FiList, FiPause, FiPlay } from 'react-icons/fi';
|
||||
import {
|
||||
FiSettings,
|
||||
FiList,
|
||||
FiPause,
|
||||
FiPlay,
|
||||
FiTerminal,
|
||||
FiVideo,
|
||||
FiSave,
|
||||
FiTrash2,
|
||||
FiRefreshCw,
|
||||
FiAlertCircle,
|
||||
FiCheckCircle,
|
||||
FiClock,
|
||||
FiBarChart2,
|
||||
FiDownload
|
||||
} from 'react-icons/fi';
|
||||
import Channel from '~/utils/channel';
|
||||
import { LocalDataKey, RecorderStatus, EventName } from '~/types';
|
||||
import type { LocalData, Session } from '~/types';
|
||||
|
||||
import { CircleButton } from '~/components/CircleButton';
|
||||
import { Timer } from './Timer';
|
||||
|
||||
const RECORD_BUTTON_SIZE = 3;
|
||||
|
||||
const channel = new Channel();
|
||||
@@ -25,6 +52,14 @@ export function App() {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [startTime, setStartTime] = useState(0);
|
||||
const [newSession, setNewSession] = useState<Session | null>(null);
|
||||
const [stats, setStats] = useState({ events: 0, size: 0, lastEventTime: 0, currentTab: '' });
|
||||
const [isTabActive, setIsTabActive] = useState(true);
|
||||
const [performanceMetrics, setPerformanceMetrics] = useState({
|
||||
fps: 0,
|
||||
memoryUsage: 0,
|
||||
recordSpeed: 0,
|
||||
});
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const parseStatusData = (data: LocalData[LocalDataKey.recorderStatus]) => {
|
||||
@@ -48,132 +83,419 @@ export function App() {
|
||||
channel.on(EventName.SessionUpdated, (data) => {
|
||||
setNewSession((data as { session: Session }).session);
|
||||
});
|
||||
|
||||
// Monitor active tab
|
||||
Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
|
||||
if (tabs[0]) {
|
||||
setStats(prev => ({ ...prev, currentTab: tabs[0].title }));
|
||||
setIsTabActive(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to tab changes
|
||||
Browser.tabs.onActivated.addListener((activeInfo) => {
|
||||
Browser.tabs.get(activeInfo.tabId).then((tab) => {
|
||||
setStats(prev => ({ ...prev, currentTab: tab.title }));
|
||||
setIsTabActive(tab.id === ( Browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0]?.id)));
|
||||
});
|
||||
});
|
||||
|
||||
// Listen to tab updates
|
||||
Browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||
if (changeInfo.status === 'complete') {
|
||||
Browser.tabs.get(tabId).then((tab) => {
|
||||
setStats(prev => ({ ...prev, currentTab: tab.title }));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update performance metrics periodically
|
||||
const perfInterval = setInterval(() => {
|
||||
if (status === RecorderStatus.RECORDING && isTabActive) {
|
||||
// Simulate performance metrics (in real implementation, these would come from content script)
|
||||
setPerformanceMetrics({
|
||||
fps: Math.floor(Math.random() * 10) + 45, // 45-55 FPS
|
||||
memoryUsage: Math.floor(Math.random() * 50) + 100, // 100-150 MB
|
||||
recordSpeed: Math.floor(Math.random() * 100) + 50, // 50-150 events/sec
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(perfInterval);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case RecorderStatus.RECORDING:
|
||||
return 'red';
|
||||
case RecorderStatus.PAUSED:
|
||||
return 'yellow';
|
||||
case RecorderStatus.PausedSwitch:
|
||||
return 'orange';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case RecorderStatus.RECORDING:
|
||||
return '正在录制';
|
||||
case RecorderStatus.PAUSED:
|
||||
return '已暂停';
|
||||
case RecorderStatus.PausedSwitch:
|
||||
return '切换标签页暂停';
|
||||
default:
|
||||
return '准备就绪';
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" w={300} padding="5%">
|
||||
<Flex>
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
RRWeb Recorder
|
||||
</Text>
|
||||
<Spacer />
|
||||
<Stack direction="row">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
void Browser.tabs.create({ url: '/pages/index.html#/' });
|
||||
}}
|
||||
size="xs"
|
||||
icon={<FiList />}
|
||||
aria-label={'Session List'}
|
||||
title="Session List"
|
||||
></IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
void Browser.runtime.openOptionsPage();
|
||||
}}
|
||||
size="xs"
|
||||
icon={<FiSettings />}
|
||||
aria-label={'Settings button'}
|
||||
title="Settings"
|
||||
></IconButton>
|
||||
</Stack>
|
||||
</Flex>
|
||||
{status !== RecorderStatus.IDLE && startTime && (
|
||||
<Timer
|
||||
startTime={startTime}
|
||||
ticking={status === RecorderStatus.RECORDING}
|
||||
/>
|
||||
)}
|
||||
<Flex justify="center" gap="10" mt="5" mb="5">
|
||||
{
|
||||
<CircleButton
|
||||
diameter={RECORD_BUTTON_SIZE}
|
||||
title={
|
||||
status === RecorderStatus.IDLE
|
||||
? 'Start Recording'
|
||||
: 'Stop Recording'
|
||||
}
|
||||
onClick={() => {
|
||||
if (status === RecorderStatus.IDLE)
|
||||
void channel.emit(EventName.StartButtonClicked, {});
|
||||
else void channel.emit(EventName.StopButtonClicked, {});
|
||||
}}
|
||||
<Flex direction="column" w={320} h={580} bg={useColorModeValue('white', 'gray.900')} borderRadius="lg" shadow="lg" overflow="hidden">
|
||||
{/* Header */}
|
||||
<Box bg={useColorModeValue('blue.500', 'blue.700')} p={4} color="white">
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack>
|
||||
<FiVideo size={20} />
|
||||
<Text fontSize="lg" fontWeight="bold">RRWeb 录制插件</Text>
|
||||
</HStack>
|
||||
<Badge
|
||||
bg={getStatusColor() === 'red' ? 'red.500' : getStatusColor() === 'yellow' ? 'yellow.500' : getStatusColor() === 'orange' ? 'orange.500' : 'green.500'}
|
||||
color="white"
|
||||
px={2}
|
||||
py={1}
|
||||
rounded="full"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
w={`${RECORD_BUTTON_SIZE}rem`}
|
||||
h={`${RECORD_BUTTON_SIZE}rem`}
|
||||
borderRadius={status === RecorderStatus.IDLE ? 9999 : 6}
|
||||
margin="0"
|
||||
bgColor="red.500"
|
||||
/>
|
||||
</CircleButton>
|
||||
}
|
||||
{status !== RecorderStatus.IDLE && (
|
||||
<CircleButton
|
||||
diameter={RECORD_BUTTON_SIZE}
|
||||
title={
|
||||
status === RecorderStatus.RECORDING
|
||||
? 'Pause Recording'
|
||||
: 'Resume Recording'
|
||||
}
|
||||
onClick={() => {
|
||||
if (status === RecorderStatus.RECORDING) {
|
||||
void channel.emit(EventName.PauseButtonClicked, {});
|
||||
} else {
|
||||
void channel.emit(EventName.ResumeButtonClicked, {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={`${RECORD_BUTTON_SIZE}rem`}
|
||||
h={`${RECORD_BUTTON_SIZE}rem`}
|
||||
borderRadius={9999}
|
||||
margin="0"
|
||||
color="gray.600"
|
||||
>
|
||||
{[RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes(
|
||||
status,
|
||||
) && (
|
||||
<FiPlay
|
||||
style={{
|
||||
paddingLeft: '0.5rem',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status === RecorderStatus.RECORDING && (
|
||||
<FiPause
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{getStatusText()}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Main Content */}
|
||||
<Flex direction="column" p={4} flex={1} overflow="auto">
|
||||
{/* Enhanced Status Cards */}
|
||||
<ScaleFade in={status !== RecorderStatus.IDLE} initialScale={0.9}>
|
||||
<VStack spacing={3} mb={4}>
|
||||
{/* Current Tab */}
|
||||
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<FiList color="indigo.500" />
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
|
||||
当前标签页
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={useColorModeValue('gray.900', 'white')}
|
||||
maxW={120}
|
||||
isTruncated
|
||||
>
|
||||
{stats.currentTab || '未知标签页'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
</CircleButton>
|
||||
)}
|
||||
</Flex>
|
||||
{newSession && (
|
||||
<Text>
|
||||
<Text as="b">New Session: </Text>
|
||||
<Link
|
||||
href={Browser.runtime.getURL(
|
||||
`pages/index.html#/session/${newSession.id}`,
|
||||
|
||||
{/* Recording Duration */}
|
||||
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<FiClock color="blue.500" />
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
|
||||
录制时长
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.900', 'white')}>
|
||||
{startTime ? <Timer startTime={startTime} ticking={status === RecorderStatus.RECORDING} /> : '00:00'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Event Count */}
|
||||
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<FiBarChart2 color="green.500" />
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
|
||||
事件数量
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.900', 'white')}>
|
||||
{stats.events.toLocaleString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* File Size */}
|
||||
<Box w="100%" bg={useColorModeValue('gray.50', 'gray.800')} p={3} rounded="lg">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<FiSave color="purple.500" />
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.700', 'gray.300')}>
|
||||
文件大小
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.900', 'white')}>
|
||||
{formatFileSize(stats.size)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{status === RecorderStatus.RECORDING && isTabActive && (
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text fontSize="xs" fontWeight="semibold" color={useColorModeValue('gray.700', 'gray.300')} mb={1}>
|
||||
性能指标
|
||||
</Text>
|
||||
|
||||
<HStack gap={2}>
|
||||
<Box flex={1} bg={useColorModeValue('blue.50', 'blue.900')} p={2} rounded="md">
|
||||
<Text fontSize="2xs" color={useColorModeValue('gray.600', 'gray.300')}>FPS</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('blue.700', 'blue.200')}>
|
||||
{performanceMetrics.fps}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flex={1} bg={useColorModeValue('green.50', 'green.900')} p={2} rounded="md">
|
||||
<Text fontSize="2xs" color={useColorModeValue('gray.600', 'gray.300')}>内存</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('green.700', 'green.200')}>
|
||||
{performanceMetrics.memoryUsage} MB
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flex={1} bg={useColorModeValue('orange.50', 'orange.900')} p={2} rounded="md">
|
||||
<Text fontSize="2xs" color={useColorModeValue('gray.600', 'gray.300')}>速度</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('orange.700', 'orange.200')}>
|
||||
{performanceMetrics.recordSpeed}/s
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
isExternal
|
||||
>
|
||||
{newSession.name}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
{errorMessage !== '' && (
|
||||
<Text color="red.500" fontSize="md">
|
||||
{errorMessage}
|
||||
<br />
|
||||
Maybe refresh your current tab.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Tab Status */}
|
||||
{status === RecorderStatus.RECORDING && (
|
||||
<Box
|
||||
w="100%"
|
||||
bg={isTabActive ? useColorModeValue('green.50', 'green.900') : useColorModeValue('red.50', 'red.900')}
|
||||
p={2}
|
||||
rounded="lg"
|
||||
>
|
||||
<Flex align="center" justify="center">
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
rounded="full"
|
||||
mr={2}
|
||||
bg={isTabActive ? 'green.500' : 'red.500'}
|
||||
/>
|
||||
<Text fontSize="2xs" color={isTabActive ? useColorModeValue('green.700', 'green.200') : useColorModeValue('red.700', 'red.200')}>
|
||||
{isTabActive ? '标签页活跃中' : '标签页已切换,录制已暂停'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</ScaleFade>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<Flex justify="center" gap={4} my={6}>
|
||||
<Tooltip label={status === RecorderStatus.IDLE ? "开始录制" : "停止录制"}>
|
||||
<CircleButton
|
||||
diameter={RECORD_BUTTON_SIZE}
|
||||
title={status === RecorderStatus.IDLE ? '开始录制' : '停止录制'}
|
||||
onClick={() => {
|
||||
if (status === RecorderStatus.IDLE) {
|
||||
void channel.emit(EventName.StartButtonClicked, {});
|
||||
toast({
|
||||
title: '开始录制',
|
||||
description: '录制已开始',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
} else {
|
||||
void channel.emit(EventName.StopButtonClicked, {});
|
||||
toast({
|
||||
title: '停止录制',
|
||||
description: '录制已停止',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={`${RECORD_BUTTON_SIZE}rem`}
|
||||
h={`${RECORD_BUTTON_SIZE}rem`}
|
||||
borderRadius={status === RecorderStatus.IDLE ? 9999 : 6}
|
||||
margin="0"
|
||||
bg={status === RecorderStatus.IDLE ? "red.500" : "gray.600"}
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'scale(1.05)' }}
|
||||
/>
|
||||
</CircleButton>
|
||||
</Tooltip>
|
||||
|
||||
{status !== RecorderStatus.IDLE && (
|
||||
<Tooltip label={status === RecorderStatus.RECORDING ? "暂停录制" : "继续录制"}>
|
||||
<CircleButton
|
||||
diameter={RECORD_BUTTON_SIZE}
|
||||
title={
|
||||
status === RecorderStatus.RECORDING
|
||||
? '暂停录制'
|
||||
: '继续录制'
|
||||
}
|
||||
onClick={() => {
|
||||
if (status === RecorderStatus.RECORDING) {
|
||||
void channel.emit(EventName.PauseButtonClicked, {});
|
||||
} else {
|
||||
void channel.emit(EventName.ResumeButtonClicked, {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={`${RECORD_BUTTON_SIZE}rem`}
|
||||
h={`${RECORD_BUTTON_SIZE}rem`}
|
||||
borderRadius={9999}
|
||||
margin="0"
|
||||
bg="yellow.500"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'scale(1.05)' }}
|
||||
>
|
||||
{status !== RecorderStatus.RECORDING && (
|
||||
<FiPlay
|
||||
style={{
|
||||
paddingLeft: '0.5rem',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status === RecorderStatus.RECORDING && (
|
||||
<FiPause
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</CircleButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<Divider my={4} />
|
||||
<VStack spacing={2} mb={4}>
|
||||
<Tooltip label="录制历史">
|
||||
<Button
|
||||
leftIcon={<FiList />}
|
||||
variant="ghost"
|
||||
w="100%"
|
||||
justifyContent="flex-start"
|
||||
_hover={{ bg: useColorModeValue('blue.50', 'blue.900') }}
|
||||
onClick={() => {
|
||||
void Browser.tabs.create({ url: '/pages/index.html#/' });
|
||||
}}
|
||||
>
|
||||
录制历史
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="设置">
|
||||
<Button
|
||||
leftIcon={<FiSettings />}
|
||||
variant="ghost"
|
||||
w="100%"
|
||||
justifyContent="flex-start"
|
||||
_hover={{ bg: useColorModeValue('blue.50', 'blue.900') }}
|
||||
onClick={() => {
|
||||
void Browser.runtime.openOptionsPage();
|
||||
}}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
|
||||
{/* Session Info */}
|
||||
{newSession && (
|
||||
<ScaleFade in={true} initialScale={0.9}>
|
||||
<Box bg={useColorModeValue('green.50', 'green.900')} p={3} rounded="lg" mb={4}>
|
||||
<HStack>
|
||||
<FiCheckCircle color="green.500" />
|
||||
<Text fontSize="sm" fontWeight="bold" color={useColorModeValue('green.800', 'green.200')}>
|
||||
新录制完成
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" mt={1} color={useColorModeValue('green.700', 'green.300')}>
|
||||
<Link
|
||||
href={Browser.runtime.getURL(`pages/index.html#/session/${newSession.id}`)}
|
||||
isExternal
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
>
|
||||
{newSession.name}
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<ScaleFade in={true} initialScale={0.9}>
|
||||
<Box bg={useColorModeValue('red.50', 'red.900')} p={3} rounded="lg">
|
||||
<HStack>
|
||||
<FiAlertCircle color="red.500" />
|
||||
<Text fontSize="sm" color={useColorModeValue('red.800', 'red.200')}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" mt={1} color={useColorModeValue('red.700', 'red.300')}>
|
||||
请刷新当前页面重试
|
||||
</Text>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<Box mt="auto" pt={4}>
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.800')} p={3} borderRadius="md">
|
||||
<HStack spacing={2} mb={2}>
|
||||
<FiTerminal color={useColorModeValue('gray.600', 'gray.400')} />
|
||||
<Text fontSize="xs" fontWeight="semibold" color={useColorModeValue('gray.800', 'gray.200')}>
|
||||
快捷键
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack spacing={1} align="stretch">
|
||||
<HStack justifyContent="space-between" fontSize="2xs">
|
||||
<Text>开始录制</Text>
|
||||
<Code fontSize="2xs" bg={useColorModeValue('gray.200', 'gray.700')} px={1} py={0.5} rounded>
|
||||
Ctrl+Shift+R
|
||||
</Code>
|
||||
</HStack>
|
||||
<HStack justifyContent="space-between" fontSize="2xs">
|
||||
<Text>停止录制</Text>
|
||||
<Code fontSize="2xs" bg={useColorModeValue('gray.200', 'gray.700')} px={1} py={0.5} rounded>
|
||||
Ctrl+Shift+S
|
||||
</Code>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,30 @@ export type SyncData = {
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
//
|
||||
recordingQuality: 'balanced' | 'high' | 'low';
|
||||
autoStart: boolean;
|
||||
enableNotifications: boolean;
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
recordCanvas: boolean;
|
||||
recordInputs: boolean;
|
||||
recordMouse: boolean;
|
||||
recordScroll: boolean;
|
||||
blockSensitiveData: boolean;
|
||||
maskInputs: boolean;
|
||||
excludedDomains: string[];
|
||||
autoCleanupDays: number;
|
||||
maxRecordingSize: '50' | '100' | '200' | '500';
|
||||
defaultExportFormat: 'json' | 'html' | 'zip';
|
||||
includeMetadata: boolean;
|
||||
shortcuts: {
|
||||
start: string;
|
||||
stop: string;
|
||||
};
|
||||
// File path management
|
||||
savePath: string;
|
||||
createSubfolders: boolean;
|
||||
fileNameFormat: 'timestamp' | 'custom';
|
||||
customNameFormat: string;
|
||||
};
|
||||
|
||||
export enum LocalDataKey {
|
||||
|
||||
421
packages/web-extension/src/utils/dataOperations.ts
Normal file
421
packages/web-extension/src/utils/dataOperations.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import type { eventWithTime } from '@rrweb/types';
|
||||
import type { Session } from '~/types';
|
||||
import { openDB } from 'idb';
|
||||
|
||||
const SessionsStoreName = 'sessions';
|
||||
const EventsStoreName = 'events';
|
||||
const SessionsDBName = 'rrweb-sessions';
|
||||
|
||||
export interface MergeOptions {
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SplitOptions {
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
maxEvents?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface CompressionOptions {
|
||||
level?: number;
|
||||
removeMetadata?: boolean;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
totalEvents: number;
|
||||
duration: number;
|
||||
fileSize: number;
|
||||
eventTypes: { [key: string]: number };
|
||||
avgEventsPerSecond: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个会话数据
|
||||
*/
|
||||
export async function mergeSessions(
|
||||
sessionIds: string[],
|
||||
options: MergeOptions = {}
|
||||
): Promise<{ session: Session; events: eventWithTime[] }> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
// 获取所有选中的会话
|
||||
const sessions: Session[] = [];
|
||||
const allEvents: eventWithTime[] = [];
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (session) {
|
||||
sessions.push(session);
|
||||
|
||||
// 获取该会话的所有事件
|
||||
const events = await db.getAll(EventsStoreName, sessionId);
|
||||
allEvents.push(...events);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No valid sessions to merge');
|
||||
}
|
||||
|
||||
// 创建新的合并会话
|
||||
const firstSession = sessions[0];
|
||||
const now = Date.now();
|
||||
|
||||
const mergedSession: Session = {
|
||||
id: `merged-${now}`,
|
||||
name: options.name || `Merged Session - ${sessions.length} recordings`,
|
||||
tags: options.tags || ['merged', ...sessions.flatMap(s => s.tags)],
|
||||
createTimestamp: now,
|
||||
modifyTimestamp: now,
|
||||
recorderVersion: firstSession.recorderVersion,
|
||||
};
|
||||
|
||||
// 按时间排序所有事件
|
||||
allEvents.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// 创建新的合并事件数组
|
||||
const mergedEvents: eventWithTime[] = allEvents.map((event, index) => ({
|
||||
...event,
|
||||
timestamp: event.timestamp + index, // 避免时间戳冲突
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}));
|
||||
|
||||
await db.close();
|
||||
|
||||
return { session: mergedSession, events: mergedEvents };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割会话数据
|
||||
*/
|
||||
export async function splitSession(
|
||||
sessionId: string,
|
||||
options: SplitOptions = {}
|
||||
): Promise<{ sessions: Session[]; eventsLists: eventWithTime[][] }> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (!session) {
|
||||
await db.close();
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const events = await db.getAll(EventsStoreName, sessionId);
|
||||
await db.close();
|
||||
|
||||
// 按时间排序事件
|
||||
events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const splitEventsLists: eventWithTime[][] = [];
|
||||
let currentChunk: eventWithTime[] = [];
|
||||
let currentStartTime = events[0]?.timestamp || 0;
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
|
||||
// 检查是否需要分割
|
||||
let shouldSplit = false;
|
||||
|
||||
if (options.maxEvents && currentChunk.length >= options.maxEvents) {
|
||||
shouldSplit = true;
|
||||
} else if (options.duration && event.timestamp - currentStartTime > options.duration) {
|
||||
shouldSplit = true;
|
||||
} else if (options.startTime && event.timestamp >= options.startTime) {
|
||||
shouldSplit = true;
|
||||
} else if (options.endTime && event.timestamp >= options.endTime) {
|
||||
shouldSplit = true;
|
||||
}
|
||||
|
||||
if (shouldSplit && currentChunk.length > 0) {
|
||||
splitEventsLists.push(currentChunk);
|
||||
currentChunk = [event];
|
||||
currentStartTime = event.timestamp;
|
||||
} else {
|
||||
currentChunk.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个 chunk
|
||||
if (currentChunk.length > 0) {
|
||||
splitEventsLists.push(currentChunk);
|
||||
}
|
||||
|
||||
// 为每个分割创建新的会话
|
||||
const now = Date.now();
|
||||
const splitSessions: Session[] = splitEventsLists.map((eventsList, index) => ({
|
||||
id: `split-${sessionId}-${index + 1}`,
|
||||
name: `${session.name} - Part ${index + 1}`,
|
||||
tags: ['split', ...(session.tags || [])],
|
||||
createTimestamp: now,
|
||||
modifyTimestamp: now,
|
||||
recorderVersion: session.recorderVersion,
|
||||
}));
|
||||
|
||||
return { sessions: splitSessions, eventsLists: splitEventsLists };
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩会话数据(通过采样减少事件数量)
|
||||
*/
|
||||
export async function compressSession(
|
||||
sessionId: string,
|
||||
options: CompressionOptions = {}
|
||||
): Promise<{ session: Session; events: eventWithTime[] }> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (!session) {
|
||||
await db.close();
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const events = await db.getAll(EventsStoreName, sessionId);
|
||||
await db.close();
|
||||
|
||||
// 按时间排序事件
|
||||
events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
let compressedEvents: eventWithTime[] = [...events];
|
||||
|
||||
// 如果设置了采样率,进行采样
|
||||
if (options.sampleRate && options.sampleRate < 1) {
|
||||
const sampleSize = Math.floor(events.length * options.sampleRate);
|
||||
const step = Math.floor(events.length / sampleSize);
|
||||
|
||||
compressedEvents = [];
|
||||
for (let i = 0; i < events.length; i += step) {
|
||||
compressedEvents.push(events[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除元数据(可选)
|
||||
if (options.removeMetadata) {
|
||||
compressedEvents = compressedEvents.map(event => {
|
||||
const { type, data, timestamp } = event;
|
||||
return { type, data, timestamp };
|
||||
});
|
||||
}
|
||||
|
||||
// 创建压缩后的会话
|
||||
const now = Date.now();
|
||||
const compressedSession: Session = {
|
||||
...session,
|
||||
id: `compressed-${sessionId}`,
|
||||
name: `${session.name} (Compressed)`,
|
||||
tags: ['compressed', ...(session.tags || [])],
|
||||
createTimestamp: now,
|
||||
modifyTimestamp: now,
|
||||
};
|
||||
|
||||
return { session: compressedSession, events: compressedEvents };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话统计信息
|
||||
*/
|
||||
export async function getSessionStats(sessionId: string): Promise<SessionStats> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (!session) {
|
||||
await db.close();
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const events = await db.getAll(EventsStoreName, sessionId);
|
||||
await db.close();
|
||||
|
||||
// 按时间排序事件
|
||||
events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const startTime = events[0]?.timestamp || 0;
|
||||
const endTime = events[events.length - 1]?.timestamp || 0;
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 统计事件类型
|
||||
const eventTypes: { [key: string]: number } = {};
|
||||
events.forEach(event => {
|
||||
eventTypes[event.type] = (eventTypes[event.type] || 0) + 1;
|
||||
});
|
||||
|
||||
// 计算平均每秒事件数
|
||||
const avgEventsPerSecond = duration > 0 ? (events.length / duration) * 1000 : 0;
|
||||
|
||||
// 计算文件大小(模拟)
|
||||
const fileSize = JSON.stringify(events).length;
|
||||
|
||||
return {
|
||||
totalEvents: events.length,
|
||||
duration,
|
||||
fileSize,
|
||||
eventTypes,
|
||||
avgEventsPerSecond,
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量重命名会话
|
||||
*/
|
||||
export async function bulkRenameSessions(
|
||||
sessionIds: string[],
|
||||
newName: string
|
||||
): Promise<void> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const updatePromises = sessionIds.map(async (sessionId) => {
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (session) {
|
||||
const updatedSession = {
|
||||
...session,
|
||||
name: newName,
|
||||
modifyTimestamp: Date.now(),
|
||||
};
|
||||
await db.put(SessionsStoreName, updatedSession, sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
await db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加标签
|
||||
*/
|
||||
export async function bulkAddTags(
|
||||
sessionIds: string[],
|
||||
tags: string[]
|
||||
): Promise<void> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const updatePromises = sessionIds.map(async (sessionId) => {
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (session) {
|
||||
const existingTags = session.tags || [];
|
||||
const newTags = [...new Set([...existingTags, ...tags])];
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
tags: newTags,
|
||||
modifyTimestamp: Date.now(),
|
||||
};
|
||||
await db.put(SessionsStoreName, updatedSession, sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
await db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除标签
|
||||
*/
|
||||
export async function bulkRemoveTags(
|
||||
sessionIds: string[],
|
||||
tagsToRemove: string[]
|
||||
): Promise<void> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const updatePromises = sessionIds.map(async (sessionId) => {
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (session) {
|
||||
const existingTags = session.tags || [];
|
||||
const newTags = existingTags.filter(tag => !tagsToRemove.includes(tag));
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
tags: newTags,
|
||||
modifyTimestamp: Date.now(),
|
||||
};
|
||||
await db.put(SessionsStoreName, updatedSession, sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
await db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索事件内容
|
||||
*/
|
||||
export async function searchEventsInSession(
|
||||
sessionId: string,
|
||||
searchTerm: string
|
||||
): Promise<{ events: eventWithTime[]; matches: number }> {
|
||||
const db = await openDB(SessionsDBName, 1);
|
||||
|
||||
const session = await db.get(SessionsStoreName, sessionId);
|
||||
if (!session) {
|
||||
await db.close();
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const events = await db.getAll(EventsStoreName, sessionId);
|
||||
await db.close();
|
||||
|
||||
const matches: eventWithTime[] = [];
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
events.forEach(event => {
|
||||
// 搜索事件类型
|
||||
if (event.type.toLowerCase().includes(searchLower)) {
|
||||
matches.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索事件数据
|
||||
if (event.data) {
|
||||
const dataString = JSON.stringify(event.data).toLowerCase();
|
||||
if (dataString.includes(searchLower)) {
|
||||
matches.push(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { events: matches, matches: matches.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出会话统计报告
|
||||
*/
|
||||
export function exportSessionStats(session: Session, stats: SessionStats): void {
|
||||
const report = {
|
||||
session: {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
tags: session.tags,
|
||||
created: new Date(session.createTimestamp).toISOString(),
|
||||
modified: new Date(session.modifyTimestamp).toISOString(),
|
||||
version: session.recorderVersion,
|
||||
},
|
||||
statistics: {
|
||||
totalEvents: stats.totalEvents,
|
||||
durationMs: stats.duration,
|
||||
fileSizeBytes: stats.fileSize,
|
||||
fileSizeMB: (stats.fileSize / 1024 / 1024).toFixed(2),
|
||||
avgEventsPerSecond: stats.avgEventsPerSecond.toFixed(2),
|
||||
startTime: new Date(stats.startTime).toISOString(),
|
||||
endTime: new Date(stats.endTime).toISOString(),
|
||||
eventTypes: stats.eventTypes,
|
||||
},
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `session-stats-${session.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
20
packages/web-extension/src/utils/format.ts
Normal file
20
packages/web-extension/src/utils/format.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Utility functions for formatting values
|
||||
*/
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function formatDuration(timestamp: number): string {
|
||||
const seconds = Math.floor(timestamp / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
68
packages/web-extension/src/utils/path.ts
Normal file
68
packages/web-extension/src/utils/path.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Settings } from '~/types';
|
||||
|
||||
export function generateFileName(settings: Settings, sessionName?: string): string {
|
||||
const now = new Date();
|
||||
|
||||
if (settings.fileNameFormat === 'timestamp') {
|
||||
const timestamp = now.toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.replace('T', '_')
|
||||
.substring(0, 19);
|
||||
return timestamp;
|
||||
} else if (settings.fileNameFormat === 'custom' && settings.customNameFormat) {
|
||||
let formattedName = settings.customNameFormat;
|
||||
|
||||
// Replace variables
|
||||
formattedName = formattedName.replace('{date}', now.toISOString().split('T')[0]);
|
||||
formattedName = formattedName.replace('{time}', now.toISOString().split('T')[1].substring(0, 8));
|
||||
formattedName = formattedName.replace('{session}', sessionName || 'recording');
|
||||
formattedName = formattedName.replace(/\s+/g, '-');
|
||||
|
||||
// Remove invalid characters
|
||||
formattedName = formattedName.replace(/[<>:"/\\|?*]/g, '');
|
||||
|
||||
return formattedName;
|
||||
} else {
|
||||
return `recording-${now.toISOString().split('T')[0]}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFullSavePath(settings: Settings, sessionName?: string): string {
|
||||
const fileName = generateFileName(settings, sessionName);
|
||||
|
||||
if (settings.createSubfolders) {
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
return `${settings.savePath}/${timestamp}/${fileName}`;
|
||||
} else {
|
||||
return `${settings.savePath}/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSavePath(path: string): { valid: boolean; error?: string } {
|
||||
if (!path || path.trim() === '') {
|
||||
return { valid: false, error: 'Save path cannot be empty' };
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
if (/[<>:"|?*]/.test(path)) {
|
||||
return { valid: false, error: 'Invalid characters in path' };
|
||||
}
|
||||
|
||||
// Check for reserved names
|
||||
if (/^(CON|PRN|AUX|NUL|COM\d|LPT\d)$/i.test(path)) {
|
||||
return { valid: false, error: 'Reserved name' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function formatPathForDisplay(path: string): string {
|
||||
// Shorten path for display if it's too long
|
||||
if (path.length > 50) {
|
||||
const parts = path.split(/[/\\]/);
|
||||
if (parts.length > 3) {
|
||||
return `.../${parts.slice(-3).join('/')}`;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
138
packages/web-extension/src/utils/settings.ts
Normal file
138
packages/web-extension/src/utils/settings.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { openDB } from 'idb';
|
||||
import { Settings, SyncDataKey } from '~/types';
|
||||
import { deleteSessions } from './storage';
|
||||
|
||||
const SettingsStoreName = 'settings';
|
||||
const SettingsDBName = 'rrweb-settings';
|
||||
|
||||
export async function getStorageSettings(): Promise<Settings> {
|
||||
const db = await openDB<Settings>(SettingsDBName, 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(SettingsStoreName)) {
|
||||
db.createObjectStore(SettingsStoreName);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await db.get(SettingsStoreName, 'settings');
|
||||
return settings || {};
|
||||
}
|
||||
|
||||
export async function saveStorageSettings(settings: Settings): Promise<void> {
|
||||
const db = await openDB<Settings>(SettingsDBName, 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(SettingsStoreName)) {
|
||||
db.createObjectStore(SettingsStoreName);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await db.put(SettingsStoreName, settings, 'settings');
|
||||
|
||||
// Also sync to chrome.storage if available
|
||||
if (chrome?.storage?.sync) {
|
||||
await chrome.storage.sync.set({ [SyncDataKey.settings]: settings });
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAllSessions(): Promise<void> {
|
||||
await deleteSessions([]);
|
||||
// Clear settings too
|
||||
const db = await openDB<Settings>(SettingsDBName, 1);
|
||||
await db.delete(SettingsStoreName, 'settings');
|
||||
}
|
||||
|
||||
export function exportSettings(settings: Settings): void {
|
||||
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `rrweb-settings-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function importSettings(file: File): Promise<Settings> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const settings = JSON.parse(e.target?.result as string);
|
||||
if (typeof settings === 'object' && settings !== null) {
|
||||
resolve(settings);
|
||||
} else {
|
||||
reject(new Error('Invalid settings file'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(new Error('Failed to parse settings file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function applyDefaultSettings(settings: Partial<Settings>): Settings {
|
||||
const defaults: Settings = {
|
||||
recordingQuality: 'balanced',
|
||||
autoStart: false,
|
||||
enableNotifications: true,
|
||||
theme: 'system',
|
||||
recordCanvas: true,
|
||||
recordInputs: true,
|
||||
recordMouse: true,
|
||||
recordScroll: true,
|
||||
blockSensitiveData: true,
|
||||
maskInputs: false,
|
||||
excludedDomains: [],
|
||||
autoCleanupDays: 30,
|
||||
maxRecordingSize: '100',
|
||||
defaultExportFormat: 'json',
|
||||
includeMetadata: true,
|
||||
shortcuts: {
|
||||
start: 'Ctrl+Shift+R',
|
||||
stop: 'Ctrl+Shift+S',
|
||||
},
|
||||
// File path management defaults
|
||||
savePath: 'recordings',
|
||||
createSubfolders: true,
|
||||
fileNameFormat: 'timestamp',
|
||||
customNameFormat: 'recording-{date}-{time}',
|
||||
};
|
||||
|
||||
// Deep merge settings
|
||||
return {
|
||||
...defaults,
|
||||
...settings,
|
||||
shortcuts: { ...defaults.shortcuts, ...settings.shortcuts },
|
||||
excludedDomains: settings.excludedDomains || defaults.excludedDomains,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateSettings(settings: Partial<Settings>): Promise<{ valid: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (settings.shortcuts?.start && settings.shortcuts.start.length > 10) {
|
||||
errors.push('Start shortcut too long');
|
||||
}
|
||||
|
||||
if (settings.shortcuts?.stop && settings.shortcuts.stop.length > 10) {
|
||||
errors.push('Stop shortcut too long');
|
||||
}
|
||||
|
||||
if (settings.autoCleanupDays && (settings.autoCleanupDays < 0 || settings.autoCleanupDays > 365)) {
|
||||
errors.push('Auto-cleanup days must be between 0 and 365');
|
||||
}
|
||||
|
||||
const validExportFormats = ['json', 'html', 'zip'];
|
||||
if (settings.defaultExportFormat && !validExportFormats.includes(settings.defaultExportFormat)) {
|
||||
errors.push('Invalid export format');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -101,21 +101,304 @@ export async function deleteSessions(ids: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadSessions(ids: string[]) {
|
||||
for (const sessionId of ids) {
|
||||
export async function downloadSessions(ids: string[], format: 'json' | 'html' | 'zip' = 'json') {
|
||||
if (ids.length === 0) {
|
||||
console.error('No session IDs provided for download');
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
// 如果是 JSON 格式,可以选择单个导出或合并导出
|
||||
await downloadJSONSessions(ids);
|
||||
} else {
|
||||
// HTML 和 ZIP 格式逐个导出
|
||||
await downloadMultipleSessions(ids, format);
|
||||
}
|
||||
}
|
||||
|
||||
// 专门处理 JSON 导出
|
||||
async function downloadJSONSessions(ids: string[]) {
|
||||
if (ids.length === 1) {
|
||||
// 单个会话导出
|
||||
const sessionId = ids[0];
|
||||
const events = await getEvents(sessionId);
|
||||
const session = await getSession(sessionId);
|
||||
const blob = new Blob([JSON.stringify({ session, events }, null, 2)], {
|
||||
|
||||
if (!session || !events) {
|
||||
console.error(`Session ${sessionId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanFileName = session.name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'recording';
|
||||
const jsonData = {
|
||||
session,
|
||||
events,
|
||||
metadata: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
totalEvents: events.length,
|
||||
duration: events.length > 0 ?
|
||||
(events[events.length - 1].timestamp - events[0].timestamp) : 0
|
||||
}
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(jsonData, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
downloadFile(blob, `${cleanFileName}.json`);
|
||||
} else {
|
||||
// 多个会话导出为单个 JSON 文件
|
||||
const sessionData = [];
|
||||
|
||||
for (const sessionId of ids) {
|
||||
try {
|
||||
const events = await getEvents(sessionId);
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (session && events) {
|
||||
sessionData.push({
|
||||
session,
|
||||
events,
|
||||
metadata: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
totalEvents: events.length,
|
||||
duration: events.length > 0 ?
|
||||
(events[events.length - 1].timestamp - events[0].timestamp) : 0
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
totalSessions: sessionData.length,
|
||||
sessions: sessionData
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
downloadFile(blob, `rrweb-sessions-${new Date().toISOString().split('T')[0]}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理多个会话的导出(HTML 和 ZIP)
|
||||
async function downloadMultipleSessions(ids: string[], format: 'json' | 'html' | 'zip') {
|
||||
// 为每个会话创建单独的下载任务
|
||||
const downloadPromises = ids.map(async (sessionId) => {
|
||||
try {
|
||||
const events = await getEvents(sessionId);
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (!session || !events) {
|
||||
console.error(`Session ${sessionId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理文件名,移除非法字符
|
||||
const cleanFileName = session.name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'recording';
|
||||
|
||||
if (format === 'html') {
|
||||
const htmlContent = generateHTMLReplay(session, events);
|
||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
||||
downloadFile(blob, `${cleanFileName}.html`);
|
||||
} else if (format === 'zip') {
|
||||
const zipContent = await generateZIPPackage(session, events);
|
||||
downloadFile(zipContent, `${cleanFileName}.zip`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error downloading session ${sessionId}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有下载完成
|
||||
await Promise.all(downloadPromises);
|
||||
}
|
||||
|
||||
function downloadFile(blob: Blob, filename: string) {
|
||||
try {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${session.name}.json`;
|
||||
a.download = filename;
|
||||
|
||||
// 确保链接在 DOM 中才能触发下载
|
||||
document.body.appendChild(a);
|
||||
|
||||
// 触发下载
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// 延迟移除元素,确保下载开始
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function generateHTMLReplay(session: Session, events: eventWithTime[]): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>rrweb 录制回放 - ${session.name}</title>
|
||||
<script src="https://unpkg.com/rrweb@latest/dist/rrweb.umd.cjs"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
padding: 30px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.title {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
#replayer {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
.controls {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.btn {
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #2563EB;
|
||||
}
|
||||
.btn:disabled {
|
||||
background: #9CA3AF;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 class="title">${session.name}</h1>
|
||||
<div class="info">
|
||||
录制时间: ${new Date(session.createTimestamp).toLocaleString()}<br>
|
||||
事件数量: ${events.length}<br>
|
||||
rrweb 版本: ${session.recorderVersion}
|
||||
</div>
|
||||
</div>
|
||||
<div id="replayer"></div>
|
||||
<div class="controls">
|
||||
<button id="playBtn" class="btn">▶ 播放</button>
|
||||
<button id="pauseBtn" class="btn" disabled>⏸ 暂停</button>
|
||||
<button id="restartBtn" class="btn">🔄 重新开始</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const events = ${JSON.stringify(events)};
|
||||
const replayer = new rrweb.Replayer(events);
|
||||
|
||||
const playBtn = document.getElementById('playBtn');
|
||||
const pauseBtn = document.getElementById('pauseBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
|
||||
playBtn.addEventListener('click', () => {
|
||||
replayer.play();
|
||||
playBtn.disabled = true;
|
||||
pauseBtn.disabled = false;
|
||||
});
|
||||
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
replayer.pause();
|
||||
playBtn.disabled = false;
|
||||
pauseBtn.disabled = true;
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', () => {
|
||||
replayer.pause();
|
||||
replayer.reset();
|
||||
playBtn.disabled = false;
|
||||
pauseBtn.disabled = true;
|
||||
});
|
||||
|
||||
// 自动播放
|
||||
replayer.play();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function generateZIPPackage(session: Session, events: eventWithTime[]): Promise<Blob> {
|
||||
// 动态导入 JSZip
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
// 添加 JSON 数据
|
||||
const jsonData = JSON.stringify({ session, events }, null, 2);
|
||||
zip.file('recording.json', jsonData);
|
||||
|
||||
// 添加 HTML 回放页面
|
||||
const htmlContent = generateHTMLReplay(session, events);
|
||||
zip.file('replay.html', htmlContent);
|
||||
|
||||
// 添加 README 文件
|
||||
const readmeContent = `# rrweb 录制文件
|
||||
|
||||
## 录制信息
|
||||
- **名称**: ${session.name}
|
||||
- **录制时间**: ${new Date(session.createTimestamp).toLocaleString()}
|
||||
- **事件数量**: ${events.length}
|
||||
- **rrweb 版本**: ${session.recorderVersion}
|
||||
|
||||
## 文件说明
|
||||
- \`recording.json\`: 原始录制数据(JSON 格式)
|
||||
- \`replay.html\`: HTML 回放页面,可在浏览器中打开查看录制内容
|
||||
|
||||
## 使用方法
|
||||
1. 打开 \`replay.html\` 文件在浏览器中查看录制回放
|
||||
2. \`recording.json\` 包含完整的录制数据,可用于其他 rrweb 兼容的工具
|
||||
|
||||
## 技术信息
|
||||
本录制文件由 rrweb 浏览器插件生成,使用 rrweb ${session.recorderVersion} 版本。
|
||||
|
||||
生成时间: ${new Date().toISOString()}
|
||||
`;
|
||||
zip.file('README.md', readmeContent);
|
||||
|
||||
return zip.generateAsync({ type: 'blob' });
|
||||
}
|
||||
|
||||
78
packages/web-extension/导出功能测试指南.md
Normal file
78
packages/web-extension/导出功能测试指南.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# RRWeb 插件导出功能测试指南
|
||||
|
||||
## 🎯 测试目标
|
||||
验证 JSON 导出功能是否能够完整导出录制的用户操作信息
|
||||
|
||||
## 📋 测试步骤
|
||||
|
||||
### 1. 安装更新后的插件
|
||||
1. 在 Chrome 浏览器中打开 `chrome://extensions/`
|
||||
2. 找到现有的 RRWeb 插件
|
||||
3. 点击"删除"按钮移除旧版本
|
||||
4. 点击"加载已解压的扩展程序"
|
||||
5. 选择 `C:\Users\xgp\projects\rrweb\packages\web-extension\dist\chrome` 文件夹
|
||||
|
||||
### 2. 录制测试数据
|
||||
1. 点击浏览器工具栏的 RRWeb 图标
|
||||
2. 点击"开始录制"
|
||||
3. 在打开的页面中进行以下操作:
|
||||
- 点击"随机变色"按钮几次
|
||||
- 点击"添加计数器"按钮几次
|
||||
- 点击"测试弹窗"按钮
|
||||
- 在输入框中输入一些文字
|
||||
- 点击一些页面上的其他元素
|
||||
4. 点击"停止录制"
|
||||
|
||||
### 3. 验证录制历史
|
||||
1. 再次点击 RRWeb 图标
|
||||
2. 点击"录制历史"按钮
|
||||
3. 确认能看到刚才录制的会话列表
|
||||
4. 查看会话的基本信息(名称、时间、事件数量等)
|
||||
|
||||
### 4. 测试 JSON 导出功能
|
||||
#### 单个会话导出测试:
|
||||
1. 在录制历史列表中,找到刚才录制的会话
|
||||
2. 点击该会话的复选框选中它
|
||||
3. 点击"导出"按钮
|
||||
4. 选择"JSON"格式
|
||||
5. 确认下载的 JSON 文件
|
||||
|
||||
#### 多个会话导出测试:
|
||||
1. 选择多个会话(复选框)
|
||||
2. 点击"导出"按钮
|
||||
3. 选择"JSON"格式
|
||||
4. 确认下载的合并 JSON 文件
|
||||
|
||||
### 5. 验证导出文件
|
||||
1. 打开下载的 JSON 文件
|
||||
2. 检查文件内容应该包含:
|
||||
- `session` 对象:包含会话元数据
|
||||
- `events` 数组:包含所有录制的事件
|
||||
- `metadata` 对象:包含导出时间、版本、事件数量等信息
|
||||
|
||||
### 6. 检查数据完整性
|
||||
1. 确认 `events` 数组不为空
|
||||
2. 确认每个事件都有正确的 `timestamp`、`type` 和 `data` 属性
|
||||
3. 确认事件数据完整反映了你的操作
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 如果导出功能不工作:
|
||||
1. 检查浏览器控制台是否有错误信息(F12)
|
||||
2. 确保 IndexedDB 中的数据正确存储
|
||||
3. 检查网络连接是否正常
|
||||
4. 尝试刷新页面重新测试
|
||||
|
||||
### 如果录制历史为空:
|
||||
1. 确认录制操作成功完成
|
||||
2. 检查 IndexedDB 数据是否正确保存
|
||||
3. 尝试清除浏览器数据后重新录制
|
||||
|
||||
## 📁 文件存储位置
|
||||
- 录制数据存储在浏览器的 IndexedDB 中
|
||||
- 导出的 JSON 文件默认下载到浏览器的默认下载文件夹
|
||||
|
||||
## ✅ 验收标准
|
||||
- 能够成功导出包含完整录制数据的 JSON 文件
|
||||
- 单个会话和多个会话的 JSON 导出都能正常工作
|
||||
- 导出的 JSON 文件包含完整的会话信息和所有事件数据
|
||||
Reference in New Issue
Block a user