Simple frontend added, tests are required
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { Box, Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
|
||||
import { Download, FileDownload, FolderZip } from '@mui/icons-material'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
import { getDownloadUrl, getDownloadZipUrl } from '../api/client'
|
||||
import { formatFileSize } from '../utils/formatters'
|
||||
|
||||
export const DownloadSection: React.FC = () => {
|
||||
const { taskId, tracks, status } = useTaskStore()
|
||||
|
||||
if (status !== 'done' || tracks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDownloadZip = () => {
|
||||
const url = getDownloadZipUrl(taskId!)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const handleDownloadTrack = (filename: string) => {
|
||||
const url = `/api/download/${taskId}/${filename}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
📥 Download Results
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<FolderZip />}
|
||||
onClick={handleDownloadZip}
|
||||
sx={{ mb: 2 }}
|
||||
fullWidth
|
||||
>
|
||||
Download All as ZIP
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Individual Tracks
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
{tracks.map((track, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
secondaryAction={
|
||||
<IconButton edge="end" onClick={() => handleDownloadTrack(track.filename)} size="small">
|
||||
<Download />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={track.filename}
|
||||
secondary={formatFileSize(track.size)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { AppBar, Toolbar, Typography, IconButton, Box, Container, Badge } from '@mui/material'
|
||||
import { Brightness4, Brightness7, FiberManualRecord } from '@mui/icons-material'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const { theme, toggleTheme, wsConnected } = useUIStore()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="static" color="default" elevation={1}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||
🎵 Audio Splitter
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Badge
|
||||
color={wsConnected ? 'success' : 'error'}
|
||||
variant="dot"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<FiberManualRecord
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: wsConnected ? 'green' : 'red',
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{wsConnected ? 'Connected' : 'Disconnected'}
|
||||
</Typography>
|
||||
|
||||
<IconButton onClick={toggleTheme} color="inherit">
|
||||
{theme === 'light' ? <Brightness4 /> : <Brightness7 />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ flex: 1, py: 4 }}>
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
<Box component="footer" sx={{ py: 2, textAlign: 'center', borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Audio Splitter v0.1.0 • Built with ❤️
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from '@mui/material'
|
||||
import { ExpandMore, ExpandLess } from '@mui/icons-material'
|
||||
import { useOptionsStore } from '../stores/optionsStore'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
py: 1,
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const OptionsPanel: React.FC = () => {
|
||||
const { options, setOptions } = useOptionsStore()
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
⚙️ Options
|
||||
</Typography>
|
||||
|
||||
{/* Output Section */}
|
||||
<Section title="Output Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Format"
|
||||
select
|
||||
value={options.format}
|
||||
onChange={(e) => handleChange('format', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="mp3">MP3</MenuItem>
|
||||
<MenuItem value="m4a">M4A</MenuItem>
|
||||
<MenuItem value="mkv">MKV</MenuItem>
|
||||
<MenuItem value="mp4">MP4</MenuItem>
|
||||
<MenuItem value="ogg">OGG</MenuItem>
|
||||
<MenuItem value="opus">OPUS</MenuItem>
|
||||
<MenuItem value="flac">FLAC</MenuItem>
|
||||
<MenuItem value="wav">WAV</MenuItem>
|
||||
<MenuItem value="aac">AAC</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Transcode to"
|
||||
select
|
||||
value={options.transcode_to || ''}
|
||||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||
<MenuItem value="aac">AAC</MenuItem>
|
||||
<MenuItem value="libopus">OPUS</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.drop_video}
|
||||
onChange={(e) => handleChange('drop_video', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Drop video streams"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.drop_subs}
|
||||
onChange={(e) => handleChange('drop_subs', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Drop subtitle streams"
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Filename Section */}
|
||||
<Section title="Filename Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Output template"
|
||||
value={options.output_template}
|
||||
onChange={(e) => handleChange('output_template', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.number_tracks}
|
||||
onChange={(e) => handleChange('number_tracks', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Number tracks (01 - )"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.replace_bad_chars}
|
||||
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Replace bad characters"
|
||||
/>
|
||||
<TextField
|
||||
label="Replacement character"
|
||||
value={options.replacement_char}
|
||||
onChange={(e) => handleChange('replacement_char', e.target.value)}
|
||||
size="small"
|
||||
disabled={!options.replace_bad_chars}
|
||||
/>
|
||||
<TextField
|
||||
label="Bad characters list"
|
||||
value={options.bad_chars}
|
||||
onChange={(e) => handleChange('bad_chars', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={!options.replace_bad_chars}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.skip_existing}
|
||||
onChange={(e) => handleChange('skip_existing', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Skip existing files"
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<Section title="Metadata Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Album"
|
||||
value={options.album}
|
||||
onChange={(e) => handleChange('album', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
label="Comment"
|
||||
value={options.comment}
|
||||
onChange={(e) => handleChange('comment', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.no_comment}
|
||||
onChange={(e) => handleChange('no_comment', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="No comment"
|
||||
/>
|
||||
<TextField
|
||||
label="Comment stream index"
|
||||
type="number"
|
||||
value={options.comment_stream ?? ''}
|
||||
onChange={(e) =>
|
||||
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
|
||||
}
|
||||
size="small"
|
||||
disabled={options.no_comment}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.merge_comments}
|
||||
onChange={(e) => handleChange('merge_comments', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Merge all comments"
|
||||
disabled={options.no_comment}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment separator"
|
||||
value={options.comment_separator}
|
||||
onChange={(e) => handleChange('comment_separator', e.target.value)}
|
||||
size="small"
|
||||
disabled={!options.merge_comments || options.no_comment}
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Box, Paper, Typography, LinearProgress, Alert, Chip } from '@mui/material'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
|
||||
export const ProgressDisplay: React.FC = () => {
|
||||
const { status, progress, message, error, tracks, logs } = useTaskStore()
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'info'
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'done':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Waiting'
|
||||
case 'processing':
|
||||
return 'Processing'
|
||||
case 'done':
|
||||
return 'Complete'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">📊 Progress</Typography>
|
||||
<Chip label={getStatusLabel()} color={getStatusColor()} size="small" />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
color={status === 'error' ? 'error' : status === 'done' ? 'success' : 'primary'}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{progress}% – {message}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tracks.length > 0 && status === 'done' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
✅ {tracks.length} track(s) extracted successfully!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
ref={logContainerRef}
|
||||
sx={{
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
bgcolor: 'background.default',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Waiting for progress updates...
|
||||
</Typography>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useTracklistStore } from '../stores/tracklistStore'
|
||||
import { validateTracklist, parseTracklist } from '../utils/validators'
|
||||
import { TracklistEntry } from '../types'
|
||||
|
||||
export const TracklistEditor: React.FC = () => {
|
||||
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = event.target.value
|
||||
setRawText(text)
|
||||
validate(text)
|
||||
}
|
||||
|
||||
const validate = (text: string) => {
|
||||
const lines = text.split('\n').filter((line) => line.trim() !== '')
|
||||
const parsed = parseTracklist(lines)
|
||||
setEntries(parsed)
|
||||
|
||||
const validation = validateTracklist(parsed)
|
||||
setErrors(validation.errors)
|
||||
setIsValid(validation.isValid)
|
||||
}
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return
|
||||
|
||||
const file = acceptedFiles[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string
|
||||
setRawText(text)
|
||||
validate(text)
|
||||
setIsDragging(false)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
},
|
||||
[setRawText]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/plain': ['.txt'],
|
||||
},
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
// Initial validation on mount
|
||||
useEffect(() => {
|
||||
if (rawText) {
|
||||
validate(rawText)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getLineClassName = (lineIndex: number): string => {
|
||||
const hasError = errors.some((e) => e.line === lineIndex + 1)
|
||||
return hasError ? 'error-line' : ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: isDragActive ? '2px dashed' : '1px solid',
|
||||
borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
Tracklist
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
|
||||
{rawText ? `${rawText.split('\n').filter((l) => l.trim()).length} tracks` : 'No tracks yet'}
|
||||
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
|
||||
</Typography>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* Line numbers column */}
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
maxWidth: 40,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.7,
|
||||
color: 'text.secondary',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{rawText.split('\n').map((_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Editor text area */}
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={10}
|
||||
maxRows={20}
|
||||
value={rawText}
|
||||
onChange={handleTextChange}
|
||||
placeholder={`Enter your tracklist here...\n\nExample:\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.7,
|
||||
},
|
||||
}}
|
||||
error={!isValid && errors.length > 0}
|
||||
helperText={
|
||||
!isValid && errors.length > 0
|
||||
? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
|
||||
: 'Drop a .txt file here or paste your tracklist'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{isDragActive && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Drop your tracklist file (.txt) here
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
|
||||
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
|
||||
import { useUploadStore } from '../stores/uploadStore'
|
||||
import { uploadFile } from '../api/client'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
|
||||
|
||||
export const UploadZone: React.FC = () => {
|
||||
const {
|
||||
file,
|
||||
fileName,
|
||||
fileSize,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
error,
|
||||
setFile,
|
||||
setFileName,
|
||||
setFileSize,
|
||||
setIsUploading,
|
||||
setUploadProgress,
|
||||
setError,
|
||||
setTaskId,
|
||||
} = useUploadStore()
|
||||
|
||||
const { setTaskId: setTaskIdStore, setIsProcessing } = useTaskStore()
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return
|
||||
|
||||
const selectedFile = acceptedFiles[0]
|
||||
const extension = '.' + selectedFile.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
setError(`Unsupported file format. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
setFile(selectedFile)
|
||||
setFileName(selectedFile.name)
|
||||
setFileSize(selectedFile.size)
|
||||
setError(null)
|
||||
setIsUploading(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
const response = await uploadFile(selectedFile)
|
||||
setTaskId(response.task_id)
|
||||
setTaskIdStore(response.task_id)
|
||||
setUploadProgress(100)
|
||||
setIsUploading(false)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
||||
setIsUploading(false)
|
||||
setUploadProgress(0)
|
||||
setFile(null)
|
||||
setFileName('')
|
||||
setFileSize(0)
|
||||
}
|
||||
},
|
||||
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'audio/*': ALLOWED_EXTENSIONS,
|
||||
},
|
||||
multiple: false,
|
||||
disabled: isUploading,
|
||||
})
|
||||
|
||||
const 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`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 4,
|
||||
border: '2px dashed',
|
||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
|
||||
cursor: isUploading ? 'default' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{file ? (
|
||||
<Box>
|
||||
<InsertDriveFile sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h6">{fileName}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatFileSize(fileSize)}
|
||||
</Typography>
|
||||
{isUploading && (
|
||||
<Box sx={{ mt: 2, width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{uploadProgress}% uploaded
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isUploading && (
|
||||
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
|
||||
✅ Uploaded successfully
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
or click to browse
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||
Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user