update dependencies and add downloads page, various fixes
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Box, Button, Paper, Typography, List, ListItem, ListItemText, IconButton, Chip } from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
@@ -18,7 +18,9 @@ function formatLogItem(log: DevLogItem) {
|
||||
export default function DevErrorPanel() {
|
||||
const [logs, setLogs] = useState<DevLogItem[]>(() => getDevLogs())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const errorCount = useMemo(() => logs.filter((item) => item.kind !== 'debug').length, [logs])
|
||||
const previousErrorCountRef = useRef(errorCount)
|
||||
|
||||
const copyLog = async (log: DevLogItem) => {
|
||||
const payload = formatLogItem(log)
|
||||
@@ -51,7 +53,14 @@ export default function DevErrorPanel() {
|
||||
useEffect(() => {
|
||||
return subscribeDevLogs((items) => {
|
||||
setLogs(items)
|
||||
if (items.length > 0) setOpen(true)
|
||||
const nextErrorCount = items.filter((item) => item.kind !== 'debug').length
|
||||
const hasNewError = nextErrorCount > previousErrorCountRef.current
|
||||
previousErrorCountRef.current = nextErrorCount
|
||||
|
||||
if (hasNewError) {
|
||||
setDismissed(false)
|
||||
setOpen(true)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -87,8 +96,24 @@ export default function DevErrorPanel() {
|
||||
|
||||
if (!import.meta.env.DEV) return null
|
||||
|
||||
if (dismissed) {
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', top: 12, right: 12, zIndex: 9999 }}>
|
||||
<Chip
|
||||
label={`Dev Logs${errorCount ? ` (${errorCount})` : ''}`}
|
||||
color={errorCount ? 'error' : 'default'}
|
||||
clickable
|
||||
onClick={() => {
|
||||
setDismissed(false)
|
||||
setOpen(true)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', left: { xs: 12, sm: 'auto' }, right: 12, bottom: 12, zIndex: 9999 }}>
|
||||
<Box sx={{ position: 'fixed', top: 12, right: 12, zIndex: 9999 }}>
|
||||
<Paper elevation={6} sx={{ minWidth: { xs: 0, sm: 320 }, width: { xs: 'calc(100vw - 24px)', sm: 'auto' }, maxWidth: { xs: 'calc(100vw - 24px)', sm: 640 }, p: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
@@ -98,7 +123,7 @@ export default function DevErrorPanel() {
|
||||
</Box>
|
||||
<Box>
|
||||
<Button size="small" onClick={() => { clearDevLogs() }} sx={{ mr: 1 }}>Clear</Button>
|
||||
<IconButton size="small" onClick={() => setOpen((v) => !v)} aria-label="toggle" sx={{ width: 32, height: 32 }}>
|
||||
<IconButton size="small" onClick={() => { setDismissed(true); setOpen(false) }} aria-label="dismiss dev log panel" sx={{ width: 32, height: 32 }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Box, Chip, IconButton, LinearProgress, Paper, Typography } from '@mui/material'
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import ClearAllIcon from '@mui/icons-material/ClearAll'
|
||||
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'
|
||||
|
||||
export type DownloadOverlayItem = {
|
||||
id: string
|
||||
trackKey: string
|
||||
title: string
|
||||
fileName?: string
|
||||
status: 'downloading' | 'completed' | 'cancelled' | 'error'
|
||||
receivedBytes: number
|
||||
totalBytes: number | null
|
||||
saveHref?: string
|
||||
error?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
type DownloadsOverlayProps = {
|
||||
downloads: DownloadOverlayItem[]
|
||||
onCancel: (id: string) => void
|
||||
onSaveAgain: (id: string) => void
|
||||
onDismiss: (id: string) => void
|
||||
onClearFinished: () => void
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null) {
|
||||
if (!value || value <= 0) return null
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function buildProgressText(download: DownloadOverlayItem) {
|
||||
const receivedText = formatBytes(download.receivedBytes)
|
||||
const totalText = formatBytes(download.totalBytes)
|
||||
|
||||
if (download.status === 'downloading') {
|
||||
if (download.note) return download.note
|
||||
if (receivedText && totalText) return `${receivedText} of ${totalText}`
|
||||
if (receivedText) return `${receivedText} received`
|
||||
return 'Preparing download...'
|
||||
}
|
||||
|
||||
if (download.status === 'completed') {
|
||||
const parts = ['Ready to save again']
|
||||
if (totalText || receivedText) parts.push(totalText || receivedText || '')
|
||||
if (download.note) parts.push(download.note)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
if (download.status === 'cancelled') return 'Cancelled'
|
||||
return download.error || 'Download failed'
|
||||
}
|
||||
|
||||
function getProgressPercent(download: DownloadOverlayItem) {
|
||||
if (!download.totalBytes || download.totalBytes <= 0) return null
|
||||
return Math.max(0, Math.min(100, (download.receivedBytes / download.totalBytes) * 100))
|
||||
}
|
||||
|
||||
export default function DownloadsOverlay({ downloads, onCancel, onSaveAgain, onDismiss, onClearFinished }: DownloadsOverlayProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const activeCount = useMemo(() => downloads.filter((download) => download.status === 'downloading').length, [downloads])
|
||||
const finishedCount = useMemo(() => downloads.filter((download) => download.status !== 'downloading').length, [downloads])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCount > 0) setExpanded(true)
|
||||
}, [activeCount])
|
||||
|
||||
if (downloads.length === 0) return null
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={10}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
width: { xs: 'calc(100vw - 24px)', sm: 360 },
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
bgcolor: 'background.paper',
|
||||
zIndex: (theme) => theme.zIndex.modal - 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.25, py: 1, borderBottom: expanded ? '1px solid rgba(255,255,255,0.08)' : 'none' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1, minWidth: 0 }} noWrap>
|
||||
Downloads
|
||||
</Typography>
|
||||
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount === 1 ? '1 active' : `${activeCount} active`} />}
|
||||
{finishedCount > 0 && <Chip size="small" variant="outlined" label={finishedCount === 1 ? '1 done' : `${finishedCount} done`} />}
|
||||
{finishedCount > 0 && (
|
||||
<IconButton size="small" onClick={onClearFinished} aria-label="clear finished downloads">
|
||||
<ClearAllIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="small" onClick={() => setExpanded((current) => !current)} aria-label={expanded ? 'collapse downloads' : 'expand downloads'}>
|
||||
{expanded ? <ExpandMoreIcon fontSize="small" /> : <ExpandLessIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{expanded && (
|
||||
<Box sx={{ maxHeight: 280, overflowY: 'auto' }}>
|
||||
{downloads.map((download) => {
|
||||
const progressPercent = getProgressPercent(download)
|
||||
const progressText = buildProgressText(download)
|
||||
|
||||
return (
|
||||
<Box key={download.id} sx={{ px: 1.25, py: 1, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
|
||||
{download.title}
|
||||
</Typography>
|
||||
{download.fileName && download.status !== 'downloading' && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>
|
||||
{download.fileName}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{download.status === 'downloading'
|
||||
? (
|
||||
<IconButton size="small" onClick={() => onCancel(download.id)} aria-label="cancel download">
|
||||
<StopCircleOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
: (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{download.status === 'completed' && download.saveHref && (
|
||||
<IconButton size="small" onClick={() => onSaveAgain(download.id)} aria-label="save download again">
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="small" onClick={() => onDismiss(download.id)} aria-label="dismiss download">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{download.status === 'downloading' && (
|
||||
<LinearProgress
|
||||
sx={{ mt: 0.75 }}
|
||||
variant={progressPercent != null ? 'determinate' : 'indeterminate'}
|
||||
value={progressPercent ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{download.status === 'downloading' && progressPercent != null
|
||||
? `Downloading ${progressPercent.toFixed(0)}% • ${progressText}`
|
||||
: progressText}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import AudiotrackIcon from '@mui/icons-material/Audiotrack'
|
||||
import MovieIcon from '@mui/icons-material/Movie'
|
||||
import ImageIcon from '@mui/icons-material/Image'
|
||||
import AppsIcon from '@mui/icons-material/Apps'
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import type { MediaSection } from '../types'
|
||||
@@ -11,7 +12,7 @@ import type { MediaSection } from '../types'
|
||||
export const drawerWidth = 240
|
||||
|
||||
type NavItem = {
|
||||
id: MediaSection
|
||||
id: MediaSection | 'downloads'
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
@@ -22,6 +23,7 @@ const ITEMS: NavItem[] = [
|
||||
{ id: 'video', label: 'Video', icon: <MovieIcon /> },
|
||||
{ id: 'image', label: 'Image', icon: <ImageIcon /> },
|
||||
{ id: 'application', label: 'Applications', icon: <AppsIcon /> },
|
||||
{ id: 'downloads', label: 'Downloads', icon: <DownloadIcon /> },
|
||||
]
|
||||
|
||||
export default function Sidebar({
|
||||
|
||||
Reference in New Issue
Block a user