mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 06:07:17 +01:00
feat(library): add additional fields and restructure LibraryEdit component for enhanced statistics display
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
a036db7ebd
commit
f1cea72715
4 changed files with 292 additions and 145 deletions
|
|
@ -271,6 +271,13 @@
|
|||
"path": "Path",
|
||||
"remotePath": "Remote Path",
|
||||
"lastScanAt": "Last Scan",
|
||||
"songCount": "Songs",
|
||||
"albumCount": "Albums",
|
||||
"artistCount": "Artists",
|
||||
"scanCount": "Scan Count",
|
||||
"missingFileCount": "Missing Files",
|
||||
"size": "Size",
|
||||
"duration": "Duration",
|
||||
"totalSongs": "Songs",
|
||||
"totalAlbums": "Albums",
|
||||
"totalArtists": "Artists",
|
||||
|
|
@ -282,6 +289,11 @@
|
|||
"createdAt": "Created",
|
||||
"updatedAt": "Updated"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Basic Information",
|
||||
"statistics": "Statistics",
|
||||
"scan": "Scan Information"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scan Library",
|
||||
"manageUsers": "Manage User Access",
|
||||
|
|
|
|||
|
|
@ -1,89 +1,48 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
Edit,
|
||||
SimpleForm,
|
||||
FormWithRedirect,
|
||||
TextInput,
|
||||
required,
|
||||
Toolbar,
|
||||
SaveButton,
|
||||
NumberField,
|
||||
DateField,
|
||||
FunctionField,
|
||||
useTranslate,
|
||||
useMutation,
|
||||
useNotify,
|
||||
useRedirect,
|
||||
NumberInput,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Divider, Typography } from '@material-ui/core'
|
||||
import { Typography, Box, Toolbar } from '@material-ui/core'
|
||||
import DeleteLibraryButton from './DeleteLibraryButton'
|
||||
import { Title } from '../common'
|
||||
import { formatBytes } from '../utils/index.js'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
divider: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
stats: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '0 1em',
|
||||
'& > *': {
|
||||
padding: '0.2em 0',
|
||||
},
|
||||
},
|
||||
})
|
||||
import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
|
||||
|
||||
const LibraryTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
const resourceName = translate('resources.library.name', { smart_count: 1 })
|
||||
return (
|
||||
<Title subTitle={`${resourceName} ${record ? `"${record.name}"` : ''}`} />
|
||||
<Title subTitle={`${resourceName} ${record ? `\"${record.name}\"` : ''}`} />
|
||||
)
|
||||
}
|
||||
|
||||
const LibraryToolbar = (props) => (
|
||||
<Toolbar {...props} classes={useStyles()}>
|
||||
<SaveButton />
|
||||
{props.record && props.record.id !== 1 && (
|
||||
<DeleteLibraryButton {...props} />
|
||||
)}
|
||||
const CustomToolbar = (props) => (
|
||||
<Toolbar {...props}>
|
||||
<Box display="flex" justifyContent="space-between" width="100%">
|
||||
<SaveButton
|
||||
handleSubmitWithRedirect={props.handleSubmitWithRedirect}
|
||||
saving={props.saving}
|
||||
pristine={props.pristine}
|
||||
/>
|
||||
<DeleteLibraryButton />
|
||||
</Box>
|
||||
</Toolbar>
|
||||
)
|
||||
|
||||
const formatDuration = (totalSeconds) => {
|
||||
if (totalSeconds == null || totalSeconds < 0) {
|
||||
return '0s'
|
||||
}
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts = []
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (seconds > 0 || parts.length === 0) {
|
||||
parts.push(`${seconds}s`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const LibraryEdit = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [mutate] = useMutation()
|
||||
const notify = useNotify()
|
||||
const redirect = useRedirect()
|
||||
const isFirstLibrary = props.id === '1'
|
||||
|
||||
const save = useCallback(
|
||||
async (values) => {
|
||||
|
|
@ -92,7 +51,7 @@ const LibraryEdit = (props) => {
|
|||
{
|
||||
type: 'update',
|
||||
resource: 'library',
|
||||
payload: { id: props.id, data: values },
|
||||
payload: { id: values.id, data: values },
|
||||
},
|
||||
{ returnPromise: true },
|
||||
)
|
||||
|
|
@ -106,99 +65,153 @@ const LibraryEdit = (props) => {
|
|||
}
|
||||
}
|
||||
},
|
||||
[mutate, notify, redirect, props.id],
|
||||
[mutate, notify, redirect],
|
||||
)
|
||||
|
||||
return (
|
||||
<Edit title={<LibraryTitle />} undoable={false} {...props}>
|
||||
<SimpleForm variant={'outlined'} toolbar={<LibraryToolbar />} save={save}>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput
|
||||
source="path"
|
||||
validate={[required()]}
|
||||
fullWidth
|
||||
disabled={isFirstLibrary}
|
||||
/>
|
||||
<Divider className={classes.divider} fullWidth />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Statistics
|
||||
</Typography>
|
||||
{/*eslint-disable-next-line react/no-unknown-property*/}
|
||||
<div className={classes.stats} fullWidth>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalSongs')}
|
||||
</Typography>
|
||||
<NumberField source="totalSongs" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalAlbums')}
|
||||
</Typography>
|
||||
<NumberField source="totalAlbums" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalArtists')}
|
||||
</Typography>
|
||||
<NumberField source="totalArtists" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalFolders')}
|
||||
</Typography>
|
||||
<NumberField source="totalFolders" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalFiles')}
|
||||
</Typography>
|
||||
<NumberField source="totalFiles" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalMissingFiles')}
|
||||
</Typography>
|
||||
<NumberField source="totalMissingFiles" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalSize')}
|
||||
</Typography>
|
||||
<FunctionField render={(record) => formatBytes(record.totalSize)} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.totalDuration')}
|
||||
</Typography>
|
||||
<FunctionField
|
||||
render={(record) => formatDuration(record.totalDuration)}
|
||||
<FormWithRedirect
|
||||
{...props}
|
||||
save={save}
|
||||
render={(formProps) => (
|
||||
<form onSubmit={formProps.handleSubmit}>
|
||||
<Box p="1em" maxWidth="800px">
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="1em">
|
||||
{/* Basic Information */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.basic')}
|
||||
</Typography>
|
||||
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput source="path" validate={[required()]} fullWidth />
|
||||
|
||||
<Box mt="2em" />
|
||||
|
||||
{/* Statistics - Two Column Layout */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{translate('resources.library.sections.statistics')}
|
||||
</Typography>
|
||||
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="0.5em">
|
||||
<NumberInput
|
||||
disabled
|
||||
resource={'library'}
|
||||
source={'totalSongs'}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} ml="0.5em">
|
||||
<NumberInput
|
||||
disabled
|
||||
resource={'library'}
|
||||
source={'totalAlbums'}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="0.5em">
|
||||
<NumberInput
|
||||
disabled
|
||||
resource={'library'}
|
||||
source={'totalArtists'}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} ml="0.5em">
|
||||
<TextInput
|
||||
disabled
|
||||
resource={'library'}
|
||||
source={'totalSize'}
|
||||
format={formatBytes}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="0.5em">
|
||||
<TextInput
|
||||
disabled
|
||||
resource={'library'}
|
||||
source={'totalDuration'}
|
||||
format={formatDuration2}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} ml="0.5em">
|
||||
<TextInput
|
||||
disabled
|
||||
resource={'library'}
|
||||
source={'totalMissingFiles'}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Timestamps Section */}
|
||||
<Box mb="1em">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
{translate('resources.library.fields.lastScanAt')}
|
||||
</Typography>
|
||||
<DateField
|
||||
variant="body1"
|
||||
source="lastScanAt"
|
||||
showTime
|
||||
record={formProps.record}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mb="1em">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
{translate('resources.library.fields.updatedAt')}
|
||||
</Typography>
|
||||
<DateField
|
||||
variant="body1"
|
||||
source="updatedAt"
|
||||
showTime
|
||||
record={formProps.record}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mb="2em">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
{translate('resources.library.fields.createdAt')}
|
||||
</Typography>
|
||||
<DateField
|
||||
variant="body1"
|
||||
source="createdAt"
|
||||
showTime
|
||||
record={formProps.record}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<CustomToolbar
|
||||
handleSubmitWithRedirect={formProps.handleSubmitWithRedirect}
|
||||
pristine={formProps.pristine}
|
||||
saving={formProps.saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={classes.divider} fullWidth />
|
||||
{/*eslint-disable-next-line react/no-unknown-property*/}
|
||||
<div className={classes.stats} fullWidth>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.lastScanAt')}
|
||||
</Typography>
|
||||
<DateField source="lastScanAt" showTime />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.createdAt')}
|
||||
</Typography>
|
||||
<DateField source="createdAt" showTime />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="caption" display="block" color="textSecondary">
|
||||
{translate('resources.library.fields.updatedAt')}
|
||||
</Typography>
|
||||
<DateField source="updatedAt" showTime />
|
||||
</div>
|
||||
</div>
|
||||
</SimpleForm>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,42 @@ export const formatDuration = (d) => {
|
|||
return `${days > 0 ? days + ':' : ''}${f}`
|
||||
}
|
||||
|
||||
export const formatDuration2 = (totalSeconds) => {
|
||||
if (totalSeconds == null || totalSeconds < 0) {
|
||||
return '0s'
|
||||
}
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts = []
|
||||
|
||||
if (days > 0) {
|
||||
// When days are present, show only d h m (3 levels max)
|
||||
parts.push(`${days}d`)
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
} else {
|
||||
// When no days, show h m s (3 levels max)
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (seconds > 0 || parts.length === 0) {
|
||||
parts.push(`${seconds}s`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export const formatShortDuration = (ns) => {
|
||||
// Convert nanoseconds to seconds
|
||||
const seconds = ns / 1e9
|
||||
|
|
@ -58,3 +94,8 @@ export const formatFullDate = (date, locale) => {
|
|||
}
|
||||
return new Date(date).toLocaleDateString(locale, options)
|
||||
}
|
||||
|
||||
export const formatNumber = (value) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatDuration2,
|
||||
formatFullDate,
|
||||
formatNumber,
|
||||
formatShortDuration,
|
||||
} from './formatters'
|
||||
|
||||
|
|
@ -64,6 +66,85 @@ describe('formatShortDuration', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('formatDuration2', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatDuration2(null)).toEqual('0s')
|
||||
expect(formatDuration2(undefined)).toEqual('0s')
|
||||
})
|
||||
|
||||
it('handles negative values', () => {
|
||||
expect(formatDuration2(-10)).toEqual('0s')
|
||||
expect(formatDuration2(-1)).toEqual('0s')
|
||||
})
|
||||
|
||||
it('formats zero seconds', () => {
|
||||
expect(formatDuration2(0)).toEqual('0s')
|
||||
})
|
||||
|
||||
it('formats seconds only', () => {
|
||||
expect(formatDuration2(1)).toEqual('1s')
|
||||
expect(formatDuration2(30)).toEqual('30s')
|
||||
expect(formatDuration2(59)).toEqual('59s')
|
||||
})
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatDuration2(60)).toEqual('1m')
|
||||
expect(formatDuration2(90)).toEqual('1m 30s')
|
||||
expect(formatDuration2(119)).toEqual('1m 59s')
|
||||
expect(formatDuration2(120)).toEqual('2m')
|
||||
})
|
||||
|
||||
it('formats hours, minutes and seconds', () => {
|
||||
expect(formatDuration2(3600)).toEqual('1h')
|
||||
expect(formatDuration2(3661)).toEqual('1h 1m 1s')
|
||||
expect(formatDuration2(7200)).toEqual('2h')
|
||||
expect(formatDuration2(7260)).toEqual('2h 1m')
|
||||
expect(formatDuration2(7261)).toEqual('2h 1m 1s')
|
||||
})
|
||||
|
||||
it('handles decimal values by flooring', () => {
|
||||
expect(formatDuration2(59.9)).toEqual('59s')
|
||||
expect(formatDuration2(60.1)).toEqual('1m')
|
||||
expect(formatDuration2(3600.9)).toEqual('1h')
|
||||
})
|
||||
|
||||
it('formats days with maximum 3 levels (d h m)', () => {
|
||||
expect(formatDuration2(86400)).toEqual('1d')
|
||||
expect(formatDuration2(86461)).toEqual('1d 1m') // seconds dropped when days present
|
||||
expect(formatDuration2(90061)).toEqual('1d 1h 1m') // seconds dropped when days present
|
||||
expect(formatDuration2(172800)).toEqual('2d')
|
||||
expect(formatDuration2(176400)).toEqual('2d 1h')
|
||||
expect(formatDuration2(176460)).toEqual('2d 1h 1m')
|
||||
expect(formatDuration2(176461)).toEqual('2d 1h 1m') // seconds dropped when days present
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatNumber(null)).toEqual('0')
|
||||
expect(formatNumber(undefined)).toEqual('0')
|
||||
})
|
||||
|
||||
it('formats integers', () => {
|
||||
expect(formatNumber(0)).toEqual('0')
|
||||
expect(formatNumber(1)).toEqual('1')
|
||||
expect(formatNumber(123)).toEqual('123')
|
||||
expect(formatNumber(1000)).toEqual('1,000')
|
||||
expect(formatNumber(1234567)).toEqual('1,234,567')
|
||||
})
|
||||
|
||||
it('formats decimal numbers', () => {
|
||||
expect(formatNumber(123.45)).toEqual('123.45')
|
||||
expect(formatNumber(1234.567)).toEqual('1,234.567')
|
||||
})
|
||||
|
||||
it('formats negative numbers', () => {
|
||||
expect(formatNumber(-123)).toEqual('-123')
|
||||
expect(formatNumber(-1234)).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45)).toEqual('-123.45')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFullDate', () => {
|
||||
it('format dates', () => {
|
||||
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue