first commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useMemo, 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'
|
||||
import { addDevLog, clearDevLogs, getDevLogs, subscribeDevLogs, type DevLogItem } from '../debugLog'
|
||||
|
||||
function formatLogItem(log: DevLogItem) {
|
||||
return [
|
||||
`${log.kind} - ${new Date(log.time).toLocaleString()}`,
|
||||
log.category ? `category: ${log.category}` : null,
|
||||
`message: ${log.message}`,
|
||||
log.source ? `source: ${log.source}` : null,
|
||||
log.stack ? `stack:\n${log.stack}` : null,
|
||||
log.details ? `details:\n${log.details}` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
export default function DevErrorPanel() {
|
||||
const [logs, setLogs] = useState<DevLogItem[]>(() => getDevLogs())
|
||||
const [open, setOpen] = useState(false)
|
||||
const errorCount = useMemo(() => logs.filter((item) => item.kind !== 'debug').length, [logs])
|
||||
|
||||
const copyLog = async (log: DevLogItem) => {
|
||||
const payload = formatLogItem(log)
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(payload)
|
||||
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } })
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } })
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = payload
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
textarea.remove()
|
||||
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id, fallback: true } })
|
||||
} catch (error: any) {
|
||||
addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard fallback copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeDevLogs((items) => {
|
||||
setLogs(items)
|
||||
if (items.length > 0) setOpen(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onError = (e: ErrorEvent) => {
|
||||
try {
|
||||
const item = { kind: 'error' as const, category: 'window', message: e.message || 'Error', stack: (e.error && (e.error.stack || e.error.message)) || undefined, source: e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : undefined }
|
||||
addDevLog(item)
|
||||
// also log to console for developer convenience
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[DevErrorPanel] window.error', item)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
try {
|
||||
const reason: any = e.reason
|
||||
const item = { kind: 'unhandledrejection' as const, category: 'window', message: (reason && (reason.message || String(reason))) || 'Unhandled rejection', stack: reason && reason.stack ? String(reason.stack) : undefined }
|
||||
addDevLog(item)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[DevErrorPanel] unhandledrejection', item)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
window.addEventListener('error', onError)
|
||||
window.addEventListener('unhandledrejection', onRejection)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('error', onError)
|
||||
window.removeEventListener('unhandledrejection', onRejection)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!import.meta.env.DEV) return null
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', left: { xs: 12, sm: 'auto' }, right: 12, bottom: 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 }}>
|
||||
<Chip label={`Logs: ${logs.length}`} color={logs.length ? 'info' : 'default'} size="small" clickable onClick={() => setOpen((v) => !v)} />
|
||||
<Chip label={`Errors: ${errorCount}`} color={errorCount ? 'error' : 'default'} size="small" clickable onClick={() => setOpen((v) => !v)} />
|
||||
<Typography variant="subtitle2">Dev Log Panel</Typography>
|
||||
</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 }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{open && (
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto', p: 0 }}>
|
||||
{logs.map((l) => (
|
||||
<ListItem key={l.id} divider alignItems="flex-start">
|
||||
<ListItemText primary={`${l.kind} — ${new Date(l.time).toLocaleTimeString()}`} secondary={<>
|
||||
<Typography component="div" variant="body2">{l.message}</Typography>
|
||||
{l.category && <Typography component="div" variant="caption" sx={{ color: 'text.secondary' }}>{l.category}</Typography>}
|
||||
{l.source && <Typography component="div" variant="caption" sx={{ color: 'text.secondary' }}>{l.source}</Typography>}
|
||||
{l.stack && <Typography component="pre" variant="caption" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>{l.stack}</Typography>}
|
||||
{l.details && <Typography component="pre" variant="caption" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>{l.details}</Typography>}
|
||||
</>} />
|
||||
<IconButton edge="end" size="small" aria-label="copy log entry" onClick={() => { void copyLog(l) }} sx={{ ml: 1, alignSelf: 'flex-start' }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react'
|
||||
import { AppBar, Toolbar, IconButton, Typography, Button, Menu, MenuItem, Box, Chip, TextField } from '@mui/material'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import StorageIcon from '@mui/icons-material/Storage'
|
||||
import { useServers } from '../context/ServersContext'
|
||||
|
||||
type HeaderProps = {
|
||||
onOpenSettings?: () => void
|
||||
onToggleSidebar?: () => void
|
||||
searchQuery?: string
|
||||
onSearchQueryChange?: (value: string) => void
|
||||
searchDisabled?: boolean
|
||||
}
|
||||
|
||||
export default function Header({ onOpenSettings, onToggleSidebar, searchQuery = '', onSearchQueryChange, searchDisabled = false }: HeaderProps) {
|
||||
const { servers, activeServerId, setActiveServerId } = useServers()
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
|
||||
|
||||
const active = servers.find((s) => s.id === activeServerId)
|
||||
const activeServerLabel = active ? active.name || active.host : 'No server configured'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)
|
||||
const handleClose = () => setAnchor(null)
|
||||
|
||||
return (
|
||||
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 0, borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<Toolbar variant="dense" sx={{ minHeight: { xs: 'auto', sm: 48 }, py: { xs: 1, sm: 0.25 }, gap: 1, flexWrap: { xs: 'wrap', sm: 'nowrap' } }}>
|
||||
<IconButton onClick={() => onToggleSidebar && onToggleSidebar()} aria-label="menu" size="medium" sx={{ flexShrink: 0 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<TextField
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange && onSearchQueryChange(event.target.value)}
|
||||
disabled={searchDisabled}
|
||||
size="small"
|
||||
placeholder="Search library"
|
||||
sx={{
|
||||
flex: { xs: '1 1 calc(100% - 104px)', sm: 1 },
|
||||
minWidth: 0,
|
||||
maxWidth: { sm: 520 },
|
||||
order: { xs: 1, sm: 0 },
|
||||
'& .MuiInputBase-input': { fontSize: { xs: 14, sm: 13 }, py: 0.9 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton onClick={() => onOpenSettings && onOpenSettings()} aria-label="settings" size="medium" sx={{ width: 40, height: 40, flexShrink: 0, order: { xs: 2, sm: 0 } }}>
|
||||
<SettingsIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: 'auto' }, minWidth: 0, order: { xs: 3, sm: 0 } }}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={handleOpen}
|
||||
startIcon={<StorageIcon sx={{ fontSize: 18 }} />}
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
fontSize: { xs: 13, sm: 13 },
|
||||
justifyContent: 'flex-start',
|
||||
minWidth: 0,
|
||||
flex: { xs: 1, sm: '0 1 auto' },
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{activeServerLabel}
|
||||
</Button>
|
||||
{active?.lastTest && active.lastTest.ok === false && (
|
||||
<Chip label={active.lastTest.message} color="error" size="small" sx={{ maxWidth: { xs: 132, sm: 200 } }} />
|
||||
)}
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={handleClose}>
|
||||
{servers.length === 0 ? (
|
||||
<MenuItem disabled>No servers configured</MenuItem>
|
||||
) : (
|
||||
servers.map((s) => (
|
||||
<MenuItem
|
||||
key={s.id}
|
||||
selected={s.id === activeServerId}
|
||||
onClick={() => {
|
||||
setActiveServerId(s.id)
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
{s.name || s.host}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleClose()
|
||||
onOpenSettings && onOpenSettings()
|
||||
}}
|
||||
>
|
||||
Manage servers...
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react'
|
||||
import { Box, Drawer, List, ListItemButton, ListItemIcon, ListItemText, Typography, Divider } from '@mui/material'
|
||||
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 LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import type { MediaSection } from '../types'
|
||||
|
||||
export const drawerWidth = 240
|
||||
|
||||
type NavItem = {
|
||||
id: MediaSection
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{ id: 'all', label: 'All', icon: <LibraryMusicIcon /> },
|
||||
{ id: 'audio', label: 'Audio', icon: <AudiotrackIcon /> },
|
||||
{ id: 'video', label: 'Video', icon: <MovieIcon /> },
|
||||
{ id: 'image', label: 'Image', icon: <ImageIcon /> },
|
||||
{ id: 'application', label: 'Applications', icon: <AppsIcon /> },
|
||||
]
|
||||
|
||||
export default function Sidebar({
|
||||
mobileOpen,
|
||||
desktopOpen = true,
|
||||
onMobileClose,
|
||||
onNavigate,
|
||||
activeId,
|
||||
}: {
|
||||
mobileOpen?: boolean
|
||||
desktopOpen?: boolean
|
||||
onMobileClose?: () => void
|
||||
onNavigate?: (id: string) => void
|
||||
activeId?: string
|
||||
}) {
|
||||
const handleNavigate = (id: string) => {
|
||||
onNavigate?.(id)
|
||||
onMobileClose?.()
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box sx={{ width: drawerWidth, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 36, height: 36, borderRadius: 1, background: 'linear-gradient(135deg,#1db954,#1ed760)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700 }}>
|
||||
H
|
||||
</Box>
|
||||
<Typography variant="h6" component="div" sx={{ fontSize: 16, fontWeight: 600 }}>
|
||||
Hydrus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ opacity: 0.08 }} />
|
||||
|
||||
<List sx={{ p: 1, flex: 1 }}>
|
||||
{ITEMS.map((it) => (
|
||||
<ListItemButton key={it.id} selected={activeId === it.id} onClick={() => handleNavigate(it.id)} sx={{ borderRadius: 1, mb: 0.5 }}>
|
||||
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>{it.icon}</ListItemIcon>
|
||||
<ListItemText primary={it.label} primaryTypographyProps={{ fontSize: 14 }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ opacity: 0.08 }} />
|
||||
|
||||
<Box sx={{ p: 1 }}>
|
||||
<ListItemButton selected={activeId === 'settings'} onClick={() => handleNavigate('settings')} sx={{ borderRadius: 1 }}>
|
||||
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" primaryTypographyProps={{ fontSize: 14 }} />
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: desktopOpen ? drawerWidth : 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
duration: theme.transitions.duration.standard,
|
||||
easing: theme.transitions.easing.sharp,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
height: '100%',
|
||||
borderRight: '1px solid rgba(255,255,255,0.08)',
|
||||
bgcolor: 'background.paper',
|
||||
backgroundImage: 'none',
|
||||
transform: desktopOpen ? 'translateX(0)' : `translateX(-${drawerWidth}px)`,
|
||||
transition: (theme) => theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.standard,
|
||||
easing: theme.transitions.easing.sharp,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Temporary drawer for mobile */}
|
||||
<Drawer anchor="left" open={Boolean(mobileOpen)} onClose={onMobileClose} ModalProps={{ keepMounted: true }} PaperProps={{ sx: { width: drawerWidth } }} sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
{content}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user