lyrics-transcriber 0.34.0__py3-none-any.whl → 0.34.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. lyrics_transcriber/correction/handlers/syllables_match.py +22 -2
  2. lyrics_transcriber/frontend/.gitignore +23 -0
  3. lyrics_transcriber/frontend/README.md +50 -0
  4. lyrics_transcriber/frontend/dist/assets/index-DqFgiUni.js +245 -0
  5. lyrics_transcriber/frontend/dist/index.html +13 -0
  6. lyrics_transcriber/frontend/dist/vite.svg +1 -0
  7. lyrics_transcriber/frontend/eslint.config.js +28 -0
  8. lyrics_transcriber/frontend/index.html +13 -0
  9. lyrics_transcriber/frontend/package-lock.json +4260 -0
  10. lyrics_transcriber/frontend/package.json +37 -0
  11. lyrics_transcriber/frontend/public/vite.svg +1 -0
  12. lyrics_transcriber/frontend/src/App.tsx +192 -0
  13. lyrics_transcriber/frontend/src/api.ts +59 -0
  14. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +155 -0
  15. lyrics_transcriber/frontend/src/components/DebugPanel.tsx +311 -0
  16. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +297 -0
  17. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  18. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +450 -0
  19. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +287 -0
  20. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +157 -0
  21. lyrics_transcriber/frontend/src/components/constants.ts +19 -0
  22. lyrics_transcriber/frontend/src/components/styles.ts +13 -0
  23. lyrics_transcriber/frontend/src/main.tsx +6 -0
  24. lyrics_transcriber/frontend/src/types.ts +158 -0
  25. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  26. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  27. lyrics_transcriber/frontend/tsconfig.json +25 -0
  28. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  29. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  30. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  31. lyrics_transcriber/frontend/vite.config.js +6 -0
  32. lyrics_transcriber/frontend/vite.config.ts +7 -0
  33. lyrics_transcriber/review/server.py +18 -29
  34. {lyrics_transcriber-0.34.0.dist-info → lyrics_transcriber-0.34.2.dist-info}/METADATA +1 -1
  35. {lyrics_transcriber-0.34.0.dist-info → lyrics_transcriber-0.34.2.dist-info}/RECORD +38 -7
  36. {lyrics_transcriber-0.34.0.dist-info → lyrics_transcriber-0.34.2.dist-info}/LICENSE +0 -0
  37. {lyrics_transcriber-0.34.0.dist-info → lyrics_transcriber-0.34.2.dist-info}/WHEEL +0 -0
  38. {lyrics_transcriber-0.34.0.dist-info → lyrics_transcriber-0.34.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,311 @@
1
+ import { Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@mui/material'
2
+ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
3
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
4
+ import { CorrectionData } from '../types'
5
+ import { useMemo, useRef, useState } from 'react'
6
+
7
+ export interface AnchorMatchInfo {
8
+ segment: string
9
+ lastWord: string
10
+ normalizedLastWord: string
11
+ overlappingAnchors: Array<{
12
+ text: string
13
+ range: [number, number]
14
+ words: string[]
15
+ hasMatchingWord: boolean
16
+ }>
17
+ matchingGap: {
18
+ text: string
19
+ position: number
20
+ length: number
21
+ corrections: Array<{
22
+ word: string
23
+ referencePosition: number | undefined
24
+ }>
25
+ followingAnchor: {
26
+ text: string
27
+ position: number | undefined
28
+ } | null
29
+ } | null
30
+ highlightDebug?: Array<{
31
+ wordIndex: number
32
+ refPos: number | undefined
33
+ highlightPos: number | undefined
34
+ anchorLength: number
35
+ isInRange: boolean
36
+ }>
37
+ wordPositionDebug?: {
38
+ anchorWords: string[]
39
+ wordIndex: number
40
+ referencePosition: number
41
+ finalPosition: number
42
+ }
43
+ debugLog?: string[]
44
+ }
45
+
46
+ interface DebugPanelProps {
47
+ data: CorrectionData
48
+ currentSource: 'genius' | 'spotify'
49
+ anchorMatchInfo: AnchorMatchInfo[]
50
+ }
51
+
52
+ export default function DebugPanel({ data, currentSource, anchorMatchInfo }: DebugPanelProps) {
53
+ // Create a ref to hold the content div
54
+ const contentRef = useRef<HTMLDivElement>(null)
55
+ const [expanded, setExpanded] = useState(false)
56
+
57
+ // Calculate newline positions for reference text using useMemo
58
+ const { newlineInfo, newlineIndices } = useMemo(() => {
59
+ const newlineInfo = new Map<number, string>()
60
+ const newlineIndices = new Set(
61
+ data.corrected_segments.slice(0, -1).map((segment, segmentIndex) => {
62
+ const segmentWords = segment.text.trim().split(/\s+/)
63
+ const lastWord = segmentWords[segmentWords.length - 1]
64
+
65
+ const matchingScoredAnchor = data.anchor_sequences.find(anchor => {
66
+ const transcriptionStart = anchor.transcription_position
67
+ const transcriptionEnd = transcriptionStart + anchor.length - 1
68
+ const lastWordPosition = data.corrected_segments
69
+ .slice(0, segmentIndex)
70
+ .reduce((acc, s) => acc + s.text.trim().split(/\s+/).length, 0) + segmentWords.length - 1
71
+
72
+ return lastWordPosition >= transcriptionStart && lastWordPosition <= transcriptionEnd
73
+ })
74
+
75
+ if (!matchingScoredAnchor) {
76
+ console.warn(`Could not find anchor for segment end: "${segment.text.trim()}"`)
77
+ return null
78
+ }
79
+
80
+ const refPosition = matchingScoredAnchor.reference_positions[currentSource]
81
+ if (refPosition === undefined) return null
82
+
83
+ const wordOffsetInAnchor = matchingScoredAnchor.words.indexOf(lastWord)
84
+ const finalPosition = refPosition + wordOffsetInAnchor
85
+
86
+ newlineInfo.set(finalPosition, segment.text.trim())
87
+ return finalPosition
88
+ }).filter((pos): pos is number => pos !== null)
89
+ )
90
+ return { newlineInfo, newlineIndices }
91
+ }, [data.corrected_segments, data.anchor_sequences, currentSource])
92
+
93
+ // Memoize the first 5 segments data
94
+ const firstFiveSegmentsData = useMemo(() =>
95
+ data.corrected_segments.slice(0, 5).map((segment, i) => {
96
+ const segmentWords = segment.text.trim().split(/\s+/)
97
+ const previousWords = data.corrected_segments
98
+ .slice(0, i)
99
+ .reduce((acc, s) => acc + s.text.trim().split(/\s+/).length, 0)
100
+ const lastWordPosition = previousWords + segmentWords.length - 1
101
+
102
+ const matchingScoredAnchor = data.anchor_sequences.find(anchor => {
103
+ const start = anchor.transcription_position
104
+ const end = start + anchor.length
105
+ return lastWordPosition >= start && lastWordPosition < end
106
+ })
107
+
108
+ return {
109
+ segment,
110
+ segmentWords,
111
+ previousWords,
112
+ lastWordPosition,
113
+ matchingAnchor: matchingScoredAnchor
114
+ }
115
+ }), [data.corrected_segments, data.anchor_sequences])
116
+
117
+ // Memoize relevant anchors
118
+ const relevantAnchors = useMemo(() =>
119
+ data.anchor_sequences
120
+ .filter(anchor => anchor.transcription_position < 50),
121
+ [data.anchor_sequences]
122
+ )
123
+
124
+ // Memoize relevant gaps
125
+ const relevantGaps = useMemo(() =>
126
+ data.gap_sequences.filter(g => g.transcription_position < 50),
127
+ [data.gap_sequences]
128
+ )
129
+
130
+ const handleCopy = (e: React.MouseEvent) => {
131
+ e.stopPropagation() // Prevent accordion from toggling
132
+
133
+ // Temporarily expand to get content
134
+ setExpanded(true)
135
+
136
+ // Use setTimeout to allow the content to render
137
+ setTimeout(() => {
138
+ if (contentRef.current) {
139
+ const debugText = contentRef.current.innerText
140
+ navigator.clipboard.writeText(debugText)
141
+
142
+ // Restore previous state if it was collapsed
143
+ setExpanded(false)
144
+ }
145
+ }, 100)
146
+ }
147
+
148
+ return (
149
+ <Box sx={{ mb: 3 }}>
150
+ <Accordion
151
+ expanded={expanded}
152
+ onChange={(_, isExpanded) => setExpanded(isExpanded)}
153
+ >
154
+ <AccordionSummary
155
+ expandIcon={<ExpandMoreIcon />}
156
+ sx={{
157
+ '& .MuiAccordionSummary-content': {
158
+ display: 'flex',
159
+ justifyContent: 'space-between',
160
+ alignItems: 'center',
161
+ width: '100%'
162
+ }
163
+ }}
164
+ >
165
+ <Typography>Debug Information</Typography>
166
+ <Box
167
+ onClick={(e) => e.stopPropagation()} // Prevent accordion toggle
168
+ sx={{ display: 'flex', alignItems: 'center', mr: 2 }}
169
+ >
170
+ <Typography
171
+ component="span"
172
+ variant="body2"
173
+ sx={{
174
+ display: 'flex',
175
+ alignItems: 'center',
176
+ cursor: 'pointer',
177
+ '&:hover': { opacity: 0.7 }
178
+ }}
179
+ onClick={handleCopy}
180
+ >
181
+ <ContentCopyIcon sx={{ mr: 0.5, fontSize: '1rem' }} />
182
+ Copy All
183
+ </Typography>
184
+ </Box>
185
+ </AccordionSummary>
186
+ <AccordionDetails>
187
+ <Box ref={contentRef} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
188
+ {/* Debug Logs */}
189
+ <Box>
190
+ <Typography variant="h6" gutterBottom>Debug Logs (first 5 segments)</Typography>
191
+ <Typography component="pre" sx={{
192
+ fontSize: '0.75rem',
193
+ whiteSpace: 'pre-wrap',
194
+ backgroundColor: '#f5f5f5',
195
+ padding: 2,
196
+ borderRadius: 1
197
+ }}>
198
+ {anchorMatchInfo.slice(0, 5).map((info, i) =>
199
+ `Segment ${i + 1}: "${info.segment}"\n` +
200
+ (info.debugLog ? info.debugLog.map(log => ` ${log}`).join('\n') : ' No debug logs\n')
201
+ ).join('\n')}
202
+ </Typography>
203
+ </Box>
204
+
205
+ {/* First 5 Segments */}
206
+ <Box>
207
+ <Typography variant="h6" gutterBottom>First 5 Segments (with position details)</Typography>
208
+ <Typography component="pre" sx={{ fontSize: '0.75rem', whiteSpace: 'pre-wrap' }}>
209
+ {firstFiveSegmentsData.map(({ segment, segmentWords, previousWords, lastWordPosition, matchingAnchor }, index) => {
210
+ return `Segment ${index + 1}: "${segment.text.trim()}"\n` +
211
+ ` Words: ${segment.words.length} (${segmentWords.length} after trimming)\n` +
212
+ ` Word count before segment: ${previousWords}\n` +
213
+ ` Last word position: ${lastWordPosition}\n` +
214
+ ` Matching anchor: ${matchingAnchor ?
215
+ `"${matchingAnchor.text}"\n Position: ${matchingAnchor.transcription_position}\n` +
216
+ ` Length: ${matchingAnchor.length}\n` +
217
+ ` Reference positions: genius=${matchingAnchor.reference_positions.genius}, spotify=${matchingAnchor.reference_positions.spotify}`
218
+ : 'None'}\n`
219
+ }).join('\n')}
220
+ </Typography>
221
+ </Box>
222
+
223
+ {/* Relevant Anchors */}
224
+ <Box>
225
+ <Typography variant="h6" gutterBottom>Relevant Anchors</Typography>
226
+ <Typography component="pre" sx={{ fontSize: '0.75rem', whiteSpace: 'pre-wrap' }}>
227
+ {relevantAnchors.map((anchor, i) => {
228
+ return `Anchor ${i}: "${anchor.text}"\n` +
229
+ ` Position: ${anchor.transcription_position}\n` +
230
+ ` Length: ${anchor.length}\n` +
231
+ ` Words: ${anchor.words.join(' ')}\n` +
232
+ ` Reference Positions: genius=${anchor.reference_positions.genius}, spotify=${anchor.reference_positions.spotify}\n`
233
+ }).join('\n')}
234
+ </Typography>
235
+ </Box>
236
+
237
+ {/* Relevant Gaps */}
238
+ <Box>
239
+ <Typography variant="h6" gutterBottom>Relevant Gaps</Typography>
240
+ <Typography component="pre" sx={{ fontSize: '0.75rem', whiteSpace: 'pre-wrap' }}>
241
+ {relevantGaps.map((gap, i) => {
242
+ return `Gap ${i}: "${gap.text}"\n` +
243
+ ` Position: ${gap.transcription_position}\n` +
244
+ ` Length: ${gap.length}\n` +
245
+ ` Words: ${gap.words.join(' ')}\n` +
246
+ ` Corrections: ${gap.corrections.length}\n`
247
+ }).join('\n')}
248
+ </Typography>
249
+ </Box>
250
+
251
+ {/* First 5 Newlines */}
252
+ <Box>
253
+ <Typography variant="h6" gutterBottom>First 5 Newlines (with detailed anchor matching)</Typography>
254
+ <Typography component="pre" sx={{ fontSize: '0.75rem', whiteSpace: 'pre-wrap' }}>
255
+ {Array.from(newlineIndices).sort((a, b) => a - b).slice(0, 5).map(pos => {
256
+ const matchingAnchor = data.anchor_sequences.find(anchor => {
257
+ const start = anchor.reference_positions[currentSource]
258
+ const end = start + anchor.length
259
+ return pos >= start && pos < end
260
+ })
261
+
262
+ const matchingSegment = data.corrected_segments.find(segment =>
263
+ newlineInfo.get(pos) === segment.text.trim()
264
+ )
265
+
266
+ const segmentIndex = matchingSegment ? data.corrected_segments.indexOf(matchingSegment) : -1
267
+ const lastWord = matchingSegment?.text.trim().split(/\s+/).pop()
268
+ const wordIndex = matchingAnchor?.words.indexOf(lastWord ?? '') ?? -1
269
+ const expectedPosition = matchingAnchor && wordIndex !== -1 ?
270
+ matchingAnchor.reference_positions[currentSource] + wordIndex :
271
+ 'Unknown'
272
+
273
+ return `Position ${pos}: "${newlineInfo.get(pos)}"\n` +
274
+ ` In Anchor: ${matchingAnchor ? `"${matchingAnchor.text}"` : 'None'}\n` +
275
+ ` Anchor Position: ${matchingAnchor?.reference_positions[currentSource]}\n` +
276
+ ` Matching Segment Index: ${segmentIndex}\n` +
277
+ ` Expected Position in Reference: ${expectedPosition}\n`
278
+ }).join('\n')}
279
+ </Typography>
280
+ </Box>
281
+
282
+ {/* Anchor Matching Debug section */}
283
+ <Box>
284
+ <Typography variant="h6" gutterBottom>Anchor Matching Debug (first 5 segments)</Typography>
285
+ <Typography component="pre" sx={{ fontSize: '0.75rem', whiteSpace: 'pre-wrap' }}>
286
+ {anchorMatchInfo.slice(0, 5).map((info, i) => `
287
+ Segment ${i}: "${info.segment}"
288
+ Last word: "${info.lastWord}" (normalized: "${info.normalizedLastWord}")
289
+ Debug Log:
290
+ ${info.debugLog ? info.debugLog.map(log => ` ${log}`).join('\n') : ' none'}
291
+ Overlapping anchors:
292
+ ${info.overlappingAnchors.map(anchor => ` "${anchor.text}"
293
+ Range: ${anchor.range[0]}-${anchor.range[1]}
294
+ Words: ${anchor.words.join(', ')}
295
+ Has matching word: ${anchor.hasMatchingWord}
296
+ `).join('\n')}
297
+ Word Position Debug: ${info.wordPositionDebug ?
298
+ `\n Anchor words: ${info.wordPositionDebug.anchorWords.join(', ')}
299
+ Word index in anchor: ${info.wordPositionDebug.wordIndex}
300
+ Reference position: ${info.wordPositionDebug.referencePosition}
301
+ Final position: ${info.wordPositionDebug.finalPosition}`
302
+ : 'none'}
303
+ `).join('\n')}
304
+ </Typography>
305
+ </Box>
306
+ </Box>
307
+ </AccordionDetails>
308
+ </Accordion>
309
+ </Box>
310
+ )
311
+ }
@@ -0,0 +1,297 @@
1
+ import {
2
+ Dialog,
3
+ DialogTitle,
4
+ DialogContent,
5
+ IconButton,
6
+ Grid,
7
+ Typography,
8
+ Box,
9
+ TextField,
10
+ Button,
11
+ } from '@mui/material'
12
+ import CloseIcon from '@mui/icons-material/Close'
13
+ import { ModalContent } from './LyricsAnalyzer'
14
+ import { WordCorrection } from '../types'
15
+ import { useState, useEffect } from 'react'
16
+
17
+ interface DetailsModalProps {
18
+ open: boolean
19
+ content: ModalContent | null
20
+ onClose: () => void
21
+ onUpdateCorrection?: (position: number, updatedWords: string[]) => void
22
+ isReadOnly?: boolean
23
+ }
24
+
25
+ export default function DetailsModal({
26
+ open,
27
+ content,
28
+ onClose,
29
+ onUpdateCorrection,
30
+ isReadOnly = true
31
+ }: DetailsModalProps) {
32
+ const [editedWord, setEditedWord] = useState('')
33
+ const [isEditing, setIsEditing] = useState(false)
34
+
35
+ useEffect(() => {
36
+ // Reset editing state when modal content changes
37
+ if (content?.type === 'gap') {
38
+ setEditedWord(content.data.word)
39
+ setIsEditing(false)
40
+ }
41
+ }, [content])
42
+
43
+ if (!content) return null
44
+
45
+ const handleStartEdit = () => {
46
+ console.group('DetailsModal Edit Debug')
47
+ console.log('Starting edit for content:', JSON.stringify(content, null, 2))
48
+ if (content.type === 'gap') {
49
+ console.log('Setting edited word:', content.data.word)
50
+ setEditedWord(content.data.word)
51
+ }
52
+ console.groupEnd()
53
+ setIsEditing(true)
54
+ }
55
+
56
+ const handleSaveEdit = () => {
57
+ console.group('DetailsModal Save Debug')
58
+ console.log('Current content:', JSON.stringify(content, null, 2))
59
+ console.log('Edited word:', editedWord)
60
+
61
+ if (content?.type === 'gap' && onUpdateCorrection) {
62
+ // Use the editedWord state instead of the original word
63
+ console.log('Saving edit with new word:', editedWord)
64
+ onUpdateCorrection(
65
+ content.data.position,
66
+ [editedWord] // Use the edited word here
67
+ )
68
+ }
69
+ console.groupEnd()
70
+ onClose()
71
+ }
72
+
73
+ const handleCancelEdit = () => {
74
+ if (content.type === 'gap') {
75
+ setEditedWord(content.data.word)
76
+ setIsEditing(false)
77
+ }
78
+ }
79
+
80
+ const handleWordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
81
+ console.log('Word changed to:', event.target.value)
82
+ setEditedWord(event.target.value)
83
+ }
84
+
85
+ const renderContent = () => {
86
+ switch (content.type) {
87
+ case 'anchor':
88
+ return (
89
+ <Grid container spacing={2}>
90
+ <GridItem title="Text" value={`"${content.data.text}"`} />
91
+ <GridItem title="Words" value={content.data.words.join(' ')} />
92
+ <GridItem title="Position" value={content.data.position} />
93
+ <GridItem
94
+ title="Reference Positions"
95
+ value={
96
+ <Box component="pre" sx={{ margin: 0, fontSize: '0.875rem' }}>
97
+ {JSON.stringify(content.data.reference_positions, null, 2)}
98
+ </Box>
99
+ }
100
+ />
101
+ <GridItem
102
+ title="Confidence"
103
+ value={`${(content.data.confidence * 100).toFixed(2)}%`}
104
+ />
105
+ <GridItem title="Length" value={`${content.data.length} words`} />
106
+ {content.data.phrase_score && (
107
+ <>
108
+ <GridItem title="Phrase Type" value={content.data.phrase_score.phrase_type} />
109
+ <GridItem
110
+ title="Scores"
111
+ value={
112
+ <Box sx={{ pl: 2 }}>
113
+ <Typography>
114
+ Total: {content.data?.total_score?.toFixed(2) ?? 'N/A'}
115
+ </Typography>
116
+ <Typography>
117
+ Natural Break: {content.data?.phrase_score?.natural_break_score?.toFixed(2) ?? 'N/A'}
118
+ </Typography>
119
+ <Typography>
120
+ Length: {content.data.phrase_score.length_score.toFixed(2)}
121
+ </Typography>
122
+ <Typography>
123
+ Phrase: {content.data.phrase_score.total_score.toFixed(2)}
124
+ </Typography>
125
+ </Box>
126
+ }
127
+ />
128
+ </>
129
+ )}
130
+ </Grid>
131
+ )
132
+
133
+ case 'gap':
134
+ return (
135
+ <Grid container spacing={2}>
136
+ <GridItem
137
+ title="Transcribed Text"
138
+ value={`"${content.data.text}"`}
139
+ />
140
+ <GridItem
141
+ title="Current Text"
142
+ value={
143
+ isEditing ? (
144
+ <Box>
145
+ <TextField
146
+ value={editedWord}
147
+ onChange={handleWordChange}
148
+ fullWidth
149
+ label="Edit word"
150
+ variant="outlined"
151
+ size="small"
152
+ />
153
+ <Box sx={{ display: 'flex', gap: 1 }}>
154
+ <Button
155
+ variant="contained"
156
+ onClick={handleSaveEdit}
157
+ >
158
+ Save Changes
159
+ </Button>
160
+ <Button
161
+ variant="outlined"
162
+ onClick={handleCancelEdit}
163
+ >
164
+ Cancel
165
+ </Button>
166
+ </Box>
167
+ </Box>
168
+ ) : (
169
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
170
+ <Typography>
171
+ "{content.data.words.map(word => {
172
+ const correction = content.data.corrections.find(
173
+ c => c.original_word === word
174
+ );
175
+ return correction ? correction.corrected_word : word;
176
+ }).join(' ')}"
177
+ </Typography>
178
+ {!isReadOnly && (
179
+ <Button
180
+ variant="outlined"
181
+ size="small"
182
+ onClick={handleStartEdit}
183
+ >
184
+ Edit
185
+ </Button>
186
+ )}
187
+ </Box>
188
+ )
189
+ }
190
+ />
191
+ <GridItem title="Position" value={content.data.position} />
192
+ <GridItem title="Length" value={`${content.data.length} words`} />
193
+ {content.data.corrections.length > 0 && (
194
+ <GridItem
195
+ title="Corrections"
196
+ value={
197
+ <Box sx={{ pl: 2 }}>
198
+ {content.data.corrections.map((correction: WordCorrection, i: number) => (
199
+ <Box key={i} sx={{ mb: 2 }}>
200
+ <Typography>
201
+ "{correction.original_word}" → "{correction.corrected_word}"
202
+ </Typography>
203
+ <Typography>
204
+ Confidence: {(correction.confidence * 100).toFixed(2)}%
205
+ </Typography>
206
+ <Typography>Source: {correction.source}</Typography>
207
+ <Typography>Reason: {correction.reason}</Typography>
208
+ {Object.keys(correction.alternatives).length > 0 && (
209
+ <Typography component="pre" sx={{ fontSize: '0.875rem' }}>
210
+ Alternatives: {JSON.stringify(correction.alternatives, null, 2)}
211
+ </Typography>
212
+ )}
213
+ </Box>
214
+ ))}
215
+ </Box>
216
+ }
217
+ />
218
+ )}
219
+ <GridItem
220
+ title="Reference Words"
221
+ value={
222
+ <Box component="pre" sx={{ margin: 0, fontSize: '0.875rem' }}>
223
+ {JSON.stringify(content.data.reference_words, null, 2)}
224
+ </Box>
225
+ }
226
+ />
227
+ {content.data.preceding_anchor && (
228
+ <GridItem
229
+ title="Preceding Anchor"
230
+ value={`"${content.data.preceding_anchor.text}"`}
231
+ />
232
+ )}
233
+ {content.data.following_anchor && (
234
+ <GridItem
235
+ title="Following Anchor"
236
+ value={`"${content.data.following_anchor.text}"`}
237
+ />
238
+ )}
239
+ </Grid>
240
+ )
241
+
242
+ default:
243
+ return null
244
+ }
245
+ }
246
+
247
+ return (
248
+ <Dialog
249
+ open={open}
250
+ onClose={onClose}
251
+ maxWidth="sm"
252
+ fullWidth
253
+ PaperProps={{
254
+ sx: { position: 'relative' },
255
+ }}
256
+ >
257
+ <IconButton
258
+ onClick={onClose}
259
+ sx={{
260
+ position: 'absolute',
261
+ right: 8,
262
+ top: 8,
263
+ }}
264
+ >
265
+ <CloseIcon />
266
+ </IconButton>
267
+ <DialogTitle>
268
+ {content.type.charAt(0).toUpperCase() + content.type.slice(1)} Details
269
+ </DialogTitle>
270
+ <DialogContent dividers>{renderContent()}</DialogContent>
271
+ </Dialog>
272
+ )
273
+ }
274
+
275
+ interface GridItemProps {
276
+ title: string
277
+ value: string | number | React.ReactNode
278
+ }
279
+
280
+ function GridItem({ title, value }: GridItemProps) {
281
+ return (
282
+ <>
283
+ <Grid item xs={4}>
284
+ <Typography variant="subtitle1" fontWeight="bold">
285
+ {title}
286
+ </Typography>
287
+ </Grid>
288
+ <Grid item xs={8}>
289
+ {typeof value === 'string' || typeof value === 'number' ? (
290
+ <Typography>{value}</Typography>
291
+ ) : (
292
+ value
293
+ )}
294
+ </Grid>
295
+ </>
296
+ )
297
+ }
@@ -0,0 +1,77 @@
1
+ import { ChangeEvent, DragEvent, useState } from 'react'
2
+ import { Paper, Typography } from '@mui/material'
3
+ import CloudUploadIcon from '@mui/icons-material/CloudUpload'
4
+ import { LyricsData } from '../types'
5
+
6
+ interface FileUploadProps {
7
+ onUpload: (data: LyricsData) => void
8
+ }
9
+
10
+ export default function FileUpload({ onUpload }: FileUploadProps) {
11
+ const [isDragging, setIsDragging] = useState(false)
12
+
13
+ const handleDragOver = (e: DragEvent) => {
14
+ e.preventDefault()
15
+ setIsDragging(true)
16
+ }
17
+
18
+ const handleDragLeave = () => {
19
+ setIsDragging(false)
20
+ }
21
+
22
+ const handleDrop = async (e: DragEvent) => {
23
+ e.preventDefault()
24
+ setIsDragging(false)
25
+ const file = e.dataTransfer.files[0]
26
+ await processFile(file)
27
+ }
28
+
29
+ const handleFileInput = async (e: ChangeEvent<HTMLInputElement>) => {
30
+ if (e.target.files?.length) {
31
+ await processFile(e.target.files[0])
32
+ }
33
+ }
34
+
35
+ const processFile = async (file: File) => {
36
+ try {
37
+ const text = await file.text()
38
+ const data = JSON.parse(text)
39
+ onUpload(data)
40
+ } catch (error) {
41
+ console.error('Error processing file:', error)
42
+ // TODO: Add error handling UI
43
+ }
44
+ }
45
+
46
+ return (
47
+ <Paper
48
+ sx={{
49
+ p: 4,
50
+ display: 'flex',
51
+ flexDirection: 'column',
52
+ alignItems: 'center',
53
+ backgroundColor: isDragging ? 'action.hover' : 'background.paper',
54
+ cursor: 'pointer',
55
+ }}
56
+ onDragOver={handleDragOver}
57
+ onDragLeave={handleDragLeave}
58
+ onDrop={handleDrop}
59
+ onClick={() => document.getElementById('file-input')?.click()}
60
+ >
61
+ <input
62
+ type="file"
63
+ id="file-input"
64
+ style={{ display: 'none' }}
65
+ accept="application/json"
66
+ onChange={handleFileInput}
67
+ />
68
+ <CloudUploadIcon sx={{ fontSize: 48, mb: 2 }} />
69
+ <Typography variant="h6" gutterBottom>
70
+ Upload Lyrics Correction Review JSON
71
+ </Typography>
72
+ <Typography variant="body2" color="text.secondary">
73
+ Drag and drop a file here, or click to select
74
+ </Typography>
75
+ </Paper>
76
+ )
77
+ }