mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
feat(ui): format dates using the selected language (#6160)
Dates were rendered with the browser locale, ignoring the language chosen in Personal settings, so a user browsing in German still saw US-style dates. All date rendering now resolves its locale through a useDateLocale hook that returns the selected language, augmented with the region from navigator.languages when the language carries none (Intl reads a bare "en" as en-US). Applied to DateField, the rated/loved tooltips, the mobile user list and album release dates; the three inline timestamp tooltips now share a formatDateTime helper. Closes #229
This commit is contained in:
parent
fed35e08de
commit
be8ec15168
9 changed files with 115 additions and 7 deletions
|
|
@ -31,6 +31,7 @@ import {
|
|||
SizeField,
|
||||
useAlbumsPerPage,
|
||||
} from '../common'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
|
@ -151,6 +152,7 @@ export const Details = (props) => {
|
|||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
const locale = useDateLocale()
|
||||
|
||||
// Create an array of detail elements
|
||||
let details = []
|
||||
|
|
@ -161,12 +163,13 @@ export const Details = (props) => {
|
|||
|
||||
// Calculate date related fields
|
||||
const yearRange = formatRange(record, 'year')
|
||||
const date = record.date ? formatFullDate(record.date) : yearRange
|
||||
const date = record.date ? formatFullDate(record.date, locale) : yearRange
|
||||
|
||||
const originalDate = record.originalDate
|
||||
? formatFullDate(record.originalDate)
|
||||
? formatFullDate(record.originalDate, locale)
|
||||
: formatRange(record, 'originalYear')
|
||||
const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate)
|
||||
const releaseDate =
|
||||
record?.releaseDate && formatFullDate(record.releaseDate, locale)
|
||||
|
||||
const dateToUse = originalDate || date
|
||||
const isOriginalDate = originalDate && dateToUse !== date
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import React from 'react'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
import { DateField as RADateField } from 'react-admin'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
|
||||
export const DateField = (props) => {
|
||||
const { record, source } = props
|
||||
const locale = useDateLocale()
|
||||
const value = record?.[source]
|
||||
if (!isDateSet(value)) return null
|
||||
return <RADateField {...props} />
|
||||
return <RADateField locales={locale} {...props} />
|
||||
}
|
||||
|
||||
DateField.defaultProps = {
|
||||
|
|
|
|||
32
ui/src/common/DateField.test.jsx
Normal file
32
ui/src/common/DateField.test.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { DateField } from './DateField'
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('<DateField>', () => {
|
||||
const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' }
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
|
||||
const { useLocale } = await import('react-admin')
|
||||
vi.mocked(useLocale).mockReturnValue('de')
|
||||
})
|
||||
|
||||
it('formats the date using the selected language', () => {
|
||||
render(<DateField record={record} source="updatedAt" />)
|
||||
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when the date is not set', () => {
|
||||
const { container } = render(
|
||||
<DateField record={{ id: '1' }} source="updatedAt" />,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton'
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatDateTime } from '../utils/formatters'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import config from '../config'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
|
@ -40,6 +42,7 @@ export const LoveButton = ({
|
|||
const record = useRecordContext({ record: recordProp }) || {}
|
||||
const classes = useStyles({ color, visible, loved: record.starred })
|
||||
const [toggleLove, loading] = useToggleLove(resource, record)
|
||||
const locale = useDateLocale()
|
||||
|
||||
const handleToggleLove = useCallback(
|
||||
(e) => {
|
||||
|
|
@ -61,7 +64,7 @@ export const LoveButton = ({
|
|||
className={clsx(classes.love, className)}
|
||||
title={
|
||||
isDateSet(record.starredAt)
|
||||
? new Date(record.starredAt).toLocaleString()
|
||||
? formatDateTime(record.starredAt, locale)
|
||||
: undefined
|
||||
}
|
||||
{...rest}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations'
|
|||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import clsx from 'clsx'
|
||||
import { useRating } from './useRating'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatDateTime } from '../utils/formatters'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
|
|
@ -32,6 +34,7 @@ export const RatingField = ({
|
|||
const record = useRecordContext(rest) || {}
|
||||
const [rate, rating] = useRating(resource, record)
|
||||
const classes = useStyles({ color, visible })
|
||||
const locale = useDateLocale()
|
||||
|
||||
const stopPropagation = (e) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -50,7 +53,7 @@ export const RatingField = ({
|
|||
onClick={(e) => stopPropagation(e)}
|
||||
title={
|
||||
isDateSet(record.ratedAt)
|
||||
? new Date(record.ratedAt).toLocaleString()
|
||||
? formatDateTime(record.ratedAt, locale)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
14
ui/src/i18n/useDateLocale.js
Normal file
14
ui/src/i18n/useDateLocale.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useLocale } from 'react-admin'
|
||||
|
||||
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
|
||||
// as en-US. Borrow the region from the browser when it speaks the same language.
|
||||
const resolveDateLocale = (locale, browserLocales = []) => {
|
||||
if (!locale || locale.includes('-')) return locale
|
||||
const base = locale.toLowerCase()
|
||||
return (
|
||||
browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale
|
||||
)
|
||||
}
|
||||
|
||||
export const useDateLocale = () =>
|
||||
resolveDateLocale(useLocale(), navigator.languages)
|
||||
45
ui/src/i18n/useDateLocale.test.js
Normal file
45
ui/src/i18n/useDateLocale.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useDateLocale } from './useDateLocale'
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useDateLocale', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderWith = async (locale, browserLocales) => {
|
||||
const { useLocale } = await import('react-admin')
|
||||
vi.mocked(useLocale).mockReturnValue(locale)
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales)
|
||||
return renderHook(() => useDateLocale()).result
|
||||
}
|
||||
|
||||
it('adds the region from the browser when the language has none', async () => {
|
||||
const result = await renderWith('en', ['en-GB', 'fr-FR'])
|
||||
expect(result.current).toEqual('en-GB')
|
||||
})
|
||||
|
||||
it('falls back to the language when no browser entry matches', async () => {
|
||||
const result = await renderWith('de', ['en-US', 'fr-FR'])
|
||||
expect(result.current).toEqual('de')
|
||||
})
|
||||
|
||||
it('keeps a language that already carries a region or script', async () => {
|
||||
expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br')
|
||||
expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans')
|
||||
})
|
||||
|
||||
it('matches the browser language case-insensitively', async () => {
|
||||
const result = await renderWith('pt', ['PT-PT'])
|
||||
expect(result.current).toEqual('PT-PT')
|
||||
})
|
||||
|
||||
it('returns undefined when there is no language', async () => {
|
||||
const result = await renderWith(undefined, ['en-GB'])
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { List, DateField } from '../common'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatDateTime } from '../utils/formatters'
|
||||
|
||||
const UserFilter = (props) => (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
|
|
@ -18,6 +20,7 @@ const UserFilter = (props) => (
|
|||
|
||||
const UserList = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const locale = useDateLocale()
|
||||
|
||||
return (
|
||||
<List
|
||||
|
|
@ -31,7 +34,7 @@ const UserList = (props) => {
|
|||
<SimpleList
|
||||
primaryText={(record) => record.userName}
|
||||
secondaryText={(record) =>
|
||||
record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString()
|
||||
record.lastLoginAt && formatDateTime(record.lastLoginAt, locale)
|
||||
}
|
||||
tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ export const formatFullDate = (date, locale) => {
|
|||
return new Date(date).toLocaleDateString(locale, options)
|
||||
}
|
||||
|
||||
export const formatDateTime = (value, locale) =>
|
||||
new Date(value).toLocaleString(locale)
|
||||
|
||||
export const formatNumber = (value, locale) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toLocaleString(locale)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue