IUHUM6OZ5KYEAYQCIYNG5Q4QLQRAQNMBWKYGV2ZDJFNY5W4DOUNQC DGN6Q2CHV7LMETGFVYZYHKUDIET5YRRUVDZ2XH65MMN5PAZHKHWQC UOADDPQMMLEBR3F3WXOATJ32MZA742VEOQZJWYUBK7H4XLLVHMBQC OBXY6BHNROIV7W4O3MMQ6GTQXU2XUKF5A4DCJYDLUEUHV72E7BNAC GIAIXNFCQIP5ZHBFXWFWDTLDUGV35MDC3ARQCVAN6FEWIX7Q32GQC M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC Z3XA4BHNMZB6I34LZ626NQRUPHARWJ5UYHGT65IFQVSQ5B2TY6OQC YX7LU4WRAUDMWS3DEDXZDSF6DXBHLYDWVSMSRK6KIW3MO6GRXSVQC 6UTPOCBHAP37JLPF6Q2ZNBTJHNJ5VTTRZLWAWAGW5GX6KMW22F2AC /*** Protected API route to get a single dataset by ID** @route GET /api/datasets/:id* @authentication Required* @param {string} id - Dataset ID in URL path* @returns {Object} Response containing:* - data: The dataset object with user permissions* @error 404 - If dataset not found or user doesn't have READ access* @error 500 - If database operation fails* @description Returns a single dataset if the user has READ access to it*/datasets.get("/:id", authenticate, async (c) => {try {// Get the JWT payload (user info)const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub; // User ID from JWT// Get dataset ID from URL parametersconst datasetId = c.req.param("id");if (!datasetId) {return c.json({error: "Missing dataset ID in URL"}, 400);}
// Connect to the databaseconst db = createDatabase(c.env);// First, get the user's roleconst userRoleResult = await db.select({ role: userRole.role }).from(userRole).where(eq(userRole.userId, userId)).limit(1);const userRoleName = userRoleResult.length > 0 ? userRoleResult[0].role : 'USER';// Check if user has permission to read this specific datasetconst hasPermission = await checkUserPermission(db, userId, datasetId, 'READ');if (!hasPermission) {return c.json({error: "Dataset not found or access denied"}, 404);}// Fetch the datasetconst datasetResult = await db.select({id: dataset.id,name: dataset.name,description: dataset.description,public: dataset.public,type: dataset.type,createdAt: dataset.createdAt,lastModified: dataset.lastModified,owner: dataset.owner,active: dataset.active,}).from(dataset).where(eq(dataset.id, datasetId)).limit(1);if (datasetResult.length === 0) {return c.json({error: "Dataset not found"}, 404);}// Get user's permissions for this datasetconst permissions = await db.select({permission: accessGrant.permission}).from(accessGrant).where(sqlExpr`${accessGrant.datasetId} = ${datasetId}AND ${accessGrant.active} = trueAND ((${accessGrant.role} = ${userRoleName} AND ${accessGrant.userId} IS NULL)OR ${accessGrant.userId} = ${userId})`);const userPermissions = permissions.map(p => p.permission);return c.json({data: {...datasetResult[0],permissions: userPermissions}});} catch (error) {console.error("Error fetching dataset:", error);return c.json({error: "Failed to fetch dataset",details: error instanceof Error ? error.message : String(error),},500);}});
try {const accessToken = await getAccessToken();console.log('Fetching dataset owner for:', targetDatasetId);const response = await fetch(`/api/datasets/${targetDatasetId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (response.ok) {const result = await response.json();console.log('Dataset API response:', result);const owner = result.data?.owner || result.owner || null;console.log('Setting dataset owner to:', owner);setDatasetOwnerId(owner);} else {console.error('Failed to fetch dataset:', response.status, response.statusText);}} catch (error) {console.error("Error fetching dataset owner:", error);}}, [isAuthenticated, getAccessToken]);
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
<TableCellclassName="font-medium whitespace-normal break-words cursor-pointer hover:text-blue-600 hover:underline"onClick={() => handleFileClick(file)}>{file.fileName}</TableCell>
import React, { useEffect, useRef, useState, useCallback } from 'react';import { useKindeAuth } from "@kinde-oss/kinde-auth-react";import { X, Play, Pause, Upload, AlertCircle } from 'lucide-react';import WaveSurfer from 'wavesurfer.js';import Spectrogram from 'wavesurfer.js/dist/plugins/spectrogram.esm.js';import Button from './ui/button';import type { File as FileType } from '../types/file';interface FileProps {file: FileType | null;isOpen: boolean;onClose: () => void;datasetOwnerId?: string | null; // Owner of the dataset this file belongs to}interface FileState {// Audio loadingaudioLoading: boolean;audioError: string | null;wavesurfer: WaveSurfer | null;// File pickinglocalFile: File | null;filePickerSupported: boolean;// UI statesisPlaying: boolean;currentTime: number;duration: number;}const File: React.FC<FileProps> = ({file,isOpen,onClose,datasetOwnerId}) => {const { getAccessToken, user } = useKindeAuth();const waveformRef = useRef<HTMLDivElement>(null);const spectrogramRef = useRef<HTMLDivElement>(null);const [state, setState] = useState<FileState>({audioLoading: false,audioError: null,wavesurfer: null,localFile: null,filePickerSupported: 'showOpenFilePicker' in window,isPlaying: false,currentTime: 0,duration: 0,});// Check if current user is the dataset ownerconst isOwner = useCallback(() => {console.log('Owner check:', { userId: user?.id, datasetOwnerId, isOwner: user?.id === datasetOwnerId });return user?.id === datasetOwnerId;}, [user?.id, datasetOwnerId]);// Initialize WaveSurfer instanceconst initWaveSurfer = useCallback(() => {if (!waveformRef.current || !spectrogramRef.current) return null;const wavesurfer = WaveSurfer.create({container: waveformRef.current,height: 100,waveColor: '#4F46E5',progressColor: '#7C3AED',interact: true,hideScrollbar: true,});// Add spectrogram pluginwavesurfer.registerPlugin(Spectrogram.create({container: spectrogramRef.current,height: 256,}));// Event listenerswavesurfer.on('ready', () => {setState(prev => ({...prev,duration: wavesurfer.getDuration(),audioLoading: false}));});wavesurfer.on('play', () => {setState(prev => ({ ...prev, isPlaying: true }));});wavesurfer.on('pause', () => {setState(prev => ({ ...prev, isPlaying: false }));});wavesurfer.on('timeupdate', (currentTime) => {setState(prev => ({ ...prev, currentTime }));});wavesurfer.on('error', (error) => {console.error('WaveSurfer error:', error);setState(prev => ({...prev,audioError: 'Failed to load audio file',audioLoading: false}));});return wavesurfer;}, []);// Load online file from APIconst loadOnlineFile = useCallback(async (fileId: string) => {if (!state.wavesurfer) return;setState(prev => ({ ...prev, audioLoading: true, audioError: null }));try {const accessToken = await getAccessToken();// Load the authenticated audio stream// For authenticated requests, we need to fetch the blob first and then load itconst response = await fetch(`/api/files/${fileId}/download`, {headers: {'Authorization': `Bearer ${accessToken}`,},});if (!response.ok) {throw new Error(`Failed to fetch audio: ${response.status}`);}const blob = await response.blob();const url = URL.createObjectURL(blob);await state.wavesurfer.load(url);// Clean up the object URL when donesetTimeout(() => URL.revokeObjectURL(url), 1000);} catch (error) {console.error('Error loading online file:', error);setState(prev => ({...prev,audioError: 'Failed to load audio file from server',audioLoading: false}));}}, [state.wavesurfer, getAccessToken]);// Load local fileconst loadLocalFile = useCallback((file: File) => {if (!state.wavesurfer) return;setState(prev => ({ ...prev, audioLoading: true, audioError: null }));try {const url = URL.createObjectURL(file);state.wavesurfer.load(url);// Store cleanup function for later usesetTimeout(() => URL.revokeObjectURL(url), 60000); // Clean up after 1 minute} catch (error) {console.error('Error loading local file:', error);setState(prev => ({...prev,audioError: 'Failed to load local audio file',audioLoading: false}));}}, [state.wavesurfer]);// File picker functionalityconst openFilePicker = useCallback(async () => {try {let selectedFile: File | null = null;if (state.filePickerSupported && 'showOpenFilePicker' in window) {// Use File System Access APIinterface FileSystemFileHandle {getFile(): Promise<File>;}const showOpenFilePicker = (window as unknown as {showOpenFilePicker: (options: unknown) => Promise<FileSystemFileHandle[]>}).showOpenFilePicker;const [fileHandle] = await showOpenFilePicker({types: [{description: 'Audio files',accept: {'audio/*': ['.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac', '.wma']}}],multiple: false});selectedFile = await fileHandle.getFile();} else {// Fallback to input elementselectedFile = await new Promise<File | null>((resolve) => {const input = document.createElement('input');input.type = 'file';input.accept = 'audio/*';input.onchange = (e) => {const file = (e.target as HTMLInputElement).files?.[0];resolve(file || null);};input.click();});}if (selectedFile) {setState(prev => ({ ...prev, localFile: selectedFile }));loadLocalFile(selectedFile);}} catch (error) {if (error instanceof Error && error.name === 'AbortError') {// User cancelled, do nothingreturn;}console.error('Error opening file picker:', error);setState(prev => ({...prev,audioError: 'Failed to open file picker'}));}}, [state.filePickerSupported, loadLocalFile]);// Play/pause toggleconst togglePlayPause = useCallback(() => {if (state.wavesurfer) {state.wavesurfer.playPause();}}, [state.wavesurfer]);// Format time for displayconst formatTime = (time: number): string => {const minutes = Math.floor(time / 60);const seconds = Math.floor(time % 60);return `${minutes}:${seconds.toString().padStart(2, '0')}`;};// Initialize WaveSurfer when modal opensuseEffect(() => {if (isOpen && file) {const wavesurfer = initWaveSurfer();setState(prev => ({ ...prev, wavesurfer }));return () => {if (wavesurfer) {wavesurfer.destroy();}};}return undefined; // No cleanup needed if conditions not met}, [isOpen, file, initWaveSurfer]);// Load audio when wavesurfer is readyuseEffect(() => {if (state.wavesurfer && file) {if (file.upload) {loadOnlineFile(file.id);}}}, [state.wavesurfer, file, loadOnlineFile]);// Handle ESC keyuseEffect(() => {const handleKeyDown = (event: KeyboardEvent) => {if (event.key === 'Escape' && isOpen) {onClose();}if (event.key === ' ' && isOpen && state.wavesurfer) {event.preventDefault();togglePlayPause();}};if (isOpen) {document.addEventListener('keydown', handleKeyDown);return () => document.removeEventListener('keydown', handleKeyDown);}return undefined; // No cleanup needed if not open}, [isOpen, onClose, togglePlayPause, state.wavesurfer]);// Don't render if not open or no fileif (!isOpen || !file) return null;const userIsOwner = isOwner();return (<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"><div className="fixed inset-0 z-50 flex items-center justify-center p-4"><div className="relative w-full h-full max-w-none bg-background border rounded-lg shadow-lg overflow-hidden">{/* Header */}<div className="flex items-center justify-between p-4 border-b bg-muted/50"><div className="flex-1 min-w-0"><h2 className="text-lg font-semibold truncate">{file.fileName}</h2><p className="text-sm text-muted-foreground">Duration: {formatTime(file.duration)} |Status: {file.upload ? 'Online' : 'Offline'}{!userIsOwner && !file.upload && ' (Not available)'}</p></div><Buttonvariant="ghost"size="icon"onClick={onClose}className="h-8 w-8"><X className="h-4 w-4" /></Button></div>{/* Main Content */}<div className="flex-1 p-4 overflow-auto">{/* Online file - show waveform */}{file.upload && (<div className="space-y-4">{state.audioLoading && (<div className="flex items-center justify-center h-32 text-muted-foreground">Loading audio...</div>)}{state.audioError && (<div className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-md"><AlertCircle className="h-4 w-4" />{state.audioError}</div>)}{/* Waveform */}<div className="bg-muted/20 rounded-md p-4"><div ref={waveformRef} className="w-full h-24" /></div>{/* Spectrogram */}<div className="bg-muted/20 rounded-md p-4"><h3 className="text-sm font-medium mb-2">Spectrogram</h3><div ref={spectrogramRef} className="w-full" style={{ height: '256px' }} /></div>{/* Controls */}{state.wavesurfer && !state.audioLoading && !state.audioError && (<div className="flex items-center gap-4 p-4 bg-muted/20 rounded-md"><Buttonvariant="outline"size="icon"onClick={togglePlayPause}disabled={state.audioLoading}>{state.isPlaying ? (<Pause className="h-4 w-4" />) : (<Play className="h-4 w-4" />)}</Button><div className="text-sm text-muted-foreground">{formatTime(state.currentTime)} / {formatTime(state.duration)}</div></div>)}</div>)}{/* Offline file + owner - show file picker */}{!file.upload && userIsOwner && (<div className="space-y-4"><div className="text-center p-8"><Upload className="h-12 w-12 mx-auto text-muted-foreground mb-4" /><h3 className="text-lg font-medium mb-2">Load Local File</h3><p className="text-sm text-muted-foreground mb-4">This file is not available online. Load it from your local storage to play and analyze.</p><Button onClick={openFilePicker}>{state.filePickerSupported ? 'Browse Files' : 'Select File'}</Button></div>{/* Show waveform if local file is loaded */}{state.localFile && (<div className="space-y-4"><div className="p-3 bg-green-50 text-green-800 rounded-md">Loaded: {state.localFile.name}</div>{state.audioLoading && (<div className="flex items-center justify-center h-32 text-muted-foreground">Loading audio...</div>)}{/* Waveform */}<div className="bg-muted/20 rounded-md p-4"><div ref={waveformRef} className="w-full h-24" /></div>{/* Spectrogram */}<div className="bg-muted/20 rounded-md p-4"><h3 className="text-sm font-medium mb-2">Spectrogram</h3><div ref={spectrogramRef} className="w-full" style={{ height: '256px' }} /></div>{/* Controls */}{state.wavesurfer && !state.audioLoading && !state.audioError && (<div className="flex items-center gap-4 p-4 bg-muted/20 rounded-md"><Buttonvariant="outline"size="icon"onClick={togglePlayPause}disabled={state.audioLoading}>{state.isPlaying ? (<Pause className="h-4 w-4" />) : (<Play className="h-4 w-4" />)}</Button><div className="text-sm text-muted-foreground">{formatTime(state.currentTime)} / {formatTime(state.duration)}</div></div>)}</div>)}{state.audioError && (<div className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-md"><AlertCircle className="h-4 w-4" />{state.audioError}</div>)}</div>)}{/* Offline file + not owner - show unavailable message */}{!file.upload && !userIsOwner && (<div className="text-center p-8"><AlertCircle className="h-12 w-12 mx-auto text-muted-foreground mb-4" /><h3 className="text-lg font-medium mb-2">File Not Available</h3><p className="text-sm text-muted-foreground">This file is not available for playback. Only the dataset owner can load local files.</p></div>)}</div></div></div></div>);};export default File;
// Fetch dataset ownerconst fetchDatasetOwner = React.useCallback(async (targetDatasetId: string) => {if (!isAuthenticated) return;try {const accessToken = await getAccessToken();console.log('Fetching dataset owner for:', targetDatasetId);const response = await fetch(`/api/datasets/${targetDatasetId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (response.ok) {const result = await response.json();console.log('Dataset API response:', result);const owner = result.data?.owner || result.owner || null;console.log('Setting dataset owner to:', owner);setDatasetOwnerId(owner);} else {console.error('Failed to fetch dataset:', response.status, response.statusText);}} catch (error) {console.error("Error fetching dataset owner:", error);}}, [isAuthenticated, getAccessToken]);
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
<TableCellclassName="font-medium whitespace-normal break-words cursor-pointer hover:text-blue-600 hover:underline"onClick={() => handleFileClick(file)}>{file.fileName}</TableCell>
# File Component Architecture Plan## 🎯 OverviewCreate a `File.tsx` component that handles audio playback and file management based on upload status and user ownership.## 🔄 User Flows### Flow A: `upload=true` (File Available Online)- **Trigger**: User clicks filename in Cluster/Selection component- **Action**: Load audio from `/api/files/{fileId}/download` endpoint- **Display**: Wavesurfer.js with spectrogram plugin- **Features**: Play/pause, seeking, waveform visualization, spectrogram- **Future**: Annotation tools for creating selections### Flow B: `upload=false` + User is Owner- **Trigger**: User clicks filename in Cluster/Selection component- **Action**: Show file picker interface- **Primary**: File System Access API (Chrome 86+, Safari 15.2+)- **Fallback**: `<input type="file" accept="audio/*">`- **Post-selection**: Load local file into wavesurfer.js- **Future**: Option to upload file to B2### Flow C: `upload=false` + User is NOT Owner- **Trigger**: User clicks filename in Cluster/Selection component- **Action**: Show informational message- **Display**: "File not available for playback" + metadata- **No audio loading capability**## 🏗️ Component Architecture### Component Structure```File.tsx (Modal/Dialog)├── FileHeader (filename, close button)├── FileContent (conditional rendering)│ ├── AudioPlayer (upload=true)│ │ ├── WaveSurfer container│ │ ├── Controls (play/pause/seek)│ │ └── Spectrogram display│ ├── FilePicker (upload=false + owner)│ │ ├── FileSystemAccessPicker│ │ └── FallbackFilePicker│ └── FileUnavailable (upload=false + not owner)└── FileFooter (metadata, actions)```### Props Interface```typescriptinterface FileProps {file: File; // File object with all metadataisOpen: boolean; // Modal open stateonClose: () => void; // Close modal callbackcurrentUserId: string; // From Kinde auth context}```### State Management```typescriptinterface FileState {// Audio loadingaudioLoading: boolean;audioError: string | null;wavesurfer: WaveSurfer | null;// File pickinglocalFile: File | null;filePickerSupported: boolean;// UI statesisPlaying: boolean;currentTime: number;duration: number;}```## 🔧 Technical Implementation Plan### 1. Owner Detection Logic```typescriptconst isOwner = (file: File, currentUserId: string): boolean => {return file.created_by === currentUserId || file.modified_by === currentUserId;// TODO: Consider admin roles, dataset ownership, etc.};```### 2. Wavesurfer.js Setup```typescript// For online files (upload=true)const initWaveSurfer = async (fileId: string, token: string) => {const wavesurfer = WaveSurfer.create({container: containerRef.current,waveColor: '#4F46E5',progressColor: '#7C3AED',responsive: true,height: 100,});// Add spectrogram pluginwavesurfer.registerPlugin(SpectrogramPlugin.create({container: spectrogramRef.current,height: 256,}));// Load authenticated audioawait wavesurfer.load(`/api/files/${fileId}/download`, {headers: { Authorization: `Bearer ${token}` }});};// For local files (upload=false + owner)const loadLocalFile = (file: File) => {const url = URL.createObjectURL(file);wavesurfer.load(url);};```### 3. File System Access API Implementation```typescriptconst openFilePicker = async (): Promise<File | null> => {// Feature detectionif ('showOpenFilePicker' in window) {try {const [fileHandle] = await window.showOpenFilePicker({types: [{description: 'Audio files',accept: {'audio/*': ['.wav', '.mp3', '.flac', '.ogg', '.m4a']}}],multiple: false});return await fileHandle.getFile();} catch (error) {if (error.name === 'AbortError') return null; // User cancelledthrow error;}} else {// Fallback to input elementreturn new Promise((resolve) => {const input = document.createElement('input');input.type = 'file';input.accept = 'audio/*';input.onchange = (e) => {const file = (e.target as HTMLInputElement).files?.[0];resolve(file || null);};input.click();});}};```### 4. Authentication Integration```typescriptconst { getAccessToken } = useKindeAuth();const loadAuthenticatedAudio = async (fileId: string) => {const token = await getAccessToken();await initWaveSurfer(fileId, token);};```## 🔗 Integration Points### Cluster.tsx & Selection.tsx Updates```typescript// Add click handler to filenameconst handleFileNameClick = (file: File) => {setSelectedFile(file);setIsFileModalOpen(true);};// In render:<TableCellclassName="font-medium whitespace-normal break-words cursor-pointer hover:text-blue-600"onClick={() => handleFileNameClick(file)}>{file.fileName}</TableCell>```### Modal/Dialog System- Use existing UI components (Dialog from shadcn/ui)- Full-screen on mobile, large modal on desktop- Escape key to close, backdrop click to close- Proper focus management for accessibility## 📦 Dependencies & Plugins### New Dependencies Needed```bashnpm install @wavesurfer/spectrogram # Spectrogram plugin```### Import Structure```typescriptimport WaveSurfer from 'wavesurfer.js';import SpectrogramPlugin from '@wavesurfer/spectrogram';```## 🎨 UI/UX Considerations### Layout- **Header**: Filename, file metadata, close button- **Main**: Waveform + spectrogram (stacked vertically)- **Controls**: Play/pause, time display, volume- **Footer**: File info, actions (future: download, annotate)### Responsive Design- Mobile: Stack vertically, smaller spectrogram- Desktop: Larger displays, side-by-side layouts possible- Touch-friendly controls for mobile devices### Loading States- Skeleton loader for waveform area- Progress indicator for file loading- Error boundaries for failed loads### Color Scheme- Match existing app theme- High contrast for waveform visibility- Accessible color combinations for spectrogram## 🚀 Implementation Order1. **Basic File component structure** (modal, props, conditional rendering)2. **Owner detection logic** (compare user IDs)3. **Wavesurfer.js integration** (online files first)4. **File System Access API** (local file picking)5. **Spectrogram plugin setup** (both online and local)6. **Integration with Cluster/Selection** (click handlers)7. **Polish & error handling** (loading states, errors)## 🔮 Future Enhancements- **Annotation tools**: Create selections on waveform- **Upload functionality**: Upload local files to B2- **Keyboard shortcuts**: Spacebar play/pause, arrow keys seek- **Zoom controls**: Zoom in/out on waveform/spectrogram- **Export options**: Download selections, export annotations