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;
}