diff --git a/src/actions/library.js b/src/actions/library.js index 61f93ba4..419c28d9 100644 --- a/src/actions/library.js +++ b/src/actions/library.js @@ -40,6 +40,42 @@ export const loadLibraryEntries = (library, page = 1, query, type) => { return fetchLibraryEntries(url, library, type) } +/** + * Get song lyrics + */ + +export const SONG_LYRICS_REQUEST = 'SONG_LYRICS_REQUEST' +export const SONG_LYRICS_SUCCESS = 'SONG_LYRICS_SUCCESS' +export const SONG_LYRICS_FAILURE = 'SONG_LYRICS_FAILURE' + +/** + * Load song lyrics from the server + * @param id ID of the song to get lyrics from + */ +export const loadSongLyrics = (id) => ({ + [FETCH_API]: { + endpoint: `${baseUrl}/library/songs/lyrics/${id}/`, + method: 'GET', + types: [SONG_LYRICS_REQUEST, SONG_LYRICS_SUCCESS, SONG_LYRICS_FAILURE], + }, + id, +}) + +/** + * Clear song lyrics status + */ + +export const SONG_LYRICS_STATUS_CLEAR = 'SONG_LYRICS_STATUS_CLEAR' + +/** + * Clear song lyrics status in state + * @param id ID of the song. + */ +export const clearSongLyricsStatus = (id) => ({ + type: SONG_LYRICS_STATUS_CLEAR, + id, +}) + /** * Get work types */ diff --git a/src/components/generics/Details.jsx b/src/components/generics/Details.jsx index 17907821..f6d9e908 100644 --- a/src/components/generics/Details.jsx +++ b/src/components/generics/Details.jsx @@ -3,6 +3,8 @@ import PropTypes from 'prop-types' import { useState } from 'react' import { CSSTransition } from 'react-transition-group' +import { isDisplayable } from 'utils' + export function Details({ children }) { return
{children}
} @@ -50,11 +52,23 @@ DetailText.propTypes = { name: PropTypes.string, } -export function DetailLongText({ children, icon, name }) { +export function DetailLongText({ + children, + icon, + name, + onExpand, + notifications, +}) { const [revealed, setRevealed] = useState(false) return ( - + )} + {isDisplayable(notifications) && ( +
{notifications}
+ )}
) } @@ -86,4 +108,9 @@ DetailLongText.propTypes = { children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), icon: PropTypes.string, name: PropTypes.string, + onExpand: PropTypes.func, + notifications: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.element), + PropTypes.element, + ]), } diff --git a/src/components/generics/Notification.jsx b/src/components/generics/Notification.jsx index f3881443..163a23db 100644 --- a/src/components/generics/Notification.jsx +++ b/src/components/generics/Notification.jsx @@ -47,6 +47,7 @@ export default function Notification({ message: messageInState, fields: fieldsInState, } = alterationResponse || {} + useEffect(() => { if (!status || !date) { return diff --git a/src/components/library/widgets/SongExpanded.jsx b/src/components/library/widgets/SongExpanded.jsx index 06e73027..209b85f4 100644 --- a/src/components/library/widgets/SongExpanded.jsx +++ b/src/components/library/widgets/SongExpanded.jsx @@ -1,6 +1,9 @@ import PropTypes from 'prop-types' +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { useSearchParams } from 'react-router' +import { clearSongLyricsStatus, loadSongLyrics } from 'actions/library' import { DetailAny, DetailLongText, @@ -10,13 +13,19 @@ import { import HighlighterQuery from 'components/generics/HighlighterQuery' import { ListingEntry } from 'components/generics/listing/Entry' import ListingList from 'components/generics/listing/List' +import Notification from 'components/generics/Notification' import SongTagList from 'components/library/SongTagList' import ArtistWidget from 'components/library/widgets/Artist' import WorkLinkWidget from 'components/library/widgets/WorkLink' +import { Status } from 'reducers/alterationsResponse' import { songPropType } from 'serverPropTypes/library' export default function SongExpanded({ query, song }) { const [_, setSearchParams] = useSearchParams() + const dispatch = useDispatch() + const statusLyrics = useSelector( + (state) => state.library.song.statusesLyrics[song.id] + ) // Method used by child components WorkEntry and ArtistsEnty to set new // search criteria @@ -24,6 +33,17 @@ export default function SongExpanded({ query, song }) { setSearchParams({ query, page: 1 }) } + // Clear song lyrics status on unmount to prevent re-displaying notification + useEffect( + () => { + return () => { + dispatch(clearSongLyricsStatus(song.id)) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + // works let works if (song.works.length > 0) { @@ -146,8 +166,36 @@ export default function SongExpanded({ query, song }) { // lyrics let lyrics if (song.lyrics_preview) { + const lyricsNotification = ( + + ) + lyrics = ( - + { + // only fetch full lyrics if there is more to display, and if not + // fetched already + if ( + song.lyrics_preview.truncated && + statusLyrics !== Status.successful + ) { + dispatch(loadSongLyrics(song.id)) + } + }} + notifications={lyricsNotification} + noDisplayOnMount + > {song.lyrics_preview.text} ) diff --git a/src/reducers/library.js b/src/reducers/library.js index 1b8a1a88..de3ac794 100644 --- a/src/reducers/library.js +++ b/src/reducers/library.js @@ -4,6 +4,10 @@ import { LIBRARY_FAILURE, LIBRARY_REQUEST, LIBRARY_SUCCESS, + SONG_LYRICS_FAILURE, + SONG_LYRICS_REQUEST, + SONG_LYRICS_STATUS_CLEAR, + SONG_LYRICS_SUCCESS, WORK_TYPES_FAILURE, WORK_TYPES_REQUEST, WORK_TYPES_SUCCESS, @@ -30,7 +34,7 @@ export const WorkLinkName = Object.freeze({ * Generators for library content */ -const generateDefaultLibrary = (libraryKey) => ({ +const generateLibraryDefaultState = (libraryKey) => ({ status: null, data: { pagination: { @@ -43,10 +47,9 @@ const generateDefaultLibrary = (libraryKey) => ({ }, }) -const generateLibraryReducer = (libraryType) => { - const defaultLibrary = generateDefaultLibrary(libraryType) - - return (state = defaultLibrary, action) => { +const generateLibraryReducer = + (libraryType, defaultState) => + (state = defaultState, action) => { if (action.libraryType !== libraryType) { return state } @@ -60,39 +63,116 @@ const generateLibraryReducer = (libraryType) => { case LIBRARY_SUCCESS: return { + ...state, status: Status.successful, data: updateData(action.response, libraryType), } case LIBRARY_FAILURE: return { + ...state, status: Status.failed, - data: defaultLibrary.data, + data: defaultState.data, } default: return state } } -} /** * Song library */ -const song = generateLibraryReducer('songs') +const songLibraryDefaultState = generateLibraryDefaultState('songs') +const songDefaultState = { + ...songLibraryDefaultState, + statusesLyrics: {}, +} +const songLibraryReducer = generateLibraryReducer('songs', songDefaultState) +const song = (stateLibrary = songDefaultState, action) => { + const state = songLibraryReducer(stateLibrary, action) + + switch (action.type) { + case SONG_LYRICS_REQUEST: + return { + ...state, + statusesLyrics: { + ...state.statusesLyrics, + [action.id]: Status.pending, + }, + } + + case SONG_LYRICS_SUCCESS: { + // add the lyrics to the corresponding song + const songs = window.structuredClone(state.data.songs) + const songId = songs.findIndex((song) => song.id === action.id) + songs[songId].lyrics_preview.text = action.response.lyrics + + return { + ...state, + statusesLyrics: { + ...state.statusesLyrics, + [action.id]: Status.successful, + }, + data: { + ...state.data, + songs, + }, + } + } + + case SONG_LYRICS_FAILURE: + return { + ...state, + statusesLyrics: { + ...state.statusesLyrics, + [action.id]: Status.failed, + }, + } + + case SONG_LYRICS_STATUS_CLEAR: + // only clear a non successful status + if (state.statusesLyrics[action.id] === Status.successful) { + return state + } else { + return { + ...state, + statusesLyrics: { + ...state.statusesLyrics, + [action.id]: null, + }, + } + } + + case LIBRARY_REQUEST: + if (action.libraryType === 'songs') { + // reset lyrics statuses as the list of songs is being fetched + return { + ...state, + statusesLyrics: [], + } + } + + return state + + default: + return state + } +} /** * Artist library */ -const artist = generateLibraryReducer('artists') +const artistLibraryDefaultState = generateLibraryDefaultState('artists') +const artist = generateLibraryReducer('artists', artistLibraryDefaultState) /** * Work library */ -const defaultWork = generateDefaultLibrary('works') +const defaultWork = generateLibraryDefaultState('works') function works(state = {}, action) { // create works when work types have been successfuly fetched diff --git a/src/style/components/generics/_details.scss b/src/style/components/generics/_details.scss index 9898b3b0..9f736884 100644 --- a/src/style/components/generics/_details.scss +++ b/src/style/components/generics/_details.scss @@ -114,7 +114,7 @@ &.reveal-enter-active { max-height: 10 * $height; - transition: max-height 300ms ease-out; + transition: max-height 600ms ease-out; .paragraph { -webkit-line-clamp: none; @@ -140,6 +140,13 @@ white-space: pre-wrap; } + .notified { + // display at the bottom of the field + bottom: 0; + height: $height; + top: unset; + } + > .controls { align-self: end; }