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. - -Run on PikaPods -
-Deploy with Zenith -
-Deploy on ElfHosted - - - - +[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. +[![PikaPods](https://www.pikapods.com/static/run-button.svg)](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 @@ -Deploy on ElfHosted - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Deploy on ElfHosted 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') - }, - ) - }) })