diff --git a/.gitignore b/.gitignore index 89e0d58..25e0630 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ next-env.d.ts *.db tmp public/datasets -public/downloads \ No newline at end of file +public/downloads +public/calibration \ No newline at end of file diff --git a/src/app/actions/calibration.ts b/src/app/actions/calibration.ts new file mode 100644 index 0000000..0604a49 --- /dev/null +++ b/src/app/actions/calibration.ts @@ -0,0 +1,135 @@ +'use server'; + +import db from '@/lib/db'; +import { experiment_calibration } from '@/lib/model/experiment_calibration'; +import { eq, and } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { requireAdmin } from '@/lib/auth'; +import { unlink } from 'fs/promises'; +import { join } from 'path'; + +export async function getCalibrationEntries(experimentId: number) { + try { + const entries = await db + .select() + .from(experiment_calibration) + .where(eq(experiment_calibration.experimentId, experimentId)); + + return { success: true, data: entries }; + } catch (error) { + console.error('Error fetching calibration entries:', error); + throw new Error('Failed to fetch calibration entries'); + } +} + +export async function deleteCalibrationEntry(calibrationId: number, experimentId: number) { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Get the entry to find the file path + const entries = await db + .select() + .from(experiment_calibration) + .where( + and( + eq(experiment_calibration.id, calibrationId), + eq(experiment_calibration.experimentId, experimentId) + ) + ); + + if (entries.length === 0) { + throw new Error('Calibration entry not found'); + } + + const filePath = entries[0].file; + + // Delete from database + await db + .delete(experiment_calibration) + .where(eq(experiment_calibration.id, calibrationId)); + + // Delete the audio file + try { + const fullPath = join(process.cwd(), 'public', filePath); + await unlink(fullPath); + } catch (err) { + console.warn('Could not delete audio file:', err); + // Don't fail the operation if file deletion fails + } + + revalidatePath(`/admin/experiments/${experimentId}`); + return { success: true }; + } catch (error) { + console.error('Error deleting calibration entry:', error); + throw new Error('Failed to delete calibration entry'); + } +} + +export async function updateCalibrationOrder(experimentId: number, orderMap: Record) { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Update all entries with new order values + const updates = Object.entries(orderMap).map(([calibrationId, newOrder]) => + db + .update(experiment_calibration) + .set({ order: newOrder }) + .where( + and( + eq(experiment_calibration.id, parseInt(calibrationId)), + eq(experiment_calibration.experimentId, experimentId) + ) + ) + ); + + await Promise.all(updates); + revalidatePath(`/admin/experiments/${experimentId}`); + return { success: true }; + } catch (error) { + console.error('Error updating calibration order:', error); + throw new Error('Failed to update calibration order'); + } +} + +export async function deleteAllCalibrationEntries(experimentId: number) { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Get all entries to delete files + const entries = await db + .select() + .from(experiment_calibration) + .where(eq(experiment_calibration.experimentId, experimentId)); + + // Delete audio files + for (const entry of entries) { + try { + const fullPath = join(process.cwd(), 'public', entry.file); + await unlink(fullPath); + } catch (err) { + console.warn('Could not delete audio file:', err); + // Don't fail the operation if file deletion fails + } + } + + // Delete all entries from database + await db + .delete(experiment_calibration) + .where(eq(experiment_calibration.experimentId, experimentId)); + + revalidatePath(`/admin/experiments/${experimentId}`); + return { success: true, deletedCount: entries.length }; + } catch (error) { + console.error('Error deleting all calibration entries:', error); + throw new Error('Failed to delete all calibration entries'); + } +} diff --git a/src/app/actions/upload.ts b/src/app/actions/upload.ts index 47d5003..48676f4 100644 --- a/src/app/actions/upload.ts +++ b/src/app/actions/upload.ts @@ -2,6 +2,7 @@ import db from '@/lib/db'; import { dataset_entry } from '@/lib/model/dataset_entry'; +import { experiment_calibration } from '@/lib/model/experiment_calibration'; import { parseCSVLine } from '@/lib/csv-parser'; import { revalidatePath } from 'next/cache'; import { eq } from 'drizzle-orm'; @@ -302,3 +303,122 @@ export async function updateDatasetEntriesMetadata( } } } + +/** + * Process calibration audio files for an experiment + * Expects metadata.csv with columns: id, audio_file, dialect, order + * Audio files should be in a 'wavs' subfolder + */ +export async function processCalibrationEntries( + experimentId: number, + tempDir: string +) { + try { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + return { + success: false, + error: 'Unauthorized', + entriesCreated: 0, + }; + } + + const calibrationDir = join(process.cwd(), 'public', 'calibration', experimentId.toString()); + + // Validate temp directory exists + if (!existsSync(tempDir)) { + return { + success: false, + error: 'Temporary extraction directory not found', + entriesCreated: 0, + }; + } + + // Parse metadata from directory + const { rows } = await parseMetadataFromDirectory(tempDir); + + const entries: typeof experiment_calibration.$inferInsert[] = []; + + // Create calibration directory + await mkdir(calibrationDir, { recursive: true }); + + // Process each row + for (let lineIndex = 0; lineIndex < rows.length; lineIndex++) { + const row = rows[lineIndex]; + const audioFile = row.audio_file as string; + // Audio files are in wavs subfolder + const audioPath = join(tempDir, audioFile); + + if (existsSync(audioPath)) { + // Copy audio file to calibration directory, preserving filename from audio_file + const audioBuffer = await readFile(audioPath); + const filename = audioFile.split('/').pop() || audioFile; + const destPath = join(calibrationDir, filename); + + await writeFile(destPath, audioBuffer); + + entries.push({ + experimentId, + dialectLabel: row.dialect, + order: lineIndex + 1, + file: `/calibration/${experimentId}/${filename}`, + }); + } + } + + if (entries.length === 0) { + return { + success: false, + error: 'No valid audio files found in wavs folder', + entriesCreated: 0, + }; + } + + // Check for existing calibration entries to avoid duplicates + const existingEntries = await db + .select({ file: experiment_calibration.file }) + .from(experiment_calibration) + .where(eq(experiment_calibration.experimentId, experimentId)); + + const existingFiles = new Set(existingEntries.map(e => e.file)); + + // Filter out duplicates + const newEntries = entries.filter(entry => !existingFiles.has(entry.file)); + + if (newEntries.length === 0) { + return { + success: false, + error: 'All calibration entries already exist for this experiment', + entriesCreated: 0, + }; + } + + // Insert new entries into database + await db.insert(experiment_calibration).values(newEntries); + + revalidatePath(`/admin/experiments/${experimentId}`); + + return { + success: true, + entriesCreated: newEntries.length, + message: `Successfully processed and inserted ${newEntries.length} calibration entries.`, + }; + } catch (error) { + console.error('Error processing calibration entries:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to process calibration entries'; + return { + success: false, + error: errorMessage, + entriesCreated: 0, + }; + } finally { + // Clean up temp directory + try { + if (existsSync(tempDir)) { + await rm(tempDir, { recursive: true, force: true }); + } + } catch (cleanupError) { + console.error('Error cleaning up temp directory:', cleanupError); + } + } +} diff --git a/src/app/admin/experiments/[id]/page.tsx b/src/app/admin/experiments/[id]/page.tsx index 3f8e294..b3f8304 100644 --- a/src/app/admin/experiments/[id]/page.tsx +++ b/src/app/admin/experiments/[id]/page.tsx @@ -4,6 +4,8 @@ import { experiment } from '@/lib/model/experiment'; import { eq } from 'drizzle-orm'; import EditableExperimentHeader from '@/components/EditableExperimentHeader'; import DeleteExperimentButton from '@/components/DeleteExperimentButton'; +import UploadCalibrationModal from '@/components/UploadCalibrationModal'; +import CalibrationListModal from '@/components/CalibrationListModal'; import { requireAdmin } from '@/lib/auth'; import { redirect } from 'next/navigation'; @@ -66,6 +68,8 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) {
+ +
diff --git a/src/app/api/upload-calibration/route.ts b/src/app/api/upload-calibration/route.ts new file mode 100644 index 0000000..5816f6a --- /dev/null +++ b/src/app/api/upload-calibration/route.ts @@ -0,0 +1,156 @@ +import { writeFile, mkdir, readFile, rm } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import extract from 'extract-zip'; +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth'; + +function parseCSVLine(line: string): string[] { + const values: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuotes && nextChar === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + values.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + values.push(current.trim()); + return values; +} + +export async function POST(request: NextRequest) { + try { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const experimentId = formData.get('experimentId') as string; + + if (!file) { + return NextResponse.json( + { error: 'No file provided' }, + { status: 400 } + ); + } + + if (!file.name.endsWith('.zip')) { + return NextResponse.json( + { error: 'File must be a ZIP archive' }, + { status: 400 } + ); + } + + if (!experimentId) { + return NextResponse.json( + { error: 'Experiment ID is required' }, + { status: 400 } + ); + } + + const tempDir = join(process.cwd(), 'tmp', `calibration-upload-${Date.now()}`); + + try { + // Create temp directory + await mkdir(tempDir, { recursive: true }); + + // Save uploaded file to temp location + const tempZipPath = join(tempDir, file.name); + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await writeFile(tempZipPath, buffer); + + // Extract ZIP file + await extract(tempZipPath, { dir: tempDir }); + + // Read and validate metadata.csv + const metadataPath = join(tempDir, 'metadata.csv'); + if (!existsSync(metadataPath)) { + throw new Error('metadata.csv not found in ZIP'); + } + + const metadataContent = await readFile(metadataPath, 'utf-8'); + const lines = metadataContent.split('\n').filter(line => line.trim()); + + if (lines.length < 2) { + throw new Error('metadata.csv is empty or invalid'); + } + + // Parse CSV header + const headers = lines[0].split(',').map(h => h.trim()); + + // Validate required columns + const requiredColumns = ['audio_file', 'dialect']; + for (const col of requiredColumns) { + if (!headers.includes(col)) { + throw new Error(`Missing required column: ${col}`); + } + } + + // Validate that wavs folder exists + const wavsDir = join(tempDir, 'wavs'); + if (!existsSync(wavsDir)) { + throw new Error('wavs directory not found in ZIP'); + } + + // Parse and count rows + let validRowCount = 0; + for (let i = 1; i < lines.length; i++) { + const values = parseCSVLine(lines[i]); + if (values.length === headers.length) { + validRowCount++; + } + } + + if (validRowCount === 0) { + throw new Error('No valid entries found in metadata.csv'); + } + + return NextResponse.json({ + success: true, + tempDir, + metadataCount: validRowCount, + message: `Successfully extracted ${validRowCount} calibration entries. Ready for processing.`, + }); + } catch (error) { + // Clean up on error + try { + if (existsSync(tempDir)) { + await rm(tempDir, { recursive: true, force: true }); + } + } catch (cleanupError) { + console.error('Error cleaning up temp directory:', cleanupError); + } + + throw error; + } + } catch (error) { + console.error('Error uploading calibration ZIP:', error); + const errorMsg = + error instanceof Error ? error.message : 'Failed to upload calibration ZIP'; + return NextResponse.json( + { error: errorMsg }, + { status: 500 } + ); + } +} diff --git a/src/components/CalibrationListModal.tsx b/src/components/CalibrationListModal.tsx new file mode 100644 index 0000000..c3c3f83 --- /dev/null +++ b/src/components/CalibrationListModal.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { getCalibrationEntries, deleteCalibrationEntry, updateCalibrationOrder, deleteAllCalibrationEntries } from '@/app/actions/calibration'; +import Modal from '@/components/Modal'; +import { ExperimentCalibration } from '@/lib/model/experiment_calibration'; +import { useAudio } from './AudioProvider'; + +interface CalibrationListModalProps { + experimentId: number; +} + +export default function CalibrationListModal({ experimentId }: CalibrationListModalProps) { + const [isOpen, setIsOpen] = useState(false); + const [calibrationItems, setCalibrationItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [draggedItem, setDraggedItem] = useState(null); + const { currentAudioId, setCurrentAudio } = useAudio(); + const audioRef = useRef(null); + + useEffect(() => { + if (isOpen) { + loadCalibrationItems(); + } + }, [isOpen]); + + async function loadCalibrationItems() { + setLoading(true); + setError(null); + try { + const result = await getCalibrationEntries(experimentId); + if (result.success) { + const sorted = [...result.data].sort((a, b) => a.order - b.order); + setCalibrationItems(sorted); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load calibration items'); + } finally { + setLoading(false); + } + } + + async function handleDelete(calibrationId: number) { + if (!confirm('Are you sure you want to delete this calibration item?')) { + return; + } + + try { + await deleteCalibrationEntry(calibrationId, experimentId); + setCalibrationItems(items => items.filter(item => item.id !== calibrationId)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete item'); + } + } + + async function handleDeleteAll() { + if (!confirm('Are you sure you want to delete ALL calibration items? This cannot be undone.')) { + return; + } + + try { + await deleteAllCalibrationEntries(experimentId); + setCalibrationItems([]); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + setCurrentAudio(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete all items'); + } + } + + async function handleMoveUp(index: number) { + if (index === 0) return; + + const reorderedItems = [...calibrationItems]; + const temp = reorderedItems[index]; + reorderedItems[index] = reorderedItems[index - 1]; + reorderedItems[index - 1] = temp; + setCalibrationItems(reorderedItems); + + // Prepare order map + const orderMap: Record = {}; + reorderedItems.forEach((item, idx) => { + orderMap[item.id] = idx; + }); + + try { + await updateCalibrationOrder(experimentId, orderMap); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update order'); + // Revert on error + await loadCalibrationItems(); + } + } + + async function handleMoveDown(index: number) { + if (index === calibrationItems.length - 1) return; + + const reorderedItems = [...calibrationItems]; + const temp = reorderedItems[index]; + reorderedItems[index] = reorderedItems[index + 1]; + reorderedItems[index + 1] = temp; + setCalibrationItems(reorderedItems); + + // Prepare order map + const orderMap: Record = {}; + reorderedItems.forEach((item, idx) => { + orderMap[item.id] = idx; + }); + + try { + await updateCalibrationOrder(experimentId, orderMap); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update order'); + // Revert on error + await loadCalibrationItems(); + } + } + + function handlePlayAudio(calibrationId: number, filePath: string) { + const audioId = `calibration-${calibrationId}`; + + if (currentAudioId === audioId && audioRef.current && !audioRef.current.paused) { + // Stop playing + audioRef.current.pause(); + audioRef.current.currentTime = 0; + setCurrentAudio(null); + return; + } + + // Stop any currently playing audio + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + + // Play new audio + if (audioRef.current) { + const fullPath = `/public${filePath}`; + audioRef.current.src = fullPath; + setCurrentAudio(audioId); + audioRef.current.play().catch(err => console.error('Failed to play audio:', err)); + } + } + + function handleDragStart(index: number) { + setDraggedItem(index); + } + + function handleDragOver(e: React.DragEvent, index: number) { + e.preventDefault(); + if (draggedItem === null || draggedItem === index) return; + + const reorderedItems = [...calibrationItems]; + const draggedItemContent = reorderedItems[draggedItem]; + reorderedItems.splice(draggedItem, 1); + reorderedItems.splice(index, 0, draggedItemContent); + setCalibrationItems(reorderedItems); + setDraggedItem(index); + } + + async function handleDragEnd() { + setDraggedItem(null); + + // Update order in database + const orderMap: Record = {}; + calibrationItems.forEach((item, idx) => { + orderMap[item.id] = idx; + }); + + try { + await updateCalibrationOrder(experimentId, orderMap); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update order'); + // Revert on error + await loadCalibrationItems(); + } + } + + return ( + <> + + + { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + setCurrentAudio(null); + setIsOpen(false); + }} + title="Calibration Items" + actions={[ + { + label: 'Close', + onClick: () => setIsOpen(false), + variant: 'secondary', + }, + ]} + > + + + ); +} diff --git a/src/components/UploadCalibrationModal.tsx b/src/components/UploadCalibrationModal.tsx new file mode 100644 index 0000000..7c1aa0e --- /dev/null +++ b/src/components/UploadCalibrationModal.tsx @@ -0,0 +1,368 @@ +'use client'; + +import { useState } from 'react'; +import { processCalibrationEntries } from '@/app/actions/upload'; +import Modal from '@/components/Modal'; + +interface UploadCalibrationModalProps { + experimentId: number; +} + +export default function UploadCalibrationModal({ + experimentId, +}: UploadCalibrationModalProps) { + const [isOpen, setIsOpen] = useState(false); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [uploadQueue, setUploadQueue] = useState>([]); + const [status, setStatus] = useState(''); + + function handleFileChange(e: React.ChangeEvent) { + const selectedFiles = e.target.files ? Array.from(e.target.files) : []; + if (selectedFiles.length > 0) { + setFiles(selectedFiles); + setError(null); + // Initialize queue with pending status and 0 progress + setUploadQueue(selectedFiles.map(file => ({ file, status: 'pending', progress: 0 }))); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (files.length === 0) { + setError('Please select at least one ZIP file'); + return; + } + + setLoading(true); + + try { + // Process each file in the queue sequentially + const newQueue = [...uploadQueue]; + + for (let i = 0; i < newQueue.length; i++) { + const item = newQueue[i]; + item.status = 'uploading'; + item.progress = 0; + setUploadQueue([...newQueue]); + setStatus(`Processing file ${i + 1} of ${newQueue.length}: ${item.file.name}`); + + try { + // Upload with progress tracking + const uploadResult = await uploadFileWithProgress(item.file, i, newQueue); + + // Process the extracted data + const processResult = await processCalibrationEntries(experimentId, uploadResult.tempDir); + + if (!processResult.success) { + item.status = 'failed'; + item.progress = 0; + item.message = `✗ ${processResult.error || 'Processing failed'}`; + } else { + item.status = 'completed'; + item.progress = 100; + item.message = `✓ Processed (${processResult.entriesCreated} entries)`; + } + } catch (err) { + item.status = 'failed'; + item.progress = 0; + item.message = `✗ ${err instanceof Error ? err.message : 'Network error'}`; + } + + setUploadQueue([...newQueue]); + } + + const failedCount = newQueue.filter(item => item.status === 'failed').length; + const completedCount = newQueue.filter(item => item.status === 'completed').length; + + if (failedCount === 0) { + setSuccess(true); + setStatus(`All files processed! (${completedCount}/${newQueue.length} completed)`); + } else if (completedCount > 0) { + setStatus(`Completed with errors: ${completedCount} succeeded, ${failedCount} failed`); + } else { + setError(`All files failed to process`); + } + + setFiles([]); + + if (failedCount === 0) { + setTimeout(() => { + setSuccess(false); + setIsOpen(false); + setStatus(''); + setUploadQueue([]); + window.location.reload(); + }, 2500); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to process uploads'; + setError(errorMsg); + setStatus(''); + } finally { + setLoading(false); + } + } + + function uploadFileWithProgress( + file: File, + index: number, + queue: Array<{ file: File; status: 'pending' | 'uploading' | 'completed' | 'failed'; progress: number; message?: string }> + ): Promise<{ tempDir: string }> { + const MAX_RETRIES = 3; + + return (async () => { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await uploadAttempt(file, index, queue, attempt, MAX_RETRIES); + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + + if (attempt < MAX_RETRIES) { + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, attempt - 1) * 1000; + queue[index].message = `Retrying in ${delay / 1000}s... (attempt ${attempt}/${MAX_RETRIES})`; + setUploadQueue([...queue]); + + await new Promise(resolve => setTimeout(resolve, delay)); + queue[index].progress = 0; // Reset progress for next attempt + setUploadQueue([...queue]); + } + } + } + + throw lastError || new Error('Upload failed after all retries'); + })(); + } + + function uploadAttempt( + file: File, + index: number, + queue: Array<{ file: File; status: 'pending' | 'uploading' | 'completed' | 'failed'; progress: number; message?: string }>, + attempt: number, + maxRetries: number + ): Promise<{ tempDir: string }> { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('experimentId', experimentId.toString()); + + const xhr = new XMLHttpRequest(); + + // Track upload progress + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); + queue[index].progress = progress; + if (attempt > 1) { + queue[index].message = `Uploading (retry ${attempt}/${maxRetries})`; + } + setUploadQueue([...queue]); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + try { + const result = JSON.parse(xhr.responseText); + if (result.success) { + resolve(result); + } else { + reject(new Error(result.error || 'Upload failed')); + } + } catch (error) { + reject(new Error('Failed to parse response')); + } + } else { + try { + const error = JSON.parse(xhr.responseText); + reject(new Error(error.error || `Upload failed with status ${xhr.status}`)); + } catch { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Network error')); + }); + + xhr.addEventListener('abort', () => { + reject(new Error('Upload aborted')); + }); + + xhr.open('POST', '/api/upload-calibration'); + xhr.send(formData); + }); + } + + function handleClose() { + if (!loading) { + setIsOpen(false); + setFiles([]); + setError(null); + setSuccess(false); + setStatus(''); + setUploadQueue([]); + } + } + + return ( + <> + + + f.status !== 'pending').length}/${uploadQueue.length}...` : 'Upload', + onClick: () => { + const form = document.getElementById( + 'uploadCalibrationForm' + ) as HTMLFormElement; + form?.dispatchEvent(new Event('submit', { bubbles: true })); + }, + variant: 'primary', + disabled: loading || files.length === 0, + }, + ]} + > +
+
+ +
+ + +
+
+ +
+

ZIP file should contain:

+
    +
  • + metadata.csv{' '} + with columns: audio_file, dialect (order is auto-generated by line count) +
  • +
  • + wavs/ subfolder with audio files (.wav) +
  • +
+
+ + {uploadQueue.length > 0 && ( +
+

Upload Queue:

+
+ {uploadQueue.map((item, idx) => ( +
+
+ + {item.status === 'completed' ? '✓' : + item.status === 'failed' ? '✗' : + item.status === 'uploading' ? '⟳' : + '○'} + + {item.file.name} + {item.progress > 0 && item.progress < 100 && ( + {item.progress}% + )} + {item.message && {item.message}} +
+ {item.status === 'uploading' && ( +
+
+
+ )} + {item.status === 'completed' && ( +
+
+
+ )} + {item.status === 'failed' && ( +
+
+
+ )} +
+ ))} +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + {status && ( +
+ {status} +
+ )} + + + + ); +} diff --git a/src/lib/model/index.ts b/src/lib/model/index.ts index 39cbe6a..fca29ae 100644 --- a/src/lib/model/index.ts +++ b/src/lib/model/index.ts @@ -1,3 +1,7 @@ export * from "./dataset"; export * from "./dataset_entry"; -export * from "./auth-schema"; \ No newline at end of file +export * from "./auth-schema"; +export * from "./experiment"; +export * from "./experiment_calibration"; +export * from "./annotation"; +export * from "./participant"; \ No newline at end of file