Edit dialog can now search and select Spotify links. No save or close button yet.

editsong
Sander Vocke 5 years ago
parent 8f9e3bd184
commit 9c661d67ee
  1. 5
      client/package-lock.json
  2. 1
      client/package.json
  3. 17
      client/src/components/common/StoreLinkIcon.tsx
  4. 2
      client/src/components/windows/settings/IntegrationSettings.tsx
  5. 196
      client/src/components/windows/song/EditSongDialog.tsx
  6. 20
      client/src/components/windows/song/SongWindow.tsx
  7. 12
      client/src/lib/integration/Integration.tsx
  8. 44
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  9. 5
      server/package-lock.json
  10. 1
      server/package.json

@ -13368,6 +13368,11 @@
"punycode": "^2.1.0"
}
},
"ts-enum-util": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/ts-enum-util/-/ts-enum-util-4.0.2.tgz",
"integrity": "sha512-BB5qjvHYgYgOB/CaoA1Cy/B2QNnZ+nVBrJ15VV/AXGWx+AO83k5wgeLOJvkSLoKKavvH/M8Wj4ZbgROjsuYwzw=="
},
"ts-pnp": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.6.tgz",

@ -27,6 +27,7 @@
"react-error-boundary": "^3.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.3",
"ts-enum-util": "^4.0.2",
"typescript": "~3.7.2",
"uuid": "^8.3.0"
},

@ -3,7 +3,7 @@ import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg
import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg';
export enum ExternalStore {
GooglePlayMusic = "GPM",
GooglePlayMusic = "Google Play Music",
Spotify = "Spotify",
}
@ -12,20 +12,25 @@ export interface IProps {
}
export function whichStore(url: string) {
if(url.includes('play.google.com')) {
if (url.includes('play.google.com')) {
return ExternalStore.GooglePlayMusic;
} else if (url.includes('spotify.com')) {
return ExternalStore.Spotify;
}
return undefined;
}
export default function StoreLinkIcon(props: any) {
const { whichStore, ...restProps } = props;
const { whichStore, style, ...restProps } = props;
switch(whichStore) {
let realStyle = (style === undefined) ?
{ height: '40px', width: '40px' } : style;
switch (whichStore) {
case ExternalStore.GooglePlayMusic:
return <GPMIcon {...restProps}/>;
return <GPMIcon {...restProps} style={realStyle}/>;
case ExternalStore.Spotify:
return <SpotifyIcon {...restProps}/>;
return <SpotifyIcon {...restProps} style={realStyle}/>;
default:
throw new Error("Unknown external store: " + whichStore)
}

@ -79,7 +79,7 @@ function EditIntegration(props: {
[serverApi.IntegrationType.SpotifyClientCredentials]:
<Box display="flex" alignItems="center">
<Box mr={1}>
{IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({
{new IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials](-1).getIcon({
style: { height: '40px', width: '40px' }
})}
</Box>

@ -0,0 +1,196 @@
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 { SongMetadata } from "./SongWindow";
import StoreLinkIcon, { ExternalStore, whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check';
import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete';
import { $enum } from "ts-enum-util";
import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations';
import { IntegrationFeature, IntegrationSong } from '../../../lib/integration/Integration';
import { TabPanel } from '@material-ui/lab';
import { v1 } from 'uuid';
let _ = require('lodash')
export function ProvideLinksWidget(props: {
providers: IntegrationState[],
metadata: SongMetadata,
store: ExternalStore,
onChange: (link: string | undefined) => void,
}) {
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}`
)
let [results, setResults] = useState<IntegrationSong[]>([]);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined;
let currentLink = props.metadata.storeLinks ? props.metadata.storeLinks.find(
(l: string) => whichStore(l) === props.store
) : undefined;
return <Box display="flex" flexDirection="column">
<Box display="flex" alignItems="center">
<Select
value={selectedProviderIdx}
onChange={(e: any) => setSelectedProviderIdx(e.target.value)}
>
{props.providers.map((p: IntegrationState, idx: number) => {
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>
<FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results.map((result: IntegrationSong, idx: number) => {
let pretty = `"${result.title}"
${result.artist && ` by ${result.artist.name}`}
${result.album && ` (${result.album.name})`}`;
return <FormControlLabel
value={result.url || idx}
control={<Radio checked={(result.url || idx) === currentLink} />}
label={<Box display="flex" alignItems="center">
{pretty}
<a href={result.url || ""} target="_blank">
<IconButton><OpenInNewIcon /></IconButton>
</a>
</Box>}
/>
})}
</RadioGroup>
</FormControl>
</Box>
}
export function ExternalLinksEditor(props: {
metadata: 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) => {
var maybeLink: string | null = null;
props.metadata.storeLinks && props.metadata.storeLinks.forEach((link: string) => {
if (whichStore(link) === store) {
maybeLink = link;
}
})
return {
...prev,
[store]: maybeLink,
}
}, {})
let store = $enum(ExternalStore).getValues()[selectedIdx];
let providers: IntegrationState[] = Array.isArray(integrations.state) ?
integrations.state.filter(
(iState: IntegrationState) => (
iState.integration.getFeatures().includes(IntegrationFeature.SearchSong) &&
iState.integration.providesStoreLink() === store
)
) : [];
return <Box display="flex" width="100%">
<Box width="30%">
<List>
{$enum(ExternalStore).getValues().map((store: string, idx: number) => {
let maybeLink = linksSet[store];
return <ListItem
selected={selectedIdx === idx}
onClick={(e: any) => setSelectedIdx(idx)}
button
>
<ListItemIcon>{linksSet[store] !== null ? <CheckIcon /> : <CancelIcon />}</ListItemIcon>
<ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon>
<ListItemText primary={store} />
{maybeLink && <a href={maybeLink} target="_blank">
<ListItemIcon><IconButton><OpenInNewIcon /></IconButton></ListItemIcon>
</a>}
{maybeLink && <ListItemIcon><IconButton
onClick={() => {
let newLinks = props.metadata.storeLinks?.filter(
(l: string) => whichStore(l) !== store
)
props.onChange({
...props.metadata,
storeLinks: newLinks,
});
}}
><DeleteIcon />
</IconButton></ListItemIcon>}
</ListItem>
})}
</List>
</Box>
<Box ml={2} width="60%">
{providers.length === 0 ?
<Typography>None of your configured integrations provides URL links for {store}.</Typography> :
<ProvideLinksWidget
providers={providers}
metadata={props.metadata}
store={store}
onChange={(link: string | undefined) => {
let removed = props.metadata.storeLinks?.filter(
(link: string) => whichStore(link) !== store
) || [];
let newValue = link ? [...removed, link] : removed;
if (!_.isEqual(new Set(newValue), new Set(props.metadata.storeLinks || []))) {
props.onChange({
...props.metadata,
storeLinks: newValue,
})
}
}}
/>
}
</Box>
</Box >
}
export default function EditSongDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: SongMetadata) => void,
id: number,
metadata: SongMetadata,
}) {
enum EditSongTabs {
Details = 0,
ExternalLinks,
}
let [editingMetadata, setEditingMetadata] = useState<SongMetadata>(props.metadata);
let [activeTab, setActiveTab] = useState<EditSongTabs>(EditSongTabs.Details);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<ExternalLinksEditor
metadata={editingMetadata}
onChange={(v: SongMetadata) => setEditingMetadata(v)}
/>
{!_.isEqual(editingMetadata, props.metadata) && <Typography>Changed!</Typography>}
</Dialog>
}

@ -1,5 +1,5 @@
import React, { useEffect, useState, useReducer } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import { Box, Typography, IconButton } from '@material-ui/core';
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
@ -8,12 +8,11 @@ import { WindowState } from '../Windows';
import { ArtistMetadata } from '../artist/ArtistWindow';
import { AlbumMetadata } from '../album/AlbumWindow';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import { saveSongChanges } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { querySongs } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import EditSongDialog from './EditSongDialog';
import EditIcon from '@material-ui/icons/Edit';
export type SongMetadata = serverApi.SongDetails;
@ -66,6 +65,7 @@ export function SongWindowControlled(props: {
}) {
let { metadata, id: songId } = props.state;
let { dispatch } = props;
let [editing, setEditing] = useState<boolean>(false);
useEffect(() => {
getSongMetadata(songId)
@ -141,7 +141,19 @@ export function SongWindowControlled(props: {
{storeLinks}
</Box>
</Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>}
</Box>
{metadata && <EditSongDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={() => { }}
id={songId}
metadata={metadata}
/>}
</Box>
}

@ -1,4 +1,5 @@
import React, { ReactFragment } from 'react';
import { ExternalStore } from '../../components/common/StoreLinkIcon';
export interface IntegrationAlbum {
name?: string,
@ -39,8 +40,9 @@ export default class Integration {
constructor(integrationId: number) { }
// Common
static getFeatures(): IntegrationFeature[] { return []; }
static getIcon(props: any): ReactFragment { return <></> }
getFeatures(): IntegrationFeature[] { return []; }
getIcon(props: any): ReactFragment { return <></> }
providesStoreLink(): ExternalStore | null { return null; }
// Requires feature: Test
async test(testParams: any): Promise<void> {}
@ -49,11 +51,11 @@ export default class Integration {
async getSongs(getSongsParams: any): Promise<IntegrationSong[]> { return []; }
// Requires feature: SearchSongs
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; }
async searchSong(query: string, limit: number): Promise<IntegrationSong[]> { return []; }
// Requires feature: SearchAlbum
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; }
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> { return []; }
// Requires feature: SearchArtist
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; }
async searchArtist(query: string, limit: number): Promise<IntegrationArtist[]> { return []; }
}

@ -3,7 +3,7 @@ import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, I
import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon';
enum SearchType {
Song = 'song',
Song = 'track',
Artist = 'artist',
Album = 'album',
};
@ -16,7 +16,7 @@ export default class SpotifyClientCreds extends Integration {
this.integrationId = integrationId;
}
static getFeatures(): IntegrationFeature[] {
getFeatures(): IntegrationFeature[] {
return [
IntegrationFeature.Test,
IntegrationFeature.SearchSong,
@ -25,53 +25,63 @@ export default class SpotifyClientCreds extends Integration {
]
}
static getIcon(props: any) {
getIcon(props: any) {
return <StoreLinkIcon whichStore={ExternalStore.Spotify} {...props} />
}
providesStoreLink() {
return ExternalStore.Spotify;
}
async test(testParams: {}) {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
if (!response.ok) {
throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response));
throw new Error("Spttify Client Credentials test failed: " + JSON.stringify(response));
}
}
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; }
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; }
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; }
async searchSong(query: string, limit: number): Promise<IntegrationSong[]> {
return this.search(query, SearchType.Song, limit);
}
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> { return []; }
async searchArtist(query: string, limit: number): Promise<IntegrationArtist[]> { return []; }
async search(query: string, type: SearchType):
async search(query: string, type: SearchType, limit: number):
Promise<IntegrationSong[] | IntegrationAlbum[] | IntegrationArtist[]> {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`);
`/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}`);
if (!response.ok) {
throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response));
throw new Error("Spotify Client Credentials search failed: " + JSON.stringify(response));
}
let json = await response.json();
console.log("Response:", json);
switch(type) {
case SearchType.Song: {
return (await response.json()).tracks.items.map((r: any): IntegrationSong => {
return json.tracks.items.map((r: any): IntegrationSong => {
return {
title: r.name,
url: r.external_urls.spotify,
artist: {
name: r.artists[0].name,
url: r.artists[0].external_urls.spotify,
name: r.artists && r.artists[0].name,
url: r.artists && r.artists[0].external_urls.spotify,
},
album: {
name: r.albums[0].name,
url: r.albums[0].external_urls.spotify,
name: r.album && r.album.name,
url: r.album && r.album.external_urls.spotify,
}
}
})
}
case SearchType.Artist: {
return (await response.json()).artists.items.map((r: any): IntegrationArtist => {
return json.artists.items.map((r: any): IntegrationArtist => {
return {
name: r.name,
url: r.external_urls.spotify,
@ -79,7 +89,7 @@ export default class SpotifyClientCreds extends Integration {
})
}
case SearchType.Album: {
return (await response.json()).albums.items.map((r: any): IntegrationAlbum => {
return json.albums.items.map((r: any): IntegrationAlbum => {
return {
name: r.name,
url: r.external_urls.spotify,

@ -3927,6 +3927,11 @@
"punycode": "^2.1.1"
}
},
"ts-enum-util": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/ts-enum-util/-/ts-enum-util-4.0.2.tgz",
"integrity": "sha512-BB5qjvHYgYgOB/CaoA1Cy/B2QNnZ+nVBrJ15VV/AXGWx+AO83k5wgeLOJvkSLoKKavvH/M8Wj4ZbgROjsuYwzw=="
},
"ts-node": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz",

@ -30,6 +30,7 @@
"pg": "^8.3.3",
"querystring": "^0.2.0",
"sqlite3": "^5.0.0",
"ts-enum-util": "^4.0.2",
"ts-node": "^8.10.2",
"typescript": "~3.7.2"
}

Loading…
Cancel
Save