diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index c40e66866..4fd1bc371 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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", diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index da46a73ab..75c68863a 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -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}\"` : ''}`} /> ) } -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> ) } diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index ae27f230f..8141e2366 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -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() +} diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js index 87b40f16b..922e775f3 100644 --- a/ui/src/utils/formatters.test.js +++ b/ui/src/utils/formatters.test.js @@ -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')