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. 93
      client/src/components/windows/song/EditSongDialog.tsx
  4. 28
      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 SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveAlbumChanges } from '../../../lib/saveChanges';
import { modifyAlbum } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
@ -152,7 +152,7 @@ export function AlbumWindowControlled(props: {
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
saveAlbumChanges(props.state.id, pendingChanges || {})
modifyAlbum(props.state.id, pendingChanges || {})
.then(() => {
setApplying(false);
props.dispatch({

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

@ -1,8 +1,9 @@
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 StoreLinkIcon, { ExternalStore, whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search';
import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete';
@ -19,13 +20,13 @@ export function ProvideLinksWidget(props: {
store: ExternalStore,
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>(
props.providers.length > 0 ? 0 : undefined
);
let [query, setQuery] = useState<string>(
`${props.metadata.title}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.albums && ` ${props.metadata.albums[0].name}`}`
)
let [results, setResults] = useState<IntegrationSong[]>([]);
let [query, setQuery] = useState<string>(defaultQuery)
let [results, setResults] = useState<IntegrationSong[] | undefined>(undefined);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined;
@ -34,8 +35,16 @@ export function ProvideLinksWidget(props: {
(l: string) => whichStore(l) === props.store
) : 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">
<Typography>Search using:</Typography>
<Box ml={2} />
<Select
value={selectedProviderIdx}
onChange={(e: any) => setSelectedProviderIdx(e.target.value)}
@ -44,20 +53,23 @@ export function ProvideLinksWidget(props: {
return <MenuItem value={idx}>{p.properties.name}</MenuItem>
})}
</Select>
<TextField
value={query}
onChange={(e: any) => setQuery(e.target.value)}
/>
<Button
onClick={() => {
selectedProvider?.integration.searchSong(query, 10)
.then((songs: IntegrationSong[]) => setResults(songs))
}}
>Search</Button>
</Box>
<TextField
value={query}
onChange={(e: any) => setQuery(e.target.value)}
label="Query"
fullWidth
/>
<IconButton
onClick={() => {
selectedProvider?.integration.searchSong(query, 10)
.then((songs: IntegrationSong[]) => setResults(songs))
}}
><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl>
<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}"
${result.artist && ` by ${result.artist.name}`}
${result.album && ` (${result.album.name})`}`;
@ -72,22 +84,24 @@ export function ProvideLinksWidget(props: {
</Box>}
/>
})}
{results && results.length === 0 && <Typography>No results were found. Try adjusting the query manually.</Typography>}
</RadioGroup>
</FormControl>
</Box>
</Box >
}
export function ExternalLinksEditor(props: {
metadata: SongMetadata,
original: SongMetadata,
onChange: (v: SongMetadata) => void,
}) {
let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations();
let linksSet: Record<string, string | null> =
$enum(ExternalStore).getValues().reduce((prev: any, store: string) => {
let getLinksSet = (metadata: SongMetadata) => {
return $enum(ExternalStore).getValues().reduce((prev: any, store: string) => {
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) {
maybeLink = link;
}
@ -96,7 +110,11 @@ export function ExternalLinksEditor(props: {
...prev,
[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 providers: IntegrationState[] = Array.isArray(integrations.state) ?
@ -113,16 +131,22 @@ export function ExternalLinksEditor(props: {
{$enum(ExternalStore).getValues().map((store: string, idx: number) => {
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
selected={selectedIdx === idx}
onClick={(e: any) => setSelectedIdx(idx)}
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>
<ListItemText primary={store} />
<ListItemText style={{ color: color }} primary={store} />
{maybeLink && <a href={maybeLink} target="_blank">
<ListItemIcon><IconButton><OpenInNewIcon /></IconButton></ListItemIcon>
<ListItemIcon><IconButton><OpenInNewIcon style={{ color: color }} /></IconButton></ListItemIcon>
</a>}
{maybeLink && <ListItemIcon><IconButton
onClick={() => {
@ -134,7 +158,7 @@ export function ExternalLinksEditor(props: {
storeLinks: newLinks,
});
}}
><DeleteIcon />
><DeleteIcon style={{ color: color }} />
</IconButton></ListItemIcon>}
</ListItem>
})}
@ -178,7 +202,6 @@ export default function EditSongDialog(props: {
}
let [editingMetadata, setEditingMetadata] = useState<SongMetadata>(props.metadata);
let [activeTab, setActiveTab] = useState<EditSongTabs>(EditSongTabs.Details);
return <Dialog
maxWidth="lg"
@ -186,11 +209,25 @@ export default function EditSongDialog(props: {
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<Typography>Under construction</Typography>
<Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
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>
}

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

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

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

@ -1,7 +1,7 @@
import * as serverApi from '../api';
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 = {
method: 'PUT',
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) {
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) {
export async function modifyArtist(id: number, change: serverApi.ModifyArtistRequest) {
const requestOpts = {
method: 'PUT',
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 = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

Loading…
Cancel
Save