diff --git a/README.md b/README.md
index e1a2955a1..4bc85e6a6 100644
--- a/README.md
+++ b/README.md
@@ -35,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting
-Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
-
-
-
-
-
-
-
-
-
-
+[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
+[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
+A share of the revenue helps fund the development of Navidrome at no additional cost for you.
+[](https://www.pikapods.com/pods?run=navidrome)
## Features
diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go
index fb07332fe..40baa2495 100644
--- a/core/artwork/resolve.go
+++ b/core/artwork/resolve.go
@@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
- tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
- if tracks == nil {
- 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()"})
+ albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
+ GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil {
return resolution{}, err
}
diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go
index 2a36531bb..da144d8e2 100644
--- a/core/artwork/resolve_test.go
+++ b/core/artwork/resolve_test.go
@@ -707,16 +707,6 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred())
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{}))
- })
})
})
diff --git a/core/artwork/worker.go b/core/artwork/worker.go
index 4be99f92e..28e51958c 100644
--- a/core/artwork/worker.go
+++ b/core/artwork/worker.go
@@ -4,11 +4,9 @@ import (
"bytes"
"cmp"
"context"
- "fmt"
"io"
"math"
"math/rand/v2"
- "runtime/debug"
"sync"
"time"
@@ -246,7 +244,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
- out, got, retryIn := w.safeAcquire(ctx, item)
+ out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue()
switch out {
@@ -288,20 +286,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
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
// 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) {
diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go
index 80ca68bc3..a6c07b763 100644
--- a/core/artwork/worker_test.go
+++ b/core/artwork/worker_test.go
@@ -142,18 +142,6 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
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 {
repo := tests.CreateMockUserRepo()
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")
})
- 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() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
diff --git a/core/storage/local/local.go b/core/storage/local/local.go
index e2ce00a1b..686838565 100644
--- a/core/storage/local/local.go
+++ b/core/storage/local/local.go
@@ -76,17 +76,6 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
}
-// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
-var _ fs.ReadLinkFS = (*localFS)(nil)
-
-func (lfs *localFS) ReadLink(name string) (string, error) {
- return fs.ReadLink(lfs.FS, name)
-}
-
-func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
- return fs.Lstat(lfs.FS, name)
-}
-
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
res, err := lfs.extractor.Parse(path...)
if err != nil {
diff --git a/resources/hosting/elfhosted.svg b/resources/hosting/elfhosted.svg
deleted file mode 100644
index 86bc86df9..000000000
--- a/resources/hosting/elfhosted.svg
+++ /dev/null
@@ -1,77 +0,0 @@
-
diff --git a/resources/hosting/pikapods.svg b/resources/hosting/pikapods.svg
deleted file mode 100644
index a3865feea..000000000
--- a/resources/hosting/pikapods.svg
+++ /dev/null
@@ -1,25 +0,0 @@
-Run on PikaPods
-
-Run on PikaPods Button
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/hosting/zenith.svg b/resources/hosting/zenith.svg
deleted file mode 100644
index 0b5b11539..000000000
--- a/resources/hosting/zenith.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-Deploy with Zenith
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 238919259..1a0c1ab8a 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -660,6 +660,11 @@
"options": {
"theme": "Tema",
"language": "Língua",
+ "dateFormat": "Formato de data",
+ "dateFormats": {
+ "language": "Igual à língua",
+ "browser": "Igual ao navegador"
+ },
"defaultView": "Tela inicial",
"desktop_notifications": "Notificações",
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go
index d39cad5f6..1864a6a44 100644
--- a/scanner/walk_dir_tree.go
+++ b/scanner/walk_dir_tree.go
@@ -43,13 +43,6 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
continue
}
- // A full walk never descends into symlinked folders when following is disabled, so a
- // target reached through one (e.g. a watcher event for a new link) is skipped too.
- if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) {
- log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath)
- continue
- }
-
// Create checker and push patterns from root to this folder
checker := newIgnoreChecker(job.fs)
err = checker.PushAllParents(ctx, folderPath)
@@ -232,18 +225,6 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
return fileInfo.IsDir(), nil
}
-// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link.
-// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected.
-func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
- for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
- info, err := fs.Lstat(fsys, p)
- if err == nil && info.Mode()&fs.ModeSymlink != 0 {
- return true
- }
- }
- return false
-}
-
const maxSymlinkHops = 40
// resolveEntryName returns the name to classify the entry by, and whether to
diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go
index 0c2aba3e0..43939e5c2 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -4,10 +4,8 @@ import (
"context"
"fmt"
"io/fs"
- "maps"
"os"
"path/filepath"
- "slices"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@@ -262,44 +260,6 @@ var _ = Describe("walk_dir_tree", func() {
// Folders not in targets should remain in lastUpdates
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
})
-
- // #6292: a watcher event for a new folder symlink makes the link itself a scan target
- Context("symlinked target folders (production local storage FS)", func() {
- BeforeEach(func() {
- libRoot := GinkgoT().TempDir()
- Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed())
- Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
- Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed())
- job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}}
- })
-
- walkTargets := func(targets ...string) map[string]*folderEntry {
- results, err := walkDirTree(ctx, job, targets...)
- Expect(err).ToNot(HaveOccurred())
- folders := map[string]*folderEntry{}
- for folder := range results {
- folders[folder.path] = folder
- }
- return folders
- }
-
- DescribeTable("with FollowSymlinks disabled",
- func(target string, expected ...string) {
- conf.Server.Scanner.FollowSymlinks = false
- Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected))
- },
- Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"),
- Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"),
- Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"),
- )
-
- It("walks a symlinked target when FollowSymlinks is enabled", func() {
- conf.Server.Scanner.FollowSymlinks = true
- folders := walkTargets("Wolfgang Amadeus Mozart")
- Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1"))
- Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3"))
- })
- })
})
})
@@ -473,8 +433,8 @@ var _ = Describe("walk_dir_tree", func() {
})
// Regression for #5752: the production localFS must resolve file symlinks.
- // fs.ReadLink-based resolution can't follow targets outside the library
- // root, so full OS-level resolution is required.
+ // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
+ // resolution is not available and full OS-level resolution is required.
Context("production local storage FS", func() {
var libRoot string
var musicFS storage.MusicFS
@@ -500,7 +460,12 @@ var _ = Describe("walk_dir_tree", func() {
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
- musicFS = newLocalMusicFS(libRoot)
+ u, err := storage.LocalPathToURL(libRoot)
+ Expect(err).ToNot(HaveOccurred())
+ s, err := storage.For(u.String())
+ Expect(err).ToNot(HaveOccurred())
+ musicFS, err = s.FS()
+ Expect(err).ToNot(HaveOccurred())
})
walkRoot := func() *folderEntry {
@@ -735,17 +700,6 @@ func getDirEntry(baseDir, name string) os.DirEntry {
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
}
-// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot
-func newLocalMusicFS(libRoot string) storage.MusicFS {
- u, err := storage.LocalPathToURL(libRoot)
- Expect(err).ToNot(HaveOccurred())
- s, err := storage.For(u.String())
- Expect(err).ToNot(HaveOccurred())
- musicFS, err := s.FS()
- Expect(err).ToNot(HaveOccurred())
- return musicFS
-}
-
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
type mockMusicFS struct {
storage.MusicFS
diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx
index 075841e43..1de904031 100644
--- a/ui/src/album/AlbumInfo.jsx
+++ b/ui/src/album/AlbumInfo.jsx
@@ -8,7 +8,6 @@ import {
ArrayField,
BooleanField,
ChipField,
- DateField,
FunctionField,
SingleFieldList,
TextField,
@@ -17,6 +16,7 @@ import {
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import {
+ DateField,
ArtistLinkField,
MultiLineTextField,
ParticipantsInfo,
diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx
index 051d5988e..46a86c6bc 100644
--- a/ui/src/album/AlbumTableView.jsx
+++ b/ui/src/album/AlbumTableView.jsx
@@ -3,7 +3,6 @@ import {
Datagrid,
DatagridBody,
DatagridRow,
- DateField,
NumberField,
TextField,
FunctionField,
@@ -13,6 +12,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd'
import {
+ DateField,
ArtistLinkField,
ArtworkAvatar,
DurationField,
diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx
index dac9cff08..8aab34ae1 100644
--- a/ui/src/common/DateField.jsx
+++ b/ui/src/common/DateField.jsx
@@ -1,12 +1,12 @@
import React from 'react'
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'
export const DateField = (props) => {
- const { record, source } = props
+ const record = useRecordContext(props)
const locale = useDateLocale()
- const value = record?.[source]
+ const value = record?.[props.source]
if (!isDateSet(value)) return null
return
}
diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx
index 47d56756b..7494b7887 100644
--- a/ui/src/common/DateField.test.jsx
+++ b/ui/src/common/DateField.test.jsx
@@ -1,6 +1,7 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { RecordContextProvider } from 'react-admin'
import { DateField } from './DateField'
vi.mock('react-admin', async (importOriginal) => ({
@@ -29,4 +30,13 @@ describe('', () => {
)
expect(container).toBeEmptyDOMElement()
})
+
+ it('reads the record from the record context', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('17.9.2026')).toBeInTheDocument()
+ })
})
diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx
index 1b1a014f1..b9f7bea8c 100644
--- a/ui/src/common/SongInfo.jsx
+++ b/ui/src/common/SongInfo.jsx
@@ -6,7 +6,6 @@ import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import {
BooleanField,
- DateField,
TextField,
NumberField,
FunctionField,
@@ -15,6 +14,7 @@ import {
} from 'react-admin'
import { humanize, underscore } from 'inflection'
import {
+ DateField,
ArtistLinkField,
BitrateField,
ParticipantsInfo,
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index f694ea75f..8f086f516 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -677,6 +677,11 @@
"options": {
"theme": "Theme",
"language": "Language",
+ "dateFormat": "Date Format",
+ "dateFormats": {
+ "language": "Match language",
+ "browser": "Match browser"
+ },
"defaultView": "Default View",
"desktop_notifications": "Desktop Notifications",
"lastfmNotConfigured": "Last.fm API-Key is not configured",
diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js
index c9298a473..f02402f29 100644
--- a/ui/src/i18n/useDateLocale.js
+++ b/ui/src/i18n/useDateLocale.js
@@ -1,5 +1,8 @@
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"
// as en-US. Borrow the region from the browser when it speaks the same language.
const resolveDateLocale = (locale, browserLocales = []) => {
@@ -10,5 +13,10 @@ const resolveDateLocale = (locale, browserLocales = []) => {
)
}
-export const useDateLocale = () =>
- resolveDateLocale(useLocale(), navigator.languages)
+// With the "browser" date format, return undefined so Intl uses the browser's
+// own locale, regardless of the selected language.
+export const useDateLocale = () => {
+ const locale = useLocale()
+ if (getStoredDateFormat() === 'browser') return undefined
+ return resolveDateLocale(locale, navigator.languages)
+}
diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js
index c0ea5ab40..16707a084 100644
--- a/ui/src/i18n/useDateLocale.test.js
+++ b/ui/src/i18n/useDateLocale.test.js
@@ -9,6 +9,7 @@ vi.mock('react-admin', () => ({
describe('useDateLocale', () => {
beforeEach(() => {
vi.clearAllMocks()
+ localStorage.removeItem('dateFormat')
})
const renderWith = async (locale, browserLocales) => {
@@ -42,4 +43,10 @@ describe('useDateLocale', () => {
const result = await renderWith(undefined, ['en-GB'])
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()
+ })
})
diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx
index a09f552d9..64ab0487d 100644
--- a/ui/src/missing/MissingFilesList.jsx
+++ b/ui/src/missing/MissingFilesList.jsx
@@ -1,4 +1,5 @@
import {
+ DateField,
List,
Pagination,
SizeField,
@@ -7,7 +8,6 @@ import {
} from '../common/index'
import {
Datagrid,
- DateField,
TextField,
downloadCSV,
Filter,
diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.jsx
index 84f9b63e6..75c4dd45a 100644
--- a/ui/src/personal/Personal.jsx
+++ b/ui/src/personal/Personal.jsx
@@ -2,6 +2,7 @@ import { SimpleForm, Title, useTranslate } from 'react-admin'
import { Card } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { SelectLanguage } from './SelectLanguage'
+import { SelectDateFormat } from './SelectDateFormat'
import { SelectTheme } from './SelectTheme'
import { SelectDefaultView } from './SelectDefaultView'
import { NotificationsToggle } from './NotificationsToggle'
@@ -24,6 +25,7 @@ const Personal = () => {
+
{config.enableReplayGain && }
diff --git a/ui/src/personal/SelectDateFormat.jsx b/ui/src/personal/SelectDateFormat.jsx
new file mode 100644
index 000000000..fdc9583d2
--- /dev/null
+++ b/ui/src/personal/SelectDateFormat.jsx
@@ -0,0 +1,22 @@
+import { SelectInput, useTranslate } from 'react-admin'
+import { getStoredDateFormat } from '../i18n/useDateLocale'
+
+export const SelectDateFormat = (props) => {
+ const translate = useTranslate()
+
+ return (
+ {
+ localStorage.setItem('dateFormat', event.target.value)
+ }}
+ />
+ )
+}
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index e1695e980..56f0bf532 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -1,7 +1,6 @@
import React, { useMemo } from 'react'
import {
Datagrid,
- DateField,
EditButton,
Filter,
NullableBooleanInput,
@@ -20,6 +19,7 @@ import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core'
import {
+ DateField,
ArtworkAvatar,
DurationField,
List,
@@ -95,7 +95,6 @@ export const ToggleField = ({ resource, source }) => {
return (
diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx
index c05833166..6c714b827 100644
--- a/ui/src/playlist/PlaylistList.test.jsx
+++ b/ui/src/playlist/PlaylistList.test.jsx
@@ -2,7 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TestContext } from 'ra-test'
-import { RecordContextProvider } from 'react-admin'
import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
vi.mock('../config', () => ({
@@ -15,7 +14,6 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
),
- isWritable: (ownerId) => ownerId === 'me',
}))
describe('', () => {
@@ -57,28 +55,3 @@ describe('playlist toggles without a record', () => {
expect(container.innerHTML).toBe('')
})
})
-
-// Secondary is a surface color in many themes, so these toggles must use primary
-describe('', () => {
- const renderToggle = (record) =>
- render(
-
-
-
-
- ,
- )
-
- 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')
- })
-})
diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx
index 0cfb2cd68..46e59ffcc 100644
--- a/ui/src/radio/RadioList.jsx
+++ b/ui/src/radio/RadioList.jsx
@@ -3,7 +3,6 @@ import React, { cloneElement } from 'react'
import {
CreateButton,
Datagrid,
- DateField,
EditButton,
Filter,
sanitizeListRestProps,
@@ -15,6 +14,7 @@ import {
useTranslate,
} from 'react-admin'
import {
+ DateField,
List,
defaultRowsPerPageOptions,
getStoredPerPage,
diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js
index 45559c3af..2e4ae38e5 100644
--- a/ui/src/themes/dracula.js
+++ b/ui/src/themes/dracula.js
@@ -185,6 +185,16 @@ export default {
color: `${foreground} !important`,
},
},
+ MuiSwitch: {
+ colorSecondary: {
+ '&$checked': {
+ color: green,
+ },
+ '&$checked + $track': {
+ backgroundColor: green,
+ },
+ },
+ },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js
index 3e2955dcd..0f4cbd7c4 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -121,6 +121,16 @@ export default {
boxShadow: '3px 3px 5px #3c3836',
},
},
+ MuiSwitch: {
+ colorSecondary: {
+ '&$checked': {
+ color: '#458588',
+ },
+ '&$checked + $track': {
+ backgroundColor: '#458588',
+ },
+ },
+ },
NDMobileArtistDetails: {
bgContainer: {
background:
diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js
index 9f6424b77..07d372a6b 100644
--- a/ui/src/themes/tokyoNight.js
+++ b/ui/src/themes/tokyoNight.js
@@ -184,6 +184,16 @@ export default {
color: `${foreground} !important`,
},
},
+ MuiSwitch: {
+ colorSecondary: {
+ '&$checked': {
+ color: blue,
+ },
+ '&$checked + $track': {
+ backgroundColor: blue,
+ },
+ },
+ },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js
index a61c0fe87..f84cd0be9 100644
--- a/ui/src/themes/tokyoNightLight.js
+++ b/ui/src/themes/tokyoNightLight.js
@@ -184,6 +184,16 @@ export default {
color: `${foreground} !important`,
},
},
+ MuiSwitch: {
+ colorSecondary: {
+ '&$checked': {
+ color: blue,
+ },
+ '&$checked + $track': {
+ backgroundColor: blue,
+ },
+ },
+ },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js
index fbb5e9bc8..4ccefe820 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -63,8 +63,6 @@ const useCurrentTheme = () => {
...theme.props,
MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true },
- // MUI defaults to secondary, which many themes use as a surface color
- MuiSwitch: { color: 'primary' },
},
}),
[theme],
diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx
index 6553d9866..65c3be8c6 100644
--- a/ui/src/themes/useCurrentTheme.test.jsx
+++ b/ui/src/themes/useCurrentTheme.test.jsx
@@ -3,10 +3,6 @@ import { Provider } from 'react-redux'
import { createStore } from 'redux'
import mediaQuery from 'css-mediaquery'
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 { themeReducer } from '../reducers/themeReducer'
import { AUTO_THEME_ID } from '../consts'
@@ -165,27 +161,4 @@ describe('useCurrentTheme', () => {
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 }) => (
-
- {children}
-
- ),
- })
- render(
-
- {}} />
- ,
- )
- const switchBase = screen
- .getByRole('checkbox')
- .closest('.MuiSwitch-switchBase')
- expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
- },
- )
- })
})