Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/actions/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
33 changes: 30 additions & 3 deletions src/components/generics/Details.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className="details">{children}</div>
}
Expand Down Expand Up @@ -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 (
<DetailAny icon={icon} name={name} className="long-text">
<DetailAny
icon={icon}
name={name}
className={classNames('long-text', {
notifiable: isDisplayable(notifications),
})}
>
<CSSTransition
classNames="reveal"
in={revealed}
Expand All @@ -70,14 +84,22 @@ export function DetailLongText({ children, icon, name }) {
<div className="controls">
<button
className="control neutral square"
onClick={() => setRevealed(true)}
onClick={() => {
if (typeof onExpand === 'function') {
onExpand()
}
setRevealed(true)
}}
>
<span className="icon">
<i className="las la-plus-square"></i>
</span>
</button>
</div>
)}
{isDisplayable(notifications) && (
<div className="notifications">{notifications}</div>
)}
</DetailAny>
)
}
Expand All @@ -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,
]),
}
1 change: 1 addition & 0 deletions src/components/generics/Notification.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default function Notification({
message: messageInState,
fields: fieldsInState,
} = alterationResponse || {}

useEffect(() => {
if (!status || !date) {
return
Expand Down
50 changes: 49 additions & 1 deletion src/components/library/widgets/SongExpanded.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,20 +13,37 @@ 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
const setQuery = (query) => {
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) {
Expand Down Expand Up @@ -146,8 +166,36 @@ export default function SongExpanded({ query, song }) {
// lyrics
let lyrics
if (song.lyrics_preview) {
const lyricsNotification = (
<Notification
alterationResponse={{
status: statusLyrics,
date: -1, // XXX There should be a valid date here
}}
pendingMessage={false}
successfulMessage={false}
failedMessage="Error fetching lyrics"
noDisplayOnMount
/>
)

lyrics = (
<DetailLongText icon="la-align-left" name="Lyrics">
<DetailLongText
icon="la-align-left"
name="Lyrics"
onExpand={() => {
// 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}
</DetailLongText>
)
Expand Down
100 changes: 90 additions & 10 deletions src/reducers/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,7 +34,7 @@ export const WorkLinkName = Object.freeze({
* Generators for library content
*/

const generateDefaultLibrary = (libraryKey) => ({
const generateLibraryDefaultState = (libraryKey) => ({
status: null,
data: {
pagination: {
Expand All @@ -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
}
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/style/components/generics/_details.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Loading