Compare commits

..

2 commits

Author SHA1 Message Date
Deluan
feb3355407 feat(ui): add pt-BR translations for the Date Format setting 2026-10-03 14:04:16 -04:00
Deluan
4112b73ac7 feat(ui): add a Date Format setting to choose language or browser dates
Since #6160 dates follow the selected UI language, which is wrong for users who run the UI in one language but want their own region's date format. The Personal page now has a Date Format select with "Match language" (default, current behavior) and "Match browser". The choice is kept in localStorage and read by useDateLocale, which returns no locale in browser mode so Intl uses the browser's own.

Six screens imported react-admin's DateField directly and so ignored the language setting (playlist, radio and missing files lists, album table, song and album info). They now use the common DateField wrapper, which also reads the record from the record context.
2026-10-03 13:02:19 -04:00
25 changed files with 113 additions and 142 deletions

View file

@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
} }
} }
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
if tracks == nil { GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
}
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil { if err != nil {
return resolution{}, err return resolution{}, err
} }

View file

@ -707,16 +707,6 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{})) Expect(res).To(Equal(resolution{}))
}) })
It("returns an error when the playlist tracks cannot be loaded", func() {
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
}) })
}) })

View file

@ -4,11 +4,9 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"fmt"
"io" "io"
"math" "math"
"math/rand/v2" "math/rand/v2"
"runtime/debug"
"sync" "sync"
"time" "time"
@ -246,7 +244,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{} trace := &ChainTrace{}
ctx = withTrace(ctx, trace) ctx = withTrace(ctx, trace)
out, got, retryIn := w.safeAcquire(ctx, item) out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue() queue := w.proc.ds.ArtworkQueue()
switch out { switch out {
@ -288,20 +286,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got return out, got
} }
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
traceStage(ctx, "panic", fmt.Errorf("%v", r))
out, got, retryIn = outcomeFailed, nil, 0
}
}()
return w.proc.acquire(ctx, item)
}
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent. // that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {

View file

@ -142,18 +142,6 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id) return v.MockPlaylistRepo.Get(ctx, id)
} }
type panickingAlbumRepo struct {
*tests.MockAlbumRepo
panicID string
}
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
if id == r.panicID {
panic("boom")
}
return r.MockAlbumRepo.Get(ctx, id)
}
func adminUserRepo() *tests.MockedUserRepo { func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo() repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
@ -290,36 +278,6 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
}) })
It("fails an item that panics, without stopping the rest of the batch", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
})
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(2))
it := findQueued(queueRepo, "al", "alboom")
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
Expect(it.Trace).To(ContainSubstring("boom"))
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
It("reschedules past the provider's requested delay when it exceeds the backoff", func() { It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external" conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})

View file

@ -660,6 +660,11 @@
"options": { "options": {
"theme": "Tema", "theme": "Tema",
"language": "Língua", "language": "Língua",
"dateFormat": "Formato de data",
"dateFormats": {
"language": "Igual à língua",
"browser": "Igual ao navegador"
},
"defaultView": "Tela inicial", "defaultView": "Tela inicial",
"desktop_notifications": "Notificações", "desktop_notifications": "Notificações",
"lastfmScrobbling": "Enviar scrobbles para Last.fm", "lastfmScrobbling": "Enviar scrobbles para Last.fm",

View file

@ -8,7 +8,6 @@ import {
ArrayField, ArrayField,
BooleanField, BooleanField,
ChipField, ChipField,
DateField,
FunctionField, FunctionField,
SingleFieldList, SingleFieldList,
TextField, TextField,
@ -17,6 +16,7 @@ import {
} from 'react-admin' } from 'react-admin'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { import {
DateField,
ArtistLinkField, ArtistLinkField,
MultiLineTextField, MultiLineTextField,
ParticipantsInfo, ParticipantsInfo,

View file

@ -3,7 +3,6 @@ import {
Datagrid, Datagrid,
DatagridBody, DatagridBody,
DatagridRow, DatagridRow,
DateField,
NumberField, NumberField,
TextField, TextField,
FunctionField, FunctionField,
@ -13,6 +12,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd' import { useDrag } from 'react-dnd'
import { import {
DateField,
ArtistLinkField, ArtistLinkField,
ArtworkAvatar, ArtworkAvatar,
DurationField, DurationField,

View file

@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import { isDateSet } from '../utils/validations' import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin' import { DateField as RADateField, useRecordContext } from 'react-admin'
import { useDateLocale } from '../i18n/useDateLocale' import { useDateLocale } from '../i18n/useDateLocale'
export const DateField = (props) => { export const DateField = (props) => {
const { record, source } = props const record = useRecordContext(props)
const locale = useDateLocale() const locale = useDateLocale()
const value = record?.[source] const value = record?.[props.source]
if (!isDateSet(value)) return null if (!isDateSet(value)) return null
return <RADateField locales={locale} {...props} /> return <RADateField locales={locale} {...props} />
} }

View file

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { RecordContextProvider } from 'react-admin'
import { DateField } from './DateField' import { DateField } from './DateField'
vi.mock('react-admin', async (importOriginal) => ({ vi.mock('react-admin', async (importOriginal) => ({
@ -29,4 +30,13 @@ describe('<DateField>', () => {
) )
expect(container).toBeEmptyDOMElement() expect(container).toBeEmptyDOMElement()
}) })
it('reads the record from the record context', () => {
render(
<RecordContextProvider value={record}>
<DateField source="updatedAt" />
</RecordContextProvider>,
)
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
})
}) })

View file

@ -6,7 +6,6 @@ import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow' import TableRow from '@material-ui/core/TableRow'
import { import {
BooleanField, BooleanField,
DateField,
TextField, TextField,
NumberField, NumberField,
FunctionField, FunctionField,
@ -15,6 +14,7 @@ import {
} from 'react-admin' } from 'react-admin'
import { humanize, underscore } from 'inflection' import { humanize, underscore } from 'inflection'
import { import {
DateField,
ArtistLinkField, ArtistLinkField,
BitrateField, BitrateField,
ParticipantsInfo, ParticipantsInfo,

View file

@ -677,6 +677,11 @@
"options": { "options": {
"theme": "Theme", "theme": "Theme",
"language": "Language", "language": "Language",
"dateFormat": "Date Format",
"dateFormats": {
"language": "Match language",
"browser": "Match browser"
},
"defaultView": "Default View", "defaultView": "Default View",
"desktop_notifications": "Desktop Notifications", "desktop_notifications": "Desktop Notifications",
"lastfmNotConfigured": "Last.fm API-Key is not configured", "lastfmNotConfigured": "Last.fm API-Key is not configured",

View file

@ -1,5 +1,8 @@
import { useLocale } from 'react-admin' import { useLocale } from 'react-admin'
export const getStoredDateFormat = () =>
localStorage.getItem('dateFormat') || 'language'
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en" // 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. // as en-US. Borrow the region from the browser when it speaks the same language.
const resolveDateLocale = (locale, browserLocales = []) => { const resolveDateLocale = (locale, browserLocales = []) => {
@ -10,5 +13,10 @@ const resolveDateLocale = (locale, browserLocales = []) => {
) )
} }
export const useDateLocale = () => // With the "browser" date format, return undefined so Intl uses the browser's
resolveDateLocale(useLocale(), navigator.languages) // own locale, regardless of the selected language.
export const useDateLocale = () => {
const locale = useLocale()
if (getStoredDateFormat() === 'browser') return undefined
return resolveDateLocale(locale, navigator.languages)
}

View file

@ -9,6 +9,7 @@ vi.mock('react-admin', () => ({
describe('useDateLocale', () => { describe('useDateLocale', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
localStorage.removeItem('dateFormat')
}) })
const renderWith = async (locale, browserLocales) => { const renderWith = async (locale, browserLocales) => {
@ -42,4 +43,10 @@ describe('useDateLocale', () => {
const result = await renderWith(undefined, ['en-GB']) const result = await renderWith(undefined, ['en-GB'])
expect(result.current).toBeUndefined() expect(result.current).toBeUndefined()
}) })
it('returns undefined to use the browser locale when set to browser', async () => {
localStorage.setItem('dateFormat', 'browser')
const result = await renderWith('en', ['fr-FR'])
expect(result.current).toBeUndefined()
})
}) })

View file

@ -1,4 +1,5 @@
import { import {
DateField,
List, List,
Pagination, Pagination,
SizeField, SizeField,
@ -7,7 +8,6 @@ import {
} from '../common/index' } from '../common/index'
import { import {
Datagrid, Datagrid,
DateField,
TextField, TextField,
downloadCSV, downloadCSV,
Filter, Filter,

View file

@ -2,6 +2,7 @@ import { SimpleForm, Title, useTranslate } from 'react-admin'
import { Card } from '@material-ui/core' import { Card } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { SelectLanguage } from './SelectLanguage' import { SelectLanguage } from './SelectLanguage'
import { SelectDateFormat } from './SelectDateFormat'
import { SelectTheme } from './SelectTheme' import { SelectTheme } from './SelectTheme'
import { SelectDefaultView } from './SelectDefaultView' import { SelectDefaultView } from './SelectDefaultView'
import { NotificationsToggle } from './NotificationsToggle' import { NotificationsToggle } from './NotificationsToggle'
@ -24,6 +25,7 @@ const Personal = () => {
<SimpleForm toolbar={null} variant={'outlined'}> <SimpleForm toolbar={null} variant={'outlined'}>
<SelectTheme /> <SelectTheme />
<SelectLanguage /> <SelectLanguage />
<SelectDateFormat />
<SelectDefaultView /> <SelectDefaultView />
{config.enableReplayGain && <ReplayGainToggle />} {config.enableReplayGain && <ReplayGainToggle />}
<NotificationsToggle /> <NotificationsToggle />

View file

@ -0,0 +1,22 @@
import { SelectInput, useTranslate } from 'react-admin'
import { getStoredDateFormat } from '../i18n/useDateLocale'
export const SelectDateFormat = (props) => {
const translate = useTranslate()
return (
<SelectInput
{...props}
source="dateFormat"
label={translate('menu.personal.options.dateFormat')}
defaultValue={getStoredDateFormat()}
choices={[
{ id: 'language', name: 'menu.personal.options.dateFormats.language' },
{ id: 'browser', name: 'menu.personal.options.dateFormats.browser' },
]}
onChange={(event) => {
localStorage.setItem('dateFormat', event.target.value)
}}
/>
)
}

View file

@ -1,7 +1,6 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { import {
Datagrid, Datagrid,
DateField,
EditButton, EditButton,
Filter, Filter,
NullableBooleanInput, NullableBooleanInput,
@ -20,6 +19,7 @@ import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core' import { useMediaQuery } from '@material-ui/core'
import { import {
DateField,
ArtworkAvatar, ArtworkAvatar,
DurationField, DurationField,
List, List,
@ -95,7 +95,6 @@ export const ToggleField = ({ resource, source }) => {
return ( return (
<Switch <Switch
checked={record[source]} checked={record[source]}
color="primary"
onClick={handleClick} onClick={handleClick}
disabled={!isWritable(record.ownerId)} disabled={!isWritable(record.ownerId)}
/> />

View file

@ -2,7 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { TestContext } from 'ra-test' import { TestContext } from 'ra-test'
import { RecordContextProvider } from 'react-admin'
import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
vi.mock('../config', () => ({ vi.mock('../config', () => ({
@ -15,7 +14,6 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'} {record?.starred ? 'starred' : 'not-starred'}
</button> </button>
), ),
isWritable: (ownerId) => ownerId === 'me',
})) }))
describe('<PlaylistLove />', () => { describe('<PlaylistLove />', () => {
@ -57,28 +55,3 @@ describe('playlist toggles without a record', () => {
expect(container.innerHTML).toBe('') expect(container.innerHTML).toBe('')
}) })
}) })
// Secondary is a surface color in many themes, so these toggles must use primary
describe('<ToggleField />', () => {
const renderToggle = (record) =>
render(
<TestContext>
<RecordContextProvider value={record}>
<ToggleField resource="playlist" source="public" />
</RecordContextProvider>
</TestContext>,
)
it.each([
['owner', 'me', false],
['non-owner', 'someone-else', true],
])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => {
renderToggle({ id: 'pl-1', public: true, ownerId })
const input = screen.getByRole('checkbox')
const switchBase = input.closest('.MuiSwitch-switchBase')
expect(input.checked).toBe(true)
expect(input.disabled).toBe(disabled)
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary')
})
})

View file

@ -3,7 +3,6 @@ import React, { cloneElement } from 'react'
import { import {
CreateButton, CreateButton,
Datagrid, Datagrid,
DateField,
EditButton, EditButton,
Filter, Filter,
sanitizeListRestProps, sanitizeListRestProps,
@ -15,6 +14,7 @@ import {
useTranslate, useTranslate,
} from 'react-admin' } from 'react-admin'
import { import {
DateField,
List, List,
defaultRowsPerPageOptions, defaultRowsPerPageOptions,
getStoredPerPage, getStoredPerPage,

View file

@ -185,6 +185,16 @@ export default {
color: `${foreground} !important`, color: `${foreground} !important`,
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: green,
},
'&$checked + $track': {
backgroundColor: green,
},
},
},
NDAlbumGridView: { NDAlbumGridView: {
albumName: { albumName: {
marginTop: '0.5rem', marginTop: '0.5rem',

View file

@ -121,6 +121,16 @@ export default {
boxShadow: '3px 3px 5px #3c3836', boxShadow: '3px 3px 5px #3c3836',
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: '#458588',
},
'&$checked + $track': {
backgroundColor: '#458588',
},
},
},
NDMobileArtistDetails: { NDMobileArtistDetails: {
bgContainer: { bgContainer: {
background: background:

View file

@ -184,6 +184,16 @@ export default {
color: `${foreground} !important`, color: `${foreground} !important`,
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: blue,
},
'&$checked + $track': {
backgroundColor: blue,
},
},
},
NDAlbumGridView: { NDAlbumGridView: {
albumName: { albumName: {
marginTop: '0.5rem', marginTop: '0.5rem',

View file

@ -184,6 +184,16 @@ export default {
color: `${foreground} !important`, color: `${foreground} !important`,
}, },
}, },
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: blue,
},
'&$checked + $track': {
backgroundColor: blue,
},
},
},
NDAlbumGridView: { NDAlbumGridView: {
albumName: { albumName: {
marginTop: '0.5rem', marginTop: '0.5rem',

View file

@ -63,8 +63,6 @@ const useCurrentTheme = () => {
...theme.props, ...theme.props,
MuiUseMediaQuery: { noSsr: true }, MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true }, MuiPopover: { disableScrollLock: true },
// MUI defaults to secondary, which many themes use as a surface color
MuiSwitch: { color: 'primary' },
}, },
}), }),
[theme], [theme],

View file

@ -3,10 +3,6 @@ import { Provider } from 'react-redux'
import { createStore } from 'redux' import { createStore } from 'redux'
import mediaQuery from 'css-mediaquery' import mediaQuery from 'css-mediaquery'
import { renderHook } from '@testing-library/react-hooks' import { renderHook } from '@testing-library/react-hooks'
import { render, screen } from '@testing-library/react'
import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import themes from './index'
import useCurrentTheme from './useCurrentTheme' import useCurrentTheme from './useCurrentTheme'
import { themeReducer } from '../reducers/themeReducer' import { themeReducer } from '../reducers/themeReducer'
import { AUTO_THEME_ID } from '../consts' import { AUTO_THEME_ID } from '../consts'
@ -165,27 +161,4 @@ describe('useCurrentTheme', () => {
expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
}) })
}) })
describe('switch color', () => {
it.each(Object.keys(themes))(
'renders switches with the primary color in %s',
(theme) => {
const { result } = renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider store={createStore(themeReducer, { theme })}>
{children}
</Provider>
),
})
render(
<ThemeProvider theme={createMuiTheme(result.current)}>
<Switch checked onChange={() => {}} />
</ThemeProvider>,
)
const switchBase = screen
.getByRole('checkbox')
.closest('.MuiSwitch-switchBase')
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
},
)
})
}) })