diff --git a/client/package-lock.json b/client/package-lock.json index 74d3f63..3718ad2 100644 --- a/client/package-lock.json +++ b/client/package-lock.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", diff --git a/client/package.json b/client/package.json index 7c82c01..444fa2b 100644 --- a/client/package.json +++ b/client/package.json @@ -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" }, diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index 8b3478e..c35fd75 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -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 ; + return ; case ExternalStore.Spotify: - return ; + return ; default: throw new Error("Unknown external store: " + whichStore) } diff --git a/client/src/components/windows/settings/IntegrationSettings.tsx b/client/src/components/windows/settings/IntegrationSettings.tsx index dfb7ade..928fd15 100644 --- a/client/src/components/windows/settings/IntegrationSettings.tsx +++ b/client/src/components/windows/settings/IntegrationSettings.tsx @@ -79,7 +79,7 @@ function EditIntegration(props: { [serverApi.IntegrationType.SpotifyClientCredentials]: - {IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({ + {new IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials](-1).getIcon({ style: { height: '40px', width: '40px' } })} diff --git a/client/src/components/windows/song/EditSongDialog.tsx b/client/src/components/windows/song/EditSongDialog.tsx new file mode 100644 index 0000000..30b0228 --- /dev/null +++ b/client/src/components/windows/song/EditSongDialog.tsx @@ -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( + props.providers.length > 0 ? 0 : undefined + ); + let [query, setQuery] = useState( + `${props.metadata.title} ${props.metadata.artists && props.metadata.artists[0].name}` + ) + let [results, setResults] = useState([]); + + 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 + + + setQuery(e.target.value)} + /> + + + + 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 } + label={ + {pretty} + + + + } + /> + })} + + + +} + +export function ExternalLinksEditor(props: { + metadata: SongMetadata, + onChange: (v: SongMetadata) => void, +}) { + let [selectedIdx, setSelectedIdx] = useState(0); + let integrations = useIntegrations(); + + let linksSet: Record = + $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 + + + + {$enum(ExternalStore).getValues().map((store: string, idx: number) => { + let maybeLink = linksSet[store]; + return setSelectedIdx(idx)} + button + > + {linksSet[store] !== null ? : } + + + {maybeLink && + + } + {maybeLink && { + let newLinks = props.metadata.storeLinks?.filter( + (l: string) => whichStore(l) !== store + ) + props.onChange({ + ...props.metadata, + storeLinks: newLinks, + }); + }} + > + } + + })} + + + + {providers.length === 0 ? + None of your configured integrations provides URL links for {store}. : + { + 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, + }) + } + }} + /> + } + + +} + +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(props.metadata); + let [activeTab, setActiveTab] = useState(EditSongTabs.Details); + + return + setEditingMetadata(v)} + /> + {!_.isEqual(editingMetadata, props.metadata) && Changed!} + + +} \ No newline at end of file diff --git a/client/src/components/windows/song/SongWindow.tsx b/client/src/components/windows/song/SongWindow.tsx index 7cf86ac..b8f761d 100644 --- a/client/src/components/windows/song/SongWindow.tsx +++ b/client/src/components/windows/song/SongWindow.tsx @@ -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(false); useEffect(() => { getSongMetadata(songId) @@ -141,7 +141,19 @@ export function SongWindowControlled(props: { {storeLinks} + + { setEditing(true); }} + > + } + {metadata && { setEditing(false); }} + onSubmit={() => { }} + id={songId} + metadata={metadata} + />} } \ No newline at end of file diff --git a/client/src/lib/integration/Integration.tsx b/client/src/lib/integration/Integration.tsx index 441262f..5ab9e51 100644 --- a/client/src/lib/integration/Integration.tsx +++ b/client/src/lib/integration/Integration.tsx @@ -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 {} @@ -49,11 +51,11 @@ export default class Integration { async getSongs(getSongsParams: any): Promise { return []; } // Requires feature: SearchSongs - async searchSong(songProps: IntegrationSong): Promise { return []; } + async searchSong(query: string, limit: number): Promise { return []; } // Requires feature: SearchAlbum - async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; } + async searchAlbum(query: string, limit: number): Promise { return []; } // Requires feature: SearchArtist - async searchArtist(artistProps: IntegrationArtist): Promise { return []; } + async searchArtist(query: string, limit: number): Promise { return []; } } \ No newline at end of file diff --git a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx index d211907..737fe05 100644 --- a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx +++ b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx @@ -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 } + 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 { return []; } - async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; } - async searchArtist(artistProps: IntegrationArtist): Promise { return []; } + async searchSong(query: string, limit: number): Promise { + return this.search(query, SearchType.Song, limit); + } + async searchAlbum(query: string, limit: number): Promise { return []; } + async searchArtist(query: string, limit: number): Promise { return []; } - async search(query: string, type: SearchType): + async search(query: string, type: SearchType, limit: number): Promise { 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, diff --git a/server/package-lock.json b/server/package-lock.json index 7d49ca9..13702bb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index a05b69b..ae78a00 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }