diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx
new file mode 100644
index 000000000..c56643fbd
--- /dev/null
+++ b/ui/src/common/ReadOnlyFields.jsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import { FieldTitle, useRecordContext } from 'react-admin'
+import { TextField } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
+import { useDateLocale } from '../i18n/useDateLocale'
+import {
+ formatBytes,
+ formatDateTime,
+ formatDuration2,
+ formatNumber,
+} from '../utils/formatters'
+import { isDateSet } from '../utils/validations'
+
+const useStyles = makeStyles(
+ (theme) => ({
+ inputRoot: {
+ '&:hover $notchedOutline': {
+ borderColor: theme.palette.divider,
+ },
+ },
+ notchedOutline: {
+ borderColor: theme.palette.divider,
+ },
+ }),
+ { name: 'NDReadOnlyField' },
+)
+
+const identity = (v) => v
+
+// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form
+export const ReadOnlyTextField = ({
+ source,
+ label,
+ resource,
+ className,
+ fullWidth,
+ format = identity,
+ ...props
+}) => {
+ const classes = useStyles(props)
+ const record = useRecordContext(props)
+ const value = get(record, source)
+
+ return (
+ }
+ value={value == null ? '' : format(value)}
+ variant="outlined"
+ margin="dense"
+ fullWidth={fullWidth}
+ focused={false}
+ helperText=" "
+ InputProps={{
+ readOnly: true,
+ classes: {
+ root: classes.inputRoot,
+ notchedOutline: classes.notchedOutline,
+ },
+ }}
+ inputProps={{ tabIndex: -1 }}
+ />
+ )
+}
+
+ReadOnlyTextField.propTypes = {
+ source: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ record: PropTypes.object,
+ resource: PropTypes.string,
+ className: PropTypes.string,
+ classes: PropTypes.object,
+ fullWidth: PropTypes.bool,
+ format: PropTypes.func,
+}
+
+export const ReadOnlyDateField = (props) => {
+ const locale = useDateLocale()
+ const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '')
+ return
+}
+
+export const ReadOnlyNumberField = (props) => {
+ const locale = useDateLocale()
+ return (
+ formatNumber(v, locale)} {...props} />
+ )
+}
+
+export const ReadOnlySizeField = (props) => (
+
+)
+
+export const ReadOnlyDurationField = (props) => (
+
+)
diff --git a/ui/src/common/ReadOnlyFields.test.jsx b/ui/src/common/ReadOnlyFields.test.jsx
new file mode 100644
index 000000000..00543a786
--- /dev/null
+++ b/ui/src/common/ReadOnlyFields.test.jsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import {
+ ReadOnlyDateField,
+ ReadOnlyDurationField,
+ ReadOnlyNumberField,
+ ReadOnlySizeField,
+ ReadOnlyTextField,
+} from './ReadOnlyFields'
+
+vi.mock('react-admin', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useLocale: vi.fn(),
+}))
+
+describe('ReadOnlyFields', () => {
+ const record = {
+ id: '1',
+ client: 'NavidromeUI',
+ createdAt: '2026-09-17T14:30:00Z',
+ lastVisitedAt: '0001-01-01T00:00:00Z',
+ count: 1234567,
+ zero: 0,
+ size: 1536000,
+ duration: 3725,
+ }
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
+ const { useLocale } = await import('react-admin')
+ vi.mocked(useLocale).mockReturnValue('de')
+ })
+
+ const renderField = (Field, props) =>
+ render()
+
+ describe('', () => {
+ it('shows the record value with the translated field label', () => {
+ renderField(ReadOnlyTextField, { source: 'client' })
+ const input = screen.getByLabelText('resources.player.fields.client')
+ expect(input).toHaveValue('NavidromeUI')
+ })
+
+ it('cannot be edited or reached with the Tab key', () => {
+ renderField(ReadOnlyTextField, { source: 'client' })
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveAttribute('readonly')
+ expect(input).toHaveAttribute('tabindex', '-1')
+ })
+
+ it('shows an empty value when the record has no value', () => {
+ renderField(ReadOnlyTextField, { source: 'userName' })
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+
+ it('uses an explicit label when given', () => {
+ renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' })
+ expect(screen.getByLabelText('Custom')).toBeInTheDocument()
+ })
+
+ it('applies a custom format', () => {
+ renderField(ReadOnlyTextField, {
+ source: 'client',
+ format: (v) => v.toUpperCase(),
+ })
+ expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI')
+ })
+
+ it('exposes theme-overridable class names', () => {
+ const { container } = renderField(ReadOnlyTextField, {
+ source: 'client',
+ })
+ expect(
+ container.querySelector('[class*="NDReadOnlyField-inputRoot"]'),
+ ).toBeInTheDocument()
+ expect(
+ container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'),
+ ).toBeInTheDocument()
+ })
+ })
+
+ describe('', () => {
+ it('formats the date using the selected language', () => {
+ renderField(ReadOnlyDateField, { source: 'createdAt' })
+ expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /)
+ })
+
+ it('shows an empty value when the date is not set', () => {
+ renderField(ReadOnlyDateField, { source: 'lastVisitedAt' })
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+ })
+
+ describe('', () => {
+ it('formats the number using the selected language', () => {
+ renderField(ReadOnlyNumberField, { source: 'count' })
+ expect(screen.getByRole('textbox')).toHaveValue('1.234.567')
+ })
+
+ it('shows zero', () => {
+ renderField(ReadOnlyNumberField, { source: 'zero' })
+ expect(screen.getByRole('textbox')).toHaveValue('0')
+ })
+ })
+
+ describe('', () => {
+ it('formats bytes as a human-readable size', () => {
+ renderField(ReadOnlySizeField, { source: 'size' })
+ expect(screen.getByRole('textbox')).toHaveValue('1.46 MB')
+ })
+ })
+
+ describe('', () => {
+ it('formats seconds as a human-readable duration', () => {
+ renderField(ReadOnlyDurationField, { source: 'duration' })
+ expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s')
+ })
+ })
+})
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 0177df326..fb8f40f00 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -15,6 +15,7 @@ export * from './perPageStore'
export * from './PlayButton'
export * from './QuickFilter'
export * from './RangeField'
+export * from './ReadOnlyFields'
export * from './ShuffleAllButton'
export * from './SimpleList'
export * from './SizeField'
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
index 7e89c892c..53d17ac7f 100644
--- a/ui/src/library/LibraryEdit.jsx
+++ b/ui/src/library/LibraryEdit.jsx
@@ -6,7 +6,6 @@ import {
BooleanInput,
required,
SaveButton,
- DateField,
useTranslate,
useMutation,
useNotify,
@@ -16,8 +15,13 @@ import {
import { Typography, Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import DeleteLibraryButton from './DeleteLibraryButton'
-import { Title } from '../common'
-import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
+import {
+ ReadOnlyDateField,
+ ReadOnlyDurationField,
+ ReadOnlyNumberField,
+ ReadOnlySizeField,
+ Title,
+} from '../common'
const useStyles = makeStyles({
toolbar: {
@@ -26,6 +30,8 @@ const useStyles = makeStyles({
},
})
+const readOnlyProps = { resource: 'library', fullWidth: true }
+
const LibraryTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.library.name', { smart_count: 1 })
@@ -125,132 +131,40 @@ const LibraryEdit = (props) => {
{translate('resources.library.sections.statistics')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- formatBytes(v, 2)}
- fullWidth
- variant="outlined"
- />
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Timestamps Section */}
-
-
- {translate('resources.library.fields.lastScanAt')}
-
-
+
-
-
-
-
- {translate('resources.library.fields.updatedAt')}
-
-
-
-
-
-
- {translate('resources.library.fields.createdAt')}
-
-
+
+
+
+
+
+
+
+
diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx
index 08f389698..d785eb04e 100644
--- a/ui/src/player/PlayerEdit.jsx
+++ b/ui/src/player/PlayerEdit.jsx
@@ -1,5 +1,4 @@
import {
- TextField,
Edit,
SimpleForm,
useTranslate,
@@ -9,7 +8,7 @@ import {
Toolbar,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
-import { Title } from '../common'
+import { ReadOnlyTextField, Title } from '../common'
import ApiKeyInput from './ApiKeyInput'
import { playerInputs } from './playerInputs'
@@ -45,8 +44,8 @@ const PlayerEdit = (props) => (
} mutationMode="pessimistic" {...props}>
}>
{playerInputs()}
-
-
+
+
diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx
index f6882e366..c7594fa13 100644
--- a/ui/src/playlist/PlaylistEdit.jsx
+++ b/ui/src/playlist/PlaylistEdit.jsx
@@ -3,7 +3,6 @@ import {
FormDataConsumer,
SimpleForm,
TextInput,
- TextField,
BooleanInput,
required,
useTranslate,
@@ -11,13 +10,14 @@ import {
ReferenceInput,
SelectInput,
} from 'react-admin'
-import { isWritable, Title } from '../common'
+import { isWritable, ReadOnlyTextField, Title } from '../common'
const SyncFragment = ({ formData, variant, ...rest }) => {
+ if (!formData.path) return null
return (
<>
- {formData.path && }
- {formData.path && }
+
+
>
)
}
@@ -56,10 +56,10 @@ const PlaylistEditForm = (props) => {
/>
) : (
-
+
)}
-
+
{(formDataProps) => }
diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx
index bbe001e6f..af879deaa 100644
--- a/ui/src/radio/RadioEdit.jsx
+++ b/ui/src/radio/RadioEdit.jsx
@@ -1,5 +1,4 @@
import {
- DateField,
Edit,
required,
SimpleForm,
@@ -9,7 +8,12 @@ import {
import { CardMedia } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { urlValidate } from '../utils/validations'
-import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
+import {
+ Title,
+ ImageUploadOverlay,
+ ReadOnlyDateField,
+ useImageLoadingState,
+} from '../common'
import subsonic from '../subsonic'
import config from '../config'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
@@ -65,8 +69,8 @@ const RadioEdit = (props) => {
fullWidth
validate={[urlValidate]}
/>
-
-
+
+
)
diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx
index 2cf7f2df7..a222d3369 100644
--- a/ui/src/share/ShareEdit.jsx
+++ b/ui/src/share/ShareEdit.jsx
@@ -2,13 +2,16 @@ import {
DateTimeInput,
BooleanInput,
Edit,
- NumberField,
SimpleForm,
TextInput,
} from 'react-admin'
import { sharePlayerUrl } from '../utils'
import { Link } from '@material-ui/core'
-import { DateField } from '../common'
+import {
+ ReadOnlyDateField,
+ ReadOnlyNumberField,
+ ReadOnlyTextField,
+} from '../common'
import config from '../config'
export const ShareEdit = (props) => {
@@ -16,20 +19,26 @@ export const ShareEdit = (props) => {
const url = sharePlayerUrl(id)
return (
-
-
+
+
{url}
{config.enableDownloads && }
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)
diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx
index c5d9c75a4..feadafff1 100644
--- a/ui/src/user/UserEdit.jsx
+++ b/ui/src/user/UserEdit.jsx
@@ -3,7 +3,6 @@ import { makeStyles } from '@material-ui/core/styles'
import {
TextInput,
BooleanInput,
- DateField,
PasswordInput,
Edit,
required,
@@ -21,7 +20,7 @@ import {
useRecordContext,
} from 'react-admin'
import { Typography } from '@material-ui/core'
-import { Title } from '../common'
+import { ReadOnlyDateField, Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
import { validateUserForm } from './userValidation'
@@ -183,10 +182,10 @@ const UserEdit = (props) => {
helperText={translate('resources.user.helperTexts.scrobbleFilter')}
/>
-
-
-
-
+
+
+
+
)
diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx
index 74405cc13..837b25ad6 100644
--- a/ui/src/user/UserEdit.test.jsx
+++ b/ui/src/user/UserEdit.test.jsx
@@ -51,9 +51,6 @@ vi.mock('react-admin', () => ({
BooleanInput: ({ source }) => (
),
- DateField: ({ source }) => (
- Date
- ),
PasswordInput: ({ source }) => (
),
@@ -82,6 +79,9 @@ vi.mock('./DeleteUserButton', () => ({
vi.mock('../common', () => ({
Title: ({ subTitle }) => {subTitle}
,
+ ReadOnlyDateField: ({ source }) => (
+ Date
+ ),
}))
// Mock Material-UI