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 parameters
const datasetId = c.req.param("id");
if (!datasetId) {
return c.json({
error: "Missing dataset ID in URL"
}, 400);
}
// Connect to the database
const db = createDatabase(c.env);
// First, get the user's role
const 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 dataset
const hasPermission = await checkUserPermission(db, userId, datasetId, 'READ');
if (!hasPermission) {
return c.json({
error: "Dataset not found or access denied"
}, 404);
}
// Fetch the dataset
const 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 dataset
const permissions = await db
.select({
permission: accessGrant.permission
})
.from(accessGrant)
.where(
sqlExpr`${accessGrant.datasetId} = ${datasetId}
AND ${accessGrant.active} = true
AND (
(${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>
<TableCell
className="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 loading
audioLoading: boolean;
audioError: string | null;
wavesurfer: WaveSurfer | null;
// File picking
localFile: File | null;
filePickerSupported: boolean;
// UI states
isPlaying: 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 owner
const isOwner = useCallback(() => {
console.log('Owner check:', { userId: user?.id, datasetOwnerId, isOwner: user?.id === datasetOwnerId });
return user?.id === datasetOwnerId;
}, [user?.id, datasetOwnerId]);
// Initialize WaveSurfer instance
const 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 plugin
wavesurfer.registerPlugin(
Spectrogram.create({
container: spectrogramRef.current,
height: 256,
})
);
// Event listeners
wavesurfer.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 API
const 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 it
const 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 done
setTimeout(() => 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 file
const 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 use
setTimeout(() => 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 functionality
const openFilePicker = useCallback(async () => {
try {
let selectedFile: File | null = null;
if (state.filePickerSupported && 'showOpenFilePicker' in window) {
// Use File System Access API
interface 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 element
selectedFile = 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 nothing
return;
}
console.error('Error opening file picker:', error);
setState(prev => ({
...prev,
audioError: 'Failed to open file picker'
}));
}
}, [state.filePickerSupported, loadLocalFile]);
// Play/pause toggle
const togglePlayPause = useCallback(() => {
if (state.wavesurfer) {
state.wavesurfer.playPause();
}
}, [state.wavesurfer]);
// Format time for display
const 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 opens
useEffect(() => {
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 ready
useEffect(() => {
if (state.wavesurfer && file) {
if (file.upload) {
loadOnlineFile(file.id);
}
}
}, [state.wavesurfer, file, loadOnlineFile]);
// Handle ESC key
useEffect(() => {
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 file
if (!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>
<Button
variant="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">
<Button
variant="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">
<Button
variant="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 owner
const 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>
<TableCell
className="font-medium whitespace-normal break-words cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => handleFileClick(file)}
>
{file.fileName}
</TableCell>
# File Component Architecture Plan
## 🎯 Overview
Create 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
```typescript
interface FileProps {
file: File; // File object with all metadata
isOpen: boolean; // Modal open state
onClose: () => void; // Close modal callback
currentUserId: string; // From Kinde auth context
}
```
### State Management
```typescript
interface FileState {
// Audio loading
audioLoading: boolean;
audioError: string | null;
wavesurfer: WaveSurfer | null;
// File picking
localFile: File | null;
filePickerSupported: boolean;
// UI states
isPlaying: boolean;
currentTime: number;
duration: number;
}
```
## 🔧 Technical Implementation Plan
### 1. Owner Detection Logic
```typescript
const 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 plugin
wavesurfer.registerPlugin(SpectrogramPlugin.create({
container: spectrogramRef.current,
height: 256,
}));
// Load authenticated audio
await 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
```typescript
const openFilePicker = async (): Promise<File | null> => {
// Feature detection
if ('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 cancelled
throw error;
}
} else {
// Fallback to input element
return 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
```typescript
const { 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 filename
const handleFileNameClick = (file: File) => {
setSelectedFile(file);
setIsFileModalOpen(true);
};
// In render:
<TableCell
className="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
```bash
npm install @wavesurfer/spectrogram # Spectrogram plugin
```
### Import Structure
```typescript
import 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 Order
1. **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