Editability improved (but submit not working), add tables to edit pages

pull/21/head
Sander Vocke 5 years ago
parent 0193e42b51
commit 935614d12f
  1. 29
      client/src/components/MainWindow.tsx
  2. 9
      client/src/components/appbar/AppBar.tsx
  3. 93
      client/src/components/common/EditableText.tsx
  4. 28
      client/src/components/common/StoreLinkIcon.tsx
  5. 12
      client/src/components/common/SubmitChangesButton.tsx
  6. 7
      client/src/components/tables/ResultsTable.tsx
  7. 139
      client/src/components/windows/AlbumWindow.tsx
  8. 135
      client/src/components/windows/ArtistWindow.tsx
  9. 33
      client/src/components/windows/QueryWindow.tsx
  10. 107
      client/src/components/windows/SongWindow.tsx
  11. 8
      client/src/components/windows/TagWindow.tsx
  12. 17
      client/src/components/windows/Windows.tsx
  13. 28
      client/src/lib/songGetters.tsx

@ -1,5 +1,5 @@
import React, { useReducer, useState, Reducer } from 'react'; import React, { useReducer, Reducer } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme, withWidth } from '@material-ui/core'; import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors'; import { grey } from '@material-ui/core/colors';
import AppBar from './appbar/AppBar'; import AppBar from './appbar/AppBar';
import QueryWindow from './windows/QueryWindow'; import QueryWindow from './windows/QueryWindow';
@ -48,7 +48,6 @@ export function MainWindowReducer(state: MainWindowState, action: any) {
activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab, activeTab: state.activeTab >= (newSize - 1) ? (newSize - 1) : state.activeTab,
} }
case MainWindowStateActions.AddTab: case MainWindowStateActions.AddTab:
console.log("Add tab: ", action)
return { return {
...state, ...state,
tabStates: [...state.tabStates, action.tabState], tabStates: [...state.tabStates, action.tabState],
@ -65,17 +64,33 @@ export function MainWindowReducer(state: MainWindowState, action: any) {
}) })
} }
default: default:
throw new Error("Unimplemented QueryWindow state update.") throw new Error("Unimplemented MainWindow state update.")
} }
} }
export default function MainWindow(props: any) { export default function MainWindow(props: any) {
const [state, dispatch] = useReducer(MainWindowReducer, { const [state, dispatch] = useReducer(MainWindowReducer, {
tabStates: [ tabStates: [
newWindowState[WindowType.Query]() newWindowState[WindowType.Query](),
newWindowState[WindowType.Song](),
newWindowState[WindowType.Album](),
newWindowState[WindowType.Artist](),
newWindowState[WindowType.Tag](),
],
tabReducers: [
newWindowReducer[WindowType.Query],
newWindowReducer[WindowType.Song],
newWindowReducer[WindowType.Album],
newWindowReducer[WindowType.Artist],
newWindowReducer[WindowType.Tag],
],
tabTypes: [
WindowType.Query,
WindowType.Song,
WindowType.Album,
WindowType.Artist,
WindowType.Tag,
], ],
tabReducers: [newWindowReducer[WindowType.Query]],
tabTypes: [WindowType.Query],
activeTab: 0 activeTab: 0
}) })

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React from 'react';
import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core'; import { AppBar as MuiAppBar, Box, Tab as MuiTab, Tabs, IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close'; import CloseIcon from '@material-ui/icons/Close';
import AddIcon from '@material-ui/icons/Add'; import AddIcon from '@material-ui/icons/Add';
@ -61,7 +61,12 @@ export default function AppBar(props: IProps) {
<Box m={0.5} display="flex" alignItems="center"> <Box m={0.5} display="flex" alignItems="center">
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> <img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img>
</Box> </Box>
<Tabs value={props.selectedTab} onChange={(e: any, v: number) => props.setSelectedTab(v)}> <Tabs
value={props.selectedTab}
onChange={(e: any, v: number) => props.setSelectedTab(v)}
variant="scrollable"
scrollButtons="auto"
>
{props.tabLabels.map((l: string, idx: number) => <Tab {props.tabLabels.map((l: string, idx: number) => <Tab
label={l} label={l}
value={idx} value={idx}

@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Box, IconButton, TextField } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import UndoIcon from '@material-ui/icons/Undo';
import { useTheme } from '@material-ui/core/styles';
// This component is an editable text. It shows up as normal text,
// but will display an edit icon on hover. When clicked, this
// enables a text input to make a new suggestion.
// The text can show a striked-through version of the old text,
// with the new value next to it and an undo button.
export interface IProps {
defaultValue: string,
changedValue: string | null, // Null == not changed
editingValue: string | null, // Null == not editing
editingLabel: string,
onChangeEditingValue: (v: string | null) => void,
onChangeChangedValue: (v: string | null) => void,
}
export default function EditableText(props: IProps) {
let editingValue = props.editingValue;
let defaultValue = props.defaultValue;
let changedValue = props.changedValue;
let onChangeEditingValue = props.onChangeEditingValue;
let onChangeChangedValue = props.onChangeChangedValue;
let editing = editingValue !== null;
let editingLabel = props.editingLabel;
const theme = useTheme();
const [hovering, setHovering] = useState<Boolean>(false);
const editButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => onChangeEditingValue(changedValue || defaultValue)}
>
<EditIcon />
</IconButton>
</Box>
const discardChangesButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => {
onChangeChangedValue(null);
onChangeEditingValue(null);
}}
>
<UndoIcon />
</IconButton>
</Box>
if (editing) {
return <Box display="flex" alignItems="center">
<TextField
variant="outlined"
value={editingValue || ""}
label={editingLabel}
inputProps={{ style: { fontSize: '2rem' } }}
onChange={(e: any) => onChangeEditingValue(e.target.value)}
/>
<IconButton
onClick={() => {
onChangeChangedValue(editingValue === defaultValue ? null : editingValue);
onChangeEditingValue(null);
}}
><CheckIcon /></IconButton>
</Box>
} else if (changedValue) {
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>
<del style={{ color: theme.palette.text.secondary }}>{defaultValue}</del>
{changedValue}
{editButton}
{discardChangesButton}
</Box>
}
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>{defaultValue}{editButton}</Box>;
}

@ -0,0 +1,28 @@
import React from 'react';
import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg';
export enum ExternalStore {
GooglePlayMusic = "GPM",
}
export interface IProps {
whichStore: ExternalStore,
}
export function whichStore(url: string) {
if(url.includes('play.google.com')) {
return ExternalStore.GooglePlayMusic;
}
return undefined;
}
export default function StoreLinkIcon(props: any) {
const { whichStore, ...restProps } = props;
switch(whichStore) {
case ExternalStore.GooglePlayMusic:
return <GPMIcon {...restProps}/>;
default:
throw new Error("Unknown external store: " + whichStore)
}
}

@ -0,0 +1,12 @@
import React from 'react';
import { Box, Button } from '@material-ui/core';
export default function SubmitChangesButton(props: any) {
return <Box>
<Button
variant="contained" color="secondary"
>
Save Changes
</Button>
</Box>
}

@ -7,6 +7,7 @@ import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album'; import AlbumIcon from '@material-ui/icons/Album';
import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import LocalOfferIcon from '@material-ui/icons/LocalOffer'; import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import { songGetters } from '../../lib/songGetters';
export interface SongGetters { export interface SongGetters {
getTitle: (song: any) => string, getTitle: (song: any) => string,
@ -25,7 +26,7 @@ export interface IProps {
mainDispatch: (action: any) => void, mainDispatch: (action: any) => void,
} }
export function SongTable(props: IProps) { export default function SongTable(props: IProps) {
const useTableStyles = makeStyles({ const useTableStyles = makeStyles({
table: { table: {
minWidth: 650, minWidth: 650,
@ -66,6 +67,8 @@ export function SongTable(props: IProps) {
tabLabel: <><PersonIcon />{mainArtistName}</>, tabLabel: <><PersonIcon />{mainArtistName}</>,
artistId: mainArtistId, artistId: mainArtistId,
metadata: null, metadata: null,
songGetters: songGetters,
songsByArtist: null,
}, },
tabReducer: newWindowReducer[WindowType.Artist], tabReducer: newWindowReducer[WindowType.Artist],
tabType: WindowType.Artist, tabType: WindowType.Artist,
@ -79,6 +82,8 @@ export function SongTable(props: IProps) {
tabLabel: <><AlbumIcon />{mainAlbumName}</>, tabLabel: <><AlbumIcon />{mainAlbumName}</>,
albumId: mainAlbumId, albumId: mainAlbumId,
metadata: null, metadata: null,
songGetters: songGetters,
songsOnAlbum: null,
}, },
tabReducer: newWindowReducer[WindowType.Album], tabReducer: newWindowReducer[WindowType.Album],
tabType: WindowType.Album, tabType: WindowType.Album,

@ -1,26 +1,39 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core';
import AlbumIcon from '@material-ui/icons/Album'; import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
var _ = require('lodash');
export interface AlbumMetadata { export type AlbumMetadata = serverApi.AlbumDetails;
name: string, export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest;
}
export interface AlbumWindowState extends WindowState { export interface AlbumWindowState extends WindowState {
albumId: number, albumId: number,
metadata: AlbumMetadata | null, metadata: AlbumMetadata | null,
pendingChanges: AlbumMetadataChanges | null,
songsOnAlbum: any[] | null,
songGetters: SongGetters,
} }
export enum AlbumWindowStateActions { export enum AlbumWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
} }
export function AlbumWindowReducer(state: AlbumWindowState, action: any) { export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
switch (action.type) { switch (action.type) {
case AlbumWindowStateActions.SetMetadata: case AlbumWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case AlbumWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case AlbumWindowStateActions.SetSongs:
return { ...state, songsOnAlbum: action.value }
default: default:
throw new Error("Unimplemented AlbumWindow state update.") throw new Error("Unimplemented AlbumWindow state update.")
} }
@ -63,25 +76,96 @@ export async function getAlbumMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let album = json.albums[0]; let album = json.albums[0];
return { return album;
name: album.name
}
})(); })();
} }
export default function AlbumWindow(props: IProps) { export default function AlbumWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the album's metadata.
useEffect(() => { useEffect(() => {
getAlbumMetadata(props.state.albumId) getAlbumMetadata(props.state.albumId)
.then((m: AlbumMetadata) => { .then((m: AlbumMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: AlbumWindowStateActions.SetMetadata, type: AlbumWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.name]); }, [metadata?.name]);
// Effect to get the album's songs.
useEffect(() => {
if(props.state.songsOnAlbum) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.albumId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.albumId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: AlbumWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsOnAlbum]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: AlbumWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<SubmitChangesButton />
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -89,13 +173,42 @@ export default function AlbumWindow(props: IProps) {
mt={4} mt={4}
width="80%" width="80%"
> >
<AlbumIcon style={{ fontSize: 80 }}/> <PersonIcon style={{ fontSize: 80 }} />
</Box> </Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.name}</Typography>} {metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs in this album in your library:</Typography>
</Box>
{props.state.songsOnAlbum && <SongTable
songs={props.state.songsOnAlbum}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsOnAlbum && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -1,26 +1,39 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core';
import PersonIcon from '@material-ui/icons/Person'; import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
var _ = require('lodash');
export interface ArtistMetadata { export type ArtistMetadata = serverApi.ArtistDetails;
name: string, export type ArtistMetadataChanges = serverApi.ModifyArtistRequest;
}
export interface ArtistWindowState extends WindowState { export interface ArtistWindowState extends WindowState {
artistId: number, artistId: number,
metadata: ArtistMetadata | null, metadata: ArtistMetadata | null,
pendingChanges: ArtistMetadataChanges | null,
songsByArtist: any[] | null,
songGetters: SongGetters,
} }
export enum ArtistWindowStateActions { export enum ArtistWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
SetSongs = "SetSongs",
} }
export function ArtistWindowReducer(state: ArtistWindowState, action: any) { export function ArtistWindowReducer(state: ArtistWindowState, action: any) {
switch (action.type) { switch (action.type) {
case ArtistWindowStateActions.SetMetadata: case ArtistWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case ArtistWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
case ArtistWindowStateActions.SetSongs:
return { ...state, songsByArtist: action.value }
default: default:
throw new Error("Unimplemented ArtistWindow state update.") throw new Error("Unimplemented ArtistWindow state update.")
} }
@ -63,25 +76,96 @@ export async function getArtistMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let artist = json.artists[0]; let artist = json.artists[0];
return { return artist;
name: artist.name
}
})(); })();
} }
export default function ArtistWindow(props: IProps) { export default function ArtistWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
// Effect to get the artist's metadata.
useEffect(() => { useEffect(() => {
getArtistMetadata(props.state.artistId) getArtistMetadata(props.state.artistId)
.then((m: ArtistMetadata) => { .then((m: ArtistMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: ArtistWindowStateActions.SetMetadata, type: ArtistWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.name]); }, [metadata?.name]);
// Effect to get the artist's songs.
useEffect(() => {
if(props.state.songsByArtist) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.artistId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.artistId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
props.dispatch({
type: ArtistWindowStateActions.SetSongs,
value: json.songs,
});
})();
}, [props.state.songsByArtist]);
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: ArtistWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<SubmitChangesButton />
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -95,7 +179,36 @@ export default function ArtistWindow(props: IProps) {
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.name}</Typography>} {metadata && <Box>
<Box m={2}>
{name}
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
>
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Songs by this artist in your library:</Typography>
</Box>
{props.state.songsByArtist && <SongTable
songs={props.state.songsByArtist}
songGetters={props.state.songGetters}
mainDispatch={props.mainDispatch}
/>}
{!props.state.songsByArtist && <CircularProgress />}
</Box> </Box>
</Box> </Box>
} }

@ -3,8 +3,8 @@ import { createMuiTheme, Box, LinearProgress } from '@material-ui/core';
import { QueryElem, toApiQuery } from '../../lib/query/Query'; import { QueryElem, toApiQuery } from '../../lib/query/Query';
import QueryBuilder from '../querybuilder/QueryBuilder'; import QueryBuilder from '../querybuilder/QueryBuilder';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { SongTable } from '../tables/ResultsTable'; import SongTable from '../tables/ResultsTable';
import stringifyList from '../../lib/stringifyList'; import { songGetters } from '../../lib/songGetters';
import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters'; import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters';
import { grey } from '@material-ui/core/colors'; import { grey } from '@material-ui/core/colors';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
@ -72,35 +72,6 @@ export default function QueryWindow(props: IProps) {
const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query)); const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query));
const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : []; const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : [];
const songGetters = {
getTitle: (song: any) => song.title,
getId: (song: any) => song.songId,
getArtistNames: (song: any) => song.artists.map((a: any) => a.name),
getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId),
getAlbumNames: (song: any) => song.albums.map((a: any) => a.name),
getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId),
getTagNames: (song: any) => {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [tag.name];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
getTagIds: (song: any) => {
// Recursively resolve the id.
const resolveTag = (tag: any) => {
var r = [tag.tagId];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
}
const doQuery = async (_query: QueryElem) => { const doQuery = async (_query: QueryElem) => {
var q: serverApi.QueryRequest = { var q: serverApi.QueryRequest = {
query: toApiQuery(_query), query: toApiQuery(_query),

@ -1,26 +1,36 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography, IconButton, Button } from '@material-ui/core';
import AudiotrackIcon from '@material-ui/icons/Audiotrack'; import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
import { ArtistMetadata } from './ArtistWindow';
import { AlbumMetadata } from './AlbumWindow';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
export interface SongMetadata { export type SongMetadata = serverApi.SongDetails;
title: string, export type SongMetadataChanges = serverApi.ModifySongRequest;
}
export interface SongWindowState extends WindowState { export interface SongWindowState extends WindowState {
songId: number, songId: number,
metadata: SongMetadata | null, metadata: SongMetadata | null,
pendingChanges: SongMetadataChanges | null,
} }
export enum SongWindowStateActions { export enum SongWindowStateActions {
SetMetadata = "SetMetadata", SetMetadata = "SetMetadata",
SetPendingChanges = "SetPendingChanges",
} }
export function SongWindowReducer(state: SongWindowState, action: any) { export function SongWindowReducer(state: SongWindowState, action: any) {
switch (action.type) { switch (action.type) {
case SongWindowStateActions.SetMetadata: case SongWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case SongWindowStateActions.SetPendingChanges:
return { ...state, pendingChanges: action.value }
default: default:
throw new Error("Unimplemented SongWindow state update.") throw new Error("Unimplemented SongWindow state update.")
} }
@ -63,25 +73,69 @@ export async function getSongMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let song = json.songs[0]; let song = json.songs[0];
return { return song;
title: song.title
}
})(); })();
} }
export default function SongWindow(props: IProps) { export default function SongWindow(props: IProps) {
let metadata = props.state.metadata; let metadata = props.state.metadata;
let pendingChanges = props.state.pendingChanges;
useEffect(() => { useEffect(() => {
getSongMetadata(props.state.songId) getSongMetadata(props.state.songId)
.then((m: SongMetadata) => { .then((m: SongMetadata) => {
console.log("metadata", m);
props.dispatch({ props.dispatch({
type: SongWindowStateActions.SetMetadata, type: SongWindowStateActions.SetMetadata,
value: m value: m
}); });
}) })
}, [props.state.metadata?.title]); }, [metadata?.title]);
const [editingTitle, setEditingTitle] = useState<string | null>(null);
const title = <Typography variant="h4"><EditableText
defaultValue={metadata?.title || "(Unknown title)"}
changedValue={pendingChanges?.title || null}
editingValue={editingTitle}
editingLabel="Title"
onChangeEditingValue={(v: string | null) => setEditingTitle(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if(v) { newVal.title = v }
else { delete newVal.title }
props.dispatch({
type: SongWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: ArtistMetadata) => {
return <Typography>
{artist.name}
</Typography>
});
const albums = metadata?.albums && metadata?.albums.map((album: AlbumMetadata) => {
return <Typography>
{album.name}
</Typography>
});
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
return store && <a
href={link} target="_blank"
>
<IconButton><StoreLinkIcon
whichStore={store}
style={{ height: '40px', width: '40px' }}
/>
</IconButton>
</a>
});
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<SubmitChangesButton/>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -95,7 +149,38 @@ export default function SongWindow(props: IProps) {
m={1} m={1}
width="80%" width="80%"
> >
{metadata && <Typography variant="h4">{metadata.title}</Typography>} {metadata && <Box>
<Box m={2}>
{title}
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<PersonIcon />
<Box m={0.5}>
{artists}
</Box>
</Box>
</Box>
<Box m={0.5}>
<Box display="flex" alignItems="center" m={0.5}>
<AlbumIcon />
<Box m={0.5}>
{albums}
</Box>
</Box>
</Box>
<Box m={1}>
<Box display="flex" alignItems="center" m={0.5}>
{storeLinks}
</Box>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box> </Box>
</Box> </Box>
} }

@ -4,9 +4,7 @@ import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../api'; import * as serverApi from '../../api';
import { WindowState } from './Windows'; import { WindowState } from './Windows';
export interface TagMetadata { export type TagMetadata = serverApi.TagDetails;
name: string,
}
export interface TagWindowState extends WindowState { export interface TagWindowState extends WindowState {
tagId: number, tagId: number,
@ -63,9 +61,7 @@ export async function getTagMetadata(id: number) {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json(); let json: any = await response.json();
let tag = json.tags[0]; let tag = json.tags[0];
return { return tag;
name: tag.name
}
})(); })();
} }

@ -9,6 +9,7 @@ import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import { SongWindowReducer } from './SongWindow'; import { SongWindowReducer } from './SongWindow';
import { AlbumWindowReducer } from './AlbumWindow'; import { AlbumWindowReducer } from './AlbumWindow';
import { TagWindowReducer } from './TagWindow'; import { TagWindowReducer } from './TagWindow';
import { songGetters } from '../../lib/songGetters';
export enum WindowType { export enum WindowType {
Query = "Query", Query = "Query",
@ -41,30 +42,38 @@ export const newWindowState = {
}, },
[WindowType.Artist]: () => { [WindowType.Artist]: () => {
return { return {
tabLabel: <><PersonIcon/>Artist</>, tabLabel: <><PersonIcon/>Artist 1</>,
artistId: 1, artistId: 1,
metadata: null, metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsByArtist: null,
} }
}, },
[WindowType.Album]: () => { [WindowType.Album]: () => {
return { return {
tabLabel: <><AlbumIcon/>Album</>, tabLabel: <><AlbumIcon/>Album 1</>,
albumId: 1, albumId: 1,
metadata: null, metadata: null,
pendingChanges: null,
songGetters: songGetters,
songsOnAlbum: null,
} }
}, },
[WindowType.Song]: () => { [WindowType.Song]: () => {
return { return {
tabLabel: <><AudiotrackIcon/>Song</>, tabLabel: <><AudiotrackIcon/>Song 1</>,
songId: 1, songId: 1,
metadata: null, metadata: null,
pendingChanges: null,
} }
}, },
[WindowType.Tag]: () => { [WindowType.Tag]: () => {
return { return {
tabLabel: <><LocalOfferIcon/>Tag</>, tabLabel: <><LocalOfferIcon/>Tag 1</>,
tagId: 1, tagId: 1,
metadata: null, metadata: null,
pendingChanges: null,
} }
}, },
} }

@ -0,0 +1,28 @@
export const songGetters = {
getTitle: (song: any) => song.title,
getId: (song: any) => song.songId,
getArtistNames: (song: any) => song.artists.map((a: any) => a.name),
getArtistIds: (song: any) => song.artists.map((a: any) => a.artistId),
getAlbumNames: (song: any) => song.albums.map((a: any) => a.name),
getAlbumIds: (song: any) => song.albums.map((a: any) => a.albumId),
getTagNames: (song: any) => {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [tag.name];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
getTagIds: (song: any) => {
// Recursively resolve the id.
const resolveTag = (tag: any) => {
var r = [tag.tagId];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return song.tags.map((tag: any) => resolveTag(tag));
},
}
Loading…
Cancel
Save