mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-26 02:07:18 +01:00
feat(library): implement library selection functionality with reducer and UI component
Signed-off-by: Deluan <deluan@navidrome.org> # Conflicts: # .github/copilot-instructions.md # Conflicts: # .gitignore
This commit is contained in:
parent
db167f56b9
commit
1713396da2
11 changed files with 307 additions and 53 deletions
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
|
|
@ -1,53 +0,0 @@
|
|||
# Navidrome Code Guidelines
|
||||
|
||||
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
- Follow standard Go conventions and idioms
|
||||
- Use context propagation for cancellation signals
|
||||
- Write unit tests for new functionality using Ginkgo/Gomega
|
||||
- Use mutex appropriately for concurrent operations
|
||||
- Implement interfaces for dependencies to facilitate testing
|
||||
|
||||
### Frontend (React)
|
||||
- Use functional components with hooks
|
||||
- Follow React best practices for state management
|
||||
- Implement PropTypes for component properties
|
||||
- Prefer using React-Admin and Material-UI components
|
||||
- Icons should be imported from `react-icons` only
|
||||
- Follow existing patterns for API interaction
|
||||
|
||||
## Repository Structure
|
||||
- `core/`: Server-side business logic (artwork handling, playback, etc.)
|
||||
- `ui/`: React frontend components
|
||||
- `model/`: Data models and repository interfaces
|
||||
- `server/`: API endpoints and server implementation
|
||||
- `utils/`: Shared utility functions
|
||||
- `persistence/`: Database access layer
|
||||
- `scanner/`: Music library scanning functionality
|
||||
|
||||
## Key Guidelines
|
||||
1. Maintain cache management patterns for performance
|
||||
2. Follow the existing concurrency patterns (mutex, atomic)
|
||||
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
|
||||
4. Keep UI components focused and reusable
|
||||
5. Document configuration options in code
|
||||
6. Consider performance implications when working with music libraries
|
||||
7. Follow existing error handling patterns
|
||||
8. Ensure compatibility with external services (LastFM, Spotify, Deezer)
|
||||
|
||||
## Development Workflow
|
||||
- Test changes thoroughly, especially around concurrent operations
|
||||
- Validate both backend and frontend interactions
|
||||
- Consider how changes will affect user experience and performance
|
||||
- Test with different music library sizes and configurations
|
||||
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
|
||||
|
||||
## Important commands
|
||||
- `make build`: Build the application
|
||||
- `make test`: Run Go tests
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- `make lintall`: Run linters
|
||||
- `make format`: Format code
|
||||
|
|
@ -19,6 +19,7 @@ import library from './library'
|
|||
import { Player } from './audioplayer'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
libraryReducer,
|
||||
themeReducer,
|
||||
addToPlaylistDialogReducer,
|
||||
expandInfoDialogReducer,
|
||||
|
|
@ -57,6 +58,7 @@ const adminStore = createAdminStore({
|
|||
dataProvider,
|
||||
history,
|
||||
customReducers: {
|
||||
library: libraryReducer,
|
||||
player: playerReducer,
|
||||
albumView: albumViewReducer,
|
||||
theme: themeReducer,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './library'
|
||||
export * from './player'
|
||||
export * from './themes'
|
||||
export * from './albumView'
|
||||
|
|
|
|||
12
ui/src/actions/library.js
Normal file
12
ui/src/actions/library.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const SET_SELECTED_LIBRARIES = 'SET_SELECTED_LIBRARIES'
|
||||
export const SET_USER_LIBRARIES = 'SET_USER_LIBRARIES'
|
||||
|
||||
export const setSelectedLibraries = (libraryIds) => ({
|
||||
type: SET_SELECTED_LIBRARIES,
|
||||
data: libraryIds,
|
||||
})
|
||||
|
||||
export const setUserLibraries = (libraries) => ({
|
||||
type: SET_USER_LIBRARIES,
|
||||
data: libraries,
|
||||
})
|
||||
208
ui/src/common/LibrarySelector.jsx
Normal file
208
ui/src/common/LibrarySelector.jsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useDataProvider, useTranslate } from 'react-admin'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Paper,
|
||||
Popper,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
|
||||
import { setSelectedLibraries, setUserLibraries } from '../actions'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginBottom: theme.spacing(3),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chip: {
|
||||
borderRadius: theme.spacing(1), // Less rounded corners to better match search input
|
||||
height: theme.spacing(4.8), // Slightly reduce height to better match search input
|
||||
fontSize: '1rem', // Match sidebar menu items font size
|
||||
fontWeight: 'normal', // Match sidebar menu items font weight
|
||||
minWidth: '210px', // Ensure minimum width
|
||||
justifyContent: 'flex-start',
|
||||
paddingLeft: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
marginTop: theme.spacing(0.1),
|
||||
'& .MuiChip-label': {
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(1),
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
fontSize: '1.2rem',
|
||||
marginLeft: theme.spacing(0.5),
|
||||
},
|
||||
},
|
||||
popper: {
|
||||
zIndex: 1300,
|
||||
},
|
||||
paper: {
|
||||
padding: theme.spacing(2),
|
||||
marginTop: theme.spacing(1),
|
||||
minWidth: 300,
|
||||
maxWidth: 400,
|
||||
},
|
||||
headerContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 0,
|
||||
},
|
||||
masterCheckbox: {
|
||||
padding: '7px', // Match the default Material-UI checkbox padding
|
||||
marginLeft: '-9px', // Negative margin to align with FormControlLabel checkboxes
|
||||
marginRight: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
const LibrarySelector = () => {
|
||||
const classes = useStyles()
|
||||
const dispatch = useDispatch()
|
||||
const dataProvider = useDataProvider()
|
||||
const translate = useTranslate()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { userLibraries, selectedLibraries } = useSelector(
|
||||
(state) => state.library,
|
||||
)
|
||||
|
||||
// Load user's libraries when component mounts
|
||||
useEffect(() => {
|
||||
const loadUserLibraries = async () => {
|
||||
// Get userId from localStorage (not from identity which returns username)
|
||||
const userId = localStorage.getItem('userId')
|
||||
if (userId) {
|
||||
try {
|
||||
const { data } = await dataProvider.getOne('user', { id: userId })
|
||||
const libraries = data.libraries || []
|
||||
dispatch(setUserLibraries(libraries))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Could not load user libraries (this may be expected for non-admin users):',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUserLibraries()
|
||||
}, [dataProvider, dispatch])
|
||||
|
||||
// Don't render if user has no libraries or only has one library
|
||||
if (!userLibraries.length || userLibraries.length === 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleToggle = (event) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleLibraryToggle = (libraryId) => {
|
||||
const newSelection = selectedLibraries.includes(libraryId)
|
||||
? selectedLibraries.filter((id) => id !== libraryId)
|
||||
: [...selectedLibraries, libraryId]
|
||||
|
||||
dispatch(setSelectedLibraries(newSelection))
|
||||
}
|
||||
|
||||
const handleMasterCheckboxChange = () => {
|
||||
if (isAllSelected) {
|
||||
dispatch(setSelectedLibraries([]))
|
||||
} else {
|
||||
const allIds = userLibraries.map((lib) => lib.id)
|
||||
dispatch(setSelectedLibraries(allIds))
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = selectedLibraries.length
|
||||
const totalCount = userLibraries.length
|
||||
const isAllSelected = selectedCount === totalCount
|
||||
const isNoneSelected = selectedCount === 0
|
||||
const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
|
||||
|
||||
const displayText = isNoneSelected
|
||||
? translate('menu.librarySelector.none') + ` (0 of ${totalCount})`
|
||||
: isAllSelected
|
||||
? translate('menu.librarySelector.allLibraries', { count: totalCount })
|
||||
: translate('menu.librarySelector.multipleLibraries', {
|
||||
selected: selectedCount,
|
||||
total: totalCount,
|
||||
})
|
||||
|
||||
return (
|
||||
<Box className={classes.root}>
|
||||
<Chip
|
||||
icon={<LibraryMusic />}
|
||||
label={displayText}
|
||||
onClick={handleToggle}
|
||||
onDelete={open ? handleToggle : undefined}
|
||||
deleteIcon={open ? <ExpandLess /> : <ExpandMore />}
|
||||
variant="outlined"
|
||||
clickable
|
||||
className={classes.chip}
|
||||
/>
|
||||
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-start"
|
||||
className={classes.popper}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<Paper className={classes.paper}>
|
||||
<Box className={classes.headerContainer}>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={handleMasterCheckboxChange}
|
||||
size="small"
|
||||
className={classes.masterCheckbox}
|
||||
/>
|
||||
<Typography>
|
||||
{translate('menu.librarySelector.selectLibraries')}:
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl component="fieldset" variant="standard" fullWidth>
|
||||
<FormGroup>
|
||||
{userLibraries.map((library) => (
|
||||
<FormControlLabel
|
||||
key={library.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedLibraries.includes(library.id)}
|
||||
onChange={() => handleLibraryToggle(library.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={library.name}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default LibrarySelector
|
||||
44
ui/src/common/useLibrarySelection.js
Normal file
44
ui/src/common/useLibrarySelection.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useSelector } from 'react-redux'
|
||||
|
||||
/**
|
||||
* Hook to get the currently selected library IDs
|
||||
* Returns an array of library IDs that should be used for filtering data
|
||||
* If no libraries are selected (empty array), returns all user accessible libraries
|
||||
*/
|
||||
export const useSelectedLibraries = () => {
|
||||
const { userLibraries, selectedLibraries } = useSelector(
|
||||
(state) => state.library,
|
||||
)
|
||||
|
||||
// If no specific selection, default to all accessible libraries
|
||||
if (selectedLibraries.length === 0 && userLibraries.length > 0) {
|
||||
return userLibraries.map((lib) => lib.id)
|
||||
}
|
||||
|
||||
return selectedLibraries
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get library filter parameters for data provider queries
|
||||
* Returns an object that can be spread into query parameters
|
||||
*/
|
||||
export const useLibraryFilter = () => {
|
||||
const selectedLibraryIds = useSelectedLibraries()
|
||||
|
||||
// If user has access to only one library or no specific selection, no filter needed
|
||||
if (selectedLibraryIds.length <= 1) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
libraryIds: selectedLibraryIds,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific library is currently selected
|
||||
*/
|
||||
export const useIsLibrarySelected = (libraryId) => {
|
||||
const selectedLibraryIds = useSelectedLibraries()
|
||||
return selectedLibraryIds.includes(libraryId)
|
||||
}
|
||||
|
|
@ -517,6 +517,12 @@
|
|||
},
|
||||
"menu": {
|
||||
"library": "Library",
|
||||
"librarySelector": {
|
||||
"allLibraries": "All Libraries (%{count})",
|
||||
"multipleLibraries": "%{selected} of %{total} Libraries",
|
||||
"selectLibraries": "Select Libraries",
|
||||
"none": "None"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"version": "Version",
|
||||
"theme": "Theme",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import SubMenu from './SubMenu'
|
|||
import { humanize, pluralize } from 'inflection'
|
||||
import albumLists from '../album/albumLists'
|
||||
import PlaylistsSubMenu from './PlaylistsSubMenu'
|
||||
import LibrarySelector from '../common/LibrarySelector'
|
||||
import config from '../config'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
|
|
@ -111,6 +112,7 @@ const Menu = ({ dense = false }) => {
|
|||
[classes.closed]: !open,
|
||||
})}
|
||||
>
|
||||
{open && <LibrarySelector />}
|
||||
<SubMenu
|
||||
handleToggle={() => handleToggle('menuAlbumList')}
|
||||
isOpen={state.menuAlbumList}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './libraryReducer'
|
||||
export * from './themeReducer'
|
||||
export * from './dialogReducer'
|
||||
export * from './playerReducer'
|
||||
|
|
|
|||
30
ui/src/reducers/libraryReducer.js
Normal file
30
ui/src/reducers/libraryReducer.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions'
|
||||
|
||||
const initialState = {
|
||||
userLibraries: [],
|
||||
selectedLibraries: [], // Empty means "all accessible libraries"
|
||||
}
|
||||
|
||||
export const libraryReducer = (previousState = initialState, payload) => {
|
||||
const { type, data } = payload
|
||||
switch (type) {
|
||||
case SET_USER_LIBRARIES:
|
||||
return {
|
||||
...previousState,
|
||||
userLibraries: data,
|
||||
// If this is the first time setting user libraries and no selection exists,
|
||||
// default to all libraries
|
||||
selectedLibraries:
|
||||
previousState.selectedLibraries.length === 0
|
||||
? data.map((lib) => lib.id)
|
||||
: previousState.selectedLibraries,
|
||||
}
|
||||
case SET_SELECTED_LIBRARIES:
|
||||
return {
|
||||
...previousState,
|
||||
selectedLibraries: data,
|
||||
}
|
||||
default:
|
||||
return previousState
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ const createAdminStore = ({
|
|||
const state = store.getState()
|
||||
saveState({
|
||||
theme: state.theme,
|
||||
library: state.library,
|
||||
player: (({ queue, volume, savedPlayIndex }) => ({
|
||||
queue,
|
||||
volume,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue