Can submit changes to external links.

editsong
Sander Vocke 5 years ago
parent cd9ee9bbb1
commit d408f312c2
  1. 4
      client/src/components/windows/album/AlbumWindow.tsx
  2. 4
      client/src/components/windows/artist/ArtistWindow.tsx
  3. 77
      client/src/components/windows/song/EditSongDialog.tsx
  4. 14
      client/src/components/windows/song/SongWindow.tsx
  5. 6
      client/src/components/windows/tag/TagWindow.tsx
  6. 2
      client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx
  7. 20
      client/src/lib/saveChanges.tsx

@ -7,7 +7,7 @@ import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable'; import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveAlbumChanges } from '../../../lib/saveChanges'; import { modifyAlbum } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries'; import { queryAlbums, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters'; import { songGetters } from '../../../lib/songGetters';
@ -152,7 +152,7 @@ export function AlbumWindowControlled(props: {
<Box> <Box>
<SubmitChangesButton onClick={() => { <SubmitChangesButton onClick={() => {
setApplying(true); setApplying(true);
saveAlbumChanges(props.state.id, pendingChanges || {}) modifyAlbum(props.state.id, pendingChanges || {})
.then(() => { .then(() => {
setApplying(false); setApplying(false);
props.dispatch({ props.dispatch({

@ -7,7 +7,7 @@ import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable'; import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveArtistChanges } from '../../../lib/saveChanges'; import { modifyArtist } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, querySongs } from '../../../lib/backend/queries'; import { queryArtists, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters'; import { songGetters } from '../../../lib/songGetters';
@ -156,7 +156,7 @@ export function ArtistWindowControlled(props: {
<Box> <Box>
<SubmitChangesButton onClick={() => { <SubmitChangesButton onClick={() => {
setApplying(true); setApplying(true);
saveArtistChanges(props.state.id, pendingChanges || {}) modifyArtist(props.state.id, pendingChanges || {})
.then(() => { .then(() => {
setApplying(false); setApplying(false);
props.dispatch({ props.dispatch({

@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { AppBar, Box, Button, Dialog, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core"; import { AppBar, Box, Button, Dialog, DialogActions, Divider, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core";
import { SongMetadata } from "./SongWindow"; import { SongMetadata } from "./SongWindow";
import StoreLinkIcon, { ExternalStore, whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { ExternalStore, whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search';
import CancelIcon from '@material-ui/icons/Cancel'; import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
@ -19,13 +20,13 @@ export function ProvideLinksWidget(props: {
store: ExternalStore, store: ExternalStore,
onChange: (link: string | undefined) => void, onChange: (link: string | undefined) => void,
}) { }) {
let defaultQuery = `${props.metadata.title}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.albums && ` ${props.metadata.albums[0].name}`}`;
let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>( let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined props.providers.length > 0 ? 0 : undefined
); );
let [query, setQuery] = useState<string>( let [query, setQuery] = useState<string>(defaultQuery)
`${props.metadata.title}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.albums && ` ${props.metadata.albums[0].name}`}` let [results, setResults] = useState<IntegrationSong[] | undefined>(undefined);
)
let [results, setResults] = useState<IntegrationSong[]>([]);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ? let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined; props.providers[selectedProviderIdx] : undefined;
@ -34,8 +35,16 @@ export function ProvideLinksWidget(props: {
(l: string) => whichStore(l) === props.store (l: string) => whichStore(l) === props.store
) : undefined; ) : undefined;
return <Box display="flex" flexDirection="column"> // Ensure results are cleared when input state changes.
useEffect(() => {
setResults(undefined);
setQuery(defaultQuery);
}, [props.store, props.providers, props.metadata])
return <Box display="flex" flexDirection="column" alignItems="left">
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Typography>Search using:</Typography>
<Box ml={2} />
<Select <Select
value={selectedProviderIdx} value={selectedProviderIdx}
onChange={(e: any) => setSelectedProviderIdx(e.target.value)} onChange={(e: any) => setSelectedProviderIdx(e.target.value)}
@ -44,20 +53,23 @@ export function ProvideLinksWidget(props: {
return <MenuItem value={idx}>{p.properties.name}</MenuItem> return <MenuItem value={idx}>{p.properties.name}</MenuItem>
})} })}
</Select> </Select>
</Box>
<TextField <TextField
value={query} value={query}
onChange={(e: any) => setQuery(e.target.value)} onChange={(e: any) => setQuery(e.target.value)}
label="Query"
fullWidth
/> />
<Button <IconButton
onClick={() => { onClick={() => {
selectedProvider?.integration.searchSong(query, 10) selectedProvider?.integration.searchSong(query, 10)
.then((songs: IntegrationSong[]) => setResults(songs)) .then((songs: IntegrationSong[]) => setResults(songs))
}} }}
>Search</Button> ><SearchIcon /></IconButton>
</Box> {results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl> <FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}> <RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results.map((result: IntegrationSong, idx: number) => { {results && results.map((result: IntegrationSong, idx: number) => {
let pretty = `"${result.title}" let pretty = `"${result.title}"
${result.artist && ` by ${result.artist.name}`} ${result.artist && ` by ${result.artist.name}`}
${result.album && ` (${result.album.name})`}`; ${result.album && ` (${result.album.name})`}`;
@ -72,6 +84,7 @@ export function ProvideLinksWidget(props: {
</Box>} </Box>}
/> />
})} })}
{results && results.length === 0 && <Typography>No results were found. Try adjusting the query manually.</Typography>}
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
</Box > </Box >
@ -79,15 +92,16 @@ export function ProvideLinksWidget(props: {
export function ExternalLinksEditor(props: { export function ExternalLinksEditor(props: {
metadata: SongMetadata, metadata: SongMetadata,
original: SongMetadata,
onChange: (v: SongMetadata) => void, onChange: (v: SongMetadata) => void,
}) { }) {
let [selectedIdx, setSelectedIdx] = useState<number>(0); let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations(); let integrations = useIntegrations();
let linksSet: Record<string, string | null> = let getLinksSet = (metadata: SongMetadata) => {
$enum(ExternalStore).getValues().reduce((prev: any, store: string) => { return $enum(ExternalStore).getValues().reduce((prev: any, store: string) => {
var maybeLink: string | null = null; var maybeLink: string | null = null;
props.metadata.storeLinks && props.metadata.storeLinks.forEach((link: string) => { metadata.storeLinks && metadata.storeLinks.forEach((link: string) => {
if (whichStore(link) === store) { if (whichStore(link) === store) {
maybeLink = link; maybeLink = link;
} }
@ -96,7 +110,11 @@ export function ExternalLinksEditor(props: {
...prev, ...prev,
[store]: maybeLink, [store]: maybeLink,
} }
}, {}) }, {});
}
let linksSet: Record<string, string | null> = getLinksSet(props.metadata);
let originalLinksSet: Record<string, string | null> = getLinksSet(props.original);
let store = $enum(ExternalStore).getValues()[selectedIdx]; let store = $enum(ExternalStore).getValues()[selectedIdx];
let providers: IntegrationState[] = Array.isArray(integrations.state) ? let providers: IntegrationState[] = Array.isArray(integrations.state) ?
@ -113,16 +131,22 @@ export function ExternalLinksEditor(props: {
{$enum(ExternalStore).getValues().map((store: string, idx: number) => { {$enum(ExternalStore).getValues().map((store: string, idx: number) => {
let maybeLink = linksSet[store]; let maybeLink = linksSet[store];
let color: string | undefined =
(linksSet[store] && !originalLinksSet[store]) ? "lightgreen" :
(!linksSet[store] && originalLinksSet[store]) ? "red" :
(linksSet[store] && originalLinksSet[store] && linksSet[store] !== originalLinksSet[store]) ? "orange" :
undefined;
return <ListItem return <ListItem
selected={selectedIdx === idx} selected={selectedIdx === idx}
onClick={(e: any) => setSelectedIdx(idx)} onClick={(e: any) => setSelectedIdx(idx)}
button button
> >
<ListItemIcon>{linksSet[store] !== null ? <CheckIcon /> : <CancelIcon />}</ListItemIcon> <ListItemIcon>{linksSet[store] !== null ? <CheckIcon style={{ color: color }} /> : <CancelIcon style={{ color: color }} />}</ListItemIcon>
<ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon> <ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon>
<ListItemText primary={store} /> <ListItemText style={{ color: color }} primary={store} />
{maybeLink && <a href={maybeLink} target="_blank"> {maybeLink && <a href={maybeLink} target="_blank">
<ListItemIcon><IconButton><OpenInNewIcon /></IconButton></ListItemIcon> <ListItemIcon><IconButton><OpenInNewIcon style={{ color: color }} /></IconButton></ListItemIcon>
</a>} </a>}
{maybeLink && <ListItemIcon><IconButton {maybeLink && <ListItemIcon><IconButton
onClick={() => { onClick={() => {
@ -134,7 +158,7 @@ export function ExternalLinksEditor(props: {
storeLinks: newLinks, storeLinks: newLinks,
}); });
}} }}
><DeleteIcon /> ><DeleteIcon style={{ color: color }} />
</IconButton></ListItemIcon>} </IconButton></ListItemIcon>}
</ListItem> </ListItem>
})} })}
@ -178,7 +202,6 @@ export default function EditSongDialog(props: {
} }
let [editingMetadata, setEditingMetadata] = useState<SongMetadata>(props.metadata); let [editingMetadata, setEditingMetadata] = useState<SongMetadata>(props.metadata);
let [activeTab, setActiveTab] = useState<EditSongTabs>(EditSongTabs.Details);
return <Dialog return <Dialog
maxWidth="lg" maxWidth="lg"
@ -186,11 +209,25 @@ export default function EditSongDialog(props: {
open={props.open} open={props.open}
onClose={props.onClose} onClose={props.onClose}
disableBackdropClick={true}> disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<Typography>Under construction</Typography>
<Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor <ExternalLinksEditor
metadata={editingMetadata} metadata={editingMetadata}
original={props.metadata}
onChange={(v: SongMetadata) => setEditingMetadata(v)} onChange={(v: SongMetadata) => setEditingMetadata(v)}
/> />
{!_.isEqual(editingMetadata, props.metadata) && <Typography>Changed!</Typography>} <Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog> </Dialog>
} }

@ -13,6 +13,7 @@ import { querySongs } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import EditSongDialog from './EditSongDialog'; import EditSongDialog from './EditSongDialog';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import { modifySong } from '../../../lib/saveChanges';
export type SongMetadata = serverApi.SongDetails; export type SongMetadata = serverApi.SongDetails;
@ -31,7 +32,7 @@ export function SongWindowReducer(state: SongWindowState, action: any) {
case SongWindowStateActions.SetMetadata: case SongWindowStateActions.SetMetadata:
return { ...state, metadata: action.value } return { ...state, metadata: action.value }
case SongWindowStateActions.Reload: case SongWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null } return { ...state, metadata: null }
default: default:
throw new Error("Unimplemented SongWindow state update.") throw new Error("Unimplemented SongWindow state update.")
} }
@ -68,6 +69,7 @@ export function SongWindowControlled(props: {
let [editing, setEditing] = useState<boolean>(false); let [editing, setEditing] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (metadata === null) {
getSongMetadata(songId) getSongMetadata(songId)
.then((m: SongMetadata) => { .then((m: SongMetadata) => {
dispatch({ dispatch({
@ -75,7 +77,8 @@ export function SongWindowControlled(props: {
value: m value: m
}); });
}) })
}, [songId, dispatch]); }
}, [songId, dispatch, metadata]);
const title = <Typography variant="h4">{metadata?.title || "(Unknown title)"}</Typography> const title = <Typography variant="h4">{metadata?.title || "(Unknown title)"}</Typography>
@ -151,7 +154,12 @@ export function SongWindowControlled(props: {
{metadata && <EditSongDialog {metadata && <EditSongDialog
open={editing} open={editing}
onClose={() => { setEditing(false); }} onClose={() => { setEditing(false); }}
onSubmit={() => { }} onSubmit={(v: serverApi.ModifySongRequest) => {
modifySong(songId, v)
.then(() => dispatch({
type: SongWindowStateActions.Reload
}))
}}
id={songId} id={songId}
metadata={metadata} metadata={metadata}
/>} />}

@ -7,7 +7,7 @@ import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable'; import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveTagChanges } from '../../../lib/saveChanges'; import { modifyTag } from '../../../lib/backend/tags';
import { queryTags, querySongs } from '../../../lib/backend/queries'; import { queryTags, querySongs } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { songGetters } from '../../../lib/songGetters'; import { songGetters } from '../../../lib/songGetters';
@ -45,7 +45,7 @@ export function TagWindowReducer(state: TagWindowState, action: any) {
case TagWindowStateActions.SetSongs: case TagWindowStateActions.SetSongs:
return { ...state, songsWithTag: action.value } return { ...state, songsWithTag: action.value }
case TagWindowStateActions.Reload: case TagWindowStateActions.Reload:
return { ...state, metadata: null, pendingChanges: null, songsWithTag: null } return { ...state, metadata: null, songsWithTag: null }
default: default:
throw new Error("Unimplemented TagWindow state update.") throw new Error("Unimplemented TagWindow state update.")
} }
@ -176,7 +176,7 @@ export function TagWindowControlled(props: {
<Box> <Box>
<SubmitChangesButton onClick={() => { <SubmitChangesButton onClick={() => {
setApplying(true); setApplying(true);
saveTagChanges(props.state.id, pendingChanges || {}) modifyTag(props.state.id, pendingChanges || {})
.then(() => { .then(() => {
setApplying(false); setApplying(false);
props.dispatch({ props.dispatch({

@ -27,8 +27,6 @@ export function extractInitialData(text: string): any | undefined {
// Now parse the data line. // Now parse the data line.
let dataline_clean = dataline.replace(/\\"/g, '"').replace(/\\\\"/g, '\\"') let dataline_clean = dataline.replace(/\\"/g, '"').replace(/\\\\"/g, '\\"')
console.log(dataline);
console.log(dataline_clean);
let json = JSON.parse(dataline_clean); let json = JSON.parse(dataline_clean);
return json; return json;

@ -1,7 +1,7 @@
import * as serverApi from '../api'; import * as serverApi from '../api';
import backendRequest from './backend/request'; import backendRequest from './backend/request';
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) { export async function modifySong(id: number, change: serverApi.ModifySongRequest) {
const requestOpts = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -15,21 +15,7 @@ export async function saveSongChanges(id: number, change: serverApi.ModifySongRe
} }
} }
export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequest) { export async function modifyArtist(id: number, change: serverApi.ModifyArtistRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
};
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString());
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save tag changes: " + response.statusText);
}
}
export async function saveArtistChanges(id: number, change: serverApi.ModifyArtistRequest) {
const requestOpts = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -43,7 +29,7 @@ export async function saveArtistChanges(id: number, change: serverApi.ModifyArti
} }
} }
export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbumRequest) { export async function modifyAlbum(id: number, change: serverApi.ModifyAlbumRequest) {
const requestOpts = { const requestOpts = {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

Loading…
Cancel
Save