diff --git a/client/package-lock.json b/client/package-lock.json index bbec58c..5fbb9c8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8698,6 +8698,11 @@ "react-double-scrollbar": "0.0.15" } }, + "material-ui-nested-menu-item": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/material-ui-nested-menu-item/-/material-ui-nested-menu-item-1.0.2.tgz", + "integrity": "sha512-LZb8xI0FrAI/A3P2vT3CB9bmSoOFWOK0dikTc1t9VvEpp1a8hZkbVUz7VhETnoLUYu3NXCkgulmXcl3zitqI9A==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", diff --git a/client/package.json b/client/package.json index 3fe72d3..fec4ea1 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "jsurl": "^0.1.5", "lodash": "^4.17.20", "material-table": "^1.69.0", + "material-ui-nested-menu-item": "^1.0.2", "react": "^16.13.1", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", diff --git a/client/public/logo.svg b/client/public/logo.svg new file mode 100644 index 0000000..ddec38e --- /dev/null +++ b/client/public/logo.svg @@ -0,0 +1,108 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/App.tsx b/client/src/App.tsx index cfade88..53a1c55 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,131 +1,25 @@ -import React, { useEffect } from 'react'; +import React from 'react'; -import AppBar, { ActiveTab as AppBarActiveTab } from './components/AppBar'; -import { Query, isQuery, QueryKeys, QueryOrdering, OrderKey, TypesIncluded, isTypesIncluded, isQueryOrdering } from './types/Query'; -import QueryBrowseWindow from './components/QueryBrowseWindow'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { HashRouter as Router, Switch, - Route, - useHistory, - useLocation, - Redirect + Route } from "react-router-dom"; - -const JSURL = require('jsurl'); - -function fixQuery(q: any): Query { - if (!isQuery(q)) { - return { - [QueryKeys.TitleLike]: '' - }; - } - return q; -} - -function fixOrder(q: any): QueryOrdering { - if (!isQueryOrdering(q)) { - return { - [QueryKeys.OrderBy]: { - [QueryKeys.OrderKey]: OrderKey.Name, - }, - [QueryKeys.Ascending]: true, - }; - } - return q; -} - -function fixTypes(q: any): TypesIncluded { - if (!isTypesIncluded(q)) { - return { - [QueryKeys.Songs]: true, - [QueryKeys.Artists]: false, - [QueryKeys.Tags]: false, - }; - } - return q; -} - -function AppBody() { - const history = useHistory(); - const location = useLocation(); - const queryParams = new URLSearchParams(location.search); - - const itemQuery: Query | undefined = JSURL.tryParse(queryParams.get('query'), undefined); - const itemOrder: QueryOrdering | undefined = JSURL.tryParse(queryParams.get('order'), undefined); - const itemTypes: TypesIncluded | undefined = JSURL.tryParse(queryParams.get('types'), undefined); - - const pushQuery = ( - q: Query, - o: QueryOrdering, - t: TypesIncluded - ) => { - const newParams = new URLSearchParams(location.search); - newParams.set('query', JSURL.stringify(q)); - newParams.set('order', JSURL.stringify(o)); - newParams.set('types', JSURL.stringify(t)); - history.push({ - search: "?" + newParams.toString() - }) - } - - useEffect(() => { - const fq = fixQuery(itemQuery); - const fo = fixOrder(itemOrder); - const ft = fixTypes(itemTypes); - if (fq !== itemQuery || fo !== itemOrder || ft !== itemTypes) { - pushQuery(fq, fo, ft); - return; - } - }, [ itemOrder, itemQuery, itemTypes ]); - - const onAppBarTabChange = (value: AppBarActiveTab) => { - switch (value) { - case AppBarActiveTab.Query: { - history.push('/query'); - break; - } - } - } - - const onQueryChange = (q: Query) => { - pushQuery(q, fixOrder(itemOrder), fixTypes(itemTypes)); - } - const onOrderChange = (o: QueryOrdering) => { - pushQuery(fixQuery(itemQuery), o, fixTypes(itemTypes)); - } - const onTypesChange = (t: TypesIncluded) => { - pushQuery(fixQuery(itemQuery), fixOrder(itemOrder), t); - } - - return ( -
- - - - - - - -
- ); -} +import Window from './components/Window'; function App() { return ( - + + + + + + ); diff --git a/client/src/api.ts b/client/src/api.ts index 748c80c..812ad32 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -22,6 +22,14 @@ export interface ArtistDetails { export function isArtistDetails(q: any): q is ArtistDetails { return 'artistId' in q; } +export interface AlbumDetails { + albumId: number, + name: string, + storeLinks?: string[], +} +export function isAlbumDetails(q: any): q is ArtistDetails { + return 'albumId' in q; +} export interface TagDetails { tagId: number, name: string, @@ -45,6 +53,7 @@ export interface SongDetails { songId: number, title: string, artists?: ArtistDetails[], + albums?: AlbumDetails[], tags?: TagDetails[], storeLinks?: string[], rankings?: RankingDetails[], diff --git a/client/src/components/AppBar.tsx b/client/src/components/AppBar.tsx deleted file mode 100644 index a05de87..0000000 --- a/client/src/components/AppBar.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import MuiAppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import IconButton from '@material-ui/core/IconButton'; -import Typography from '@material-ui/core/Typography'; -import InputBase from '@material-ui/core/InputBase'; -import { createStyles, fade, Theme, makeStyles } from '@material-ui/core/styles'; -import MenuIcon from '@material-ui/icons/Menu'; -import SearchIcon from '@material-ui/icons/Search'; -import Tabs from '@material-ui/core/Tabs'; -import Tab from '@material-ui/core/Tab'; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - flexGrow: 1, - }, - menuButton: { - marginRight: theme.spacing(2), - }, - title: { - flexGrow: 1, - display: 'none', - [theme.breakpoints.up('sm')]: { - display: 'block', - }, - }, - search: { - position: 'relative', - borderRadius: theme.shape.borderRadius, - backgroundColor: fade(theme.palette.common.white, 0.15), - '&:hover': { - backgroundColor: fade(theme.palette.common.white, 0.25), - }, - marginLeft: 0, - width: '100%', - [theme.breakpoints.up('sm')]: { - marginLeft: theme.spacing(1), - width: 'auto', - }, - }, - searchIcon: { - padding: theme.spacing(0, 2), - height: '100%', - position: 'absolute', - pointerEvents: 'none', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - inputRoot: { - color: 'inherit', - }, - inputInput: { - padding: theme.spacing(1, 1, 1, 0), - // vertical padding + font size from searchIcon - paddingLeft: `calc(1em + ${theme.spacing(4)}px)`, - transition: theme.transitions.create('width'), - width: '100%', - [theme.breakpoints.up('sm')]: { - width: '12ch', - '&:focus': { - width: '20ch', - }, - }, - }, - }), -); - -export enum ActiveTab { - Query = 0, -} - -export interface IProps { - activeTab: ActiveTab, - onActiveTabChange: (tab:ActiveTab) => void -} - -export default function AppBar(props: IProps) { - const classes = useStyles(); - - return ( -
- - - - - - MuDBase -
-
- -
- -
-
- { props.onActiveTabChange(idx); }}> - - -
-
- ); -} diff --git a/client/src/components/BrowseWindow.tsx b/client/src/components/BrowseWindow.tsx deleted file mode 100644 index a117657..0000000 --- a/client/src/components/BrowseWindow.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; - -import { Paper } from '@material-ui/core'; -import { DisplayItem } from '../types/DisplayItem'; -import DraggableItemListItem from './DraggableItemListItem'; -import ItemList from './ItemList'; -import * as serverApi from '../api'; -import StoreIcon from '@material-ui/icons/Store'; -import { ReactComponent as GooglePlayIcon } from '../assets/googleplaymusic_icon.svg'; - -type SongItem = serverApi.SongDetails; -type ArtistItem = serverApi.ArtistDetails; -export type Item = SongItem | ArtistItem; - -const getStoreIcon = (url: String) => { - if (url.includes('play.google.com')) { - return ; - } - return ; -} - -function toDisplayItem(item: Item): DisplayItem | undefined { - if (serverApi.isSongDetails(item)) { - return { - title: item.title, - artistNames: (item.artists && item.artists.map((artist: serverApi.ArtistDetails) => { - return artist.name; - })) || ['Unknown'], - tagNames: (item.tags && item.tags.map((tag: serverApi.TagDetails) => { - return tag.name; - })) || [], - storeLinks: (item.storeLinks && item.storeLinks.map((url: String) => { - return { - icon: getStoreIcon(url), - url: url - } - })) || [], - } - } else if (serverApi.isArtistDetails(item)) { - return { - name: item.name ? item.name : "Unknown", - tagNames: [], // TODO - storeLinks: (item.storeLinks && item.storeLinks.map((url: String) => { - return { - icon: getStoreIcon(url), - url: url - } - })) || [], - }; - - } - return undefined; -} - -interface IProps { - items: Item[] -} - -export default function BrowseWindow(props: IProps) { - return - - {props.items.map((item: Item) => { - const di = toDisplayItem(item); - return di && ; - })} - - ; -} \ No newline at end of file diff --git a/client/src/components/DraggableItemListItem.tsx b/client/src/components/DraggableItemListItem.tsx deleted file mode 100644 index 318b0be..0000000 --- a/client/src/components/DraggableItemListItem.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import ItemListItem from './ItemListItem'; -import { useDrag } from 'react-dnd'; -import { dragTypes } from '../types/DragTypes'; - -export default function DraggableItemListItem(props: any) { - const [ /*{ isDragging: boolean }*/ , drag] = useDrag({ - item: { type: dragTypes.ListItem }, - collect: (monitor: any) => ({ - isDragging: !!monitor.isDragging(), - }), - }); - - return
- -
; -} \ No newline at end of file diff --git a/client/src/components/EditArtistDialog.tsx b/client/src/components/EditArtistDialog.tsx deleted file mode 100644 index 760f5bc..0000000 --- a/client/src/components/EditArtistDialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { Dialog, Grid, Typography, TextField, Button } from '@material-ui/core'; - -var cloneDeep = require('lodash/cloneDeep'); - -export interface ArtistProperties { - name: String, -} - -export interface IProps { - dialogOpen: boolean, - onClose?: () => void, - onChangeArtistProperties?: (props: ArtistProperties) => void, - artistProperties: ArtistProperties, - onSubmit?: () => void, -} - -export default function EditArtistDialog(props: IProps) { - const onNameChange = (name: String) => { - if (props.onChangeArtistProperties) { - const p = cloneDeep(props.artistProperties); - p.name = name; - props.onChangeArtistProperties(p); - } - }; - - return - - Artist Details - - - - onNameChange(i.target.value)} - fullWidth - /> - - - - -} \ No newline at end of file diff --git a/client/src/components/EditSongDialog.tsx b/client/src/components/EditSongDialog.tsx deleted file mode 100644 index 70a60aa..0000000 --- a/client/src/components/EditSongDialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Dialog, Grid, Typography, TextField, Button } from '@material-ui/core'; -import { Autocomplete } from '@material-ui/lab'; - -var cloneDeep = require('lodash/cloneDeep'); - -export interface SongProperties { - title: String, - artistId: Number | undefined, -} - -export interface ArtistProperties { - name: String, - id: Number, -} - -export interface IProps { - dialogOpen: boolean, - onClose?: () => void, - onChangeSongProperties?: (props: SongProperties) => void, - songProperties: SongProperties, - onSubmit?: () => void, - artists: ArtistProperties[], -} - -export default function EditSongDialog(props: IProps) { - const onTitleChange = (title: String) => { - if (props.onChangeSongProperties) { - const p = cloneDeep(props.songProperties); - p.title = title; - props.onChangeSongProperties(p); - } - }; - const onArtistChange = (artist: Number | undefined) => { - if (props.onChangeSongProperties) { - const p = cloneDeep(props.songProperties); - p.artistId = artist; - props.onChangeSongProperties(p); - } - }; - - return - - Song Details - - - - onTitleChange(i.target.value)} - fullWidth - /> - - - { // TODO: this autocomplete is not controlled but does send updates to the parent - // right away. In other words, there is no way to affect its value from outside - // the dialog. - } - option.name as string} - onChange={(event, newValue) => { - if(newValue) { - onArtistChange(newValue.id); - } else { - onArtistChange(undefined); - } - }} - renderInput={ - (params) => - - } - /> - - - - -} \ No newline at end of file diff --git a/client/src/components/FilterControl.tsx b/client/src/components/FilterControl.tsx deleted file mode 100644 index 510ac67..0000000 --- a/client/src/components/FilterControl.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; - -import { - TextField, - Paper, - Select, - MenuItem, - Typography -} from '@material-ui/core'; - -import { - TitleQuery, - ArtistQuery, - isTitleQuery, - isArtistQuery, - Query, - isAndQuery, - isOrQuery, - QueryKeys, -} from '../types/Query'; - -interface TitleFilterControlProps { - query: TitleQuery, - onChangeQuery: (q: Query) => void, -} -function TitleFilterControl(props: TitleFilterControlProps) { - return props.onChangeQuery({ - [QueryKeys.TitleLike]: i.target.value - })} - /> -} - -interface ArtistFilterControlProps { - query: ArtistQuery, - onChangeQuery: (q: Query) => void, -} -function ArtistFilterControl(props: ArtistFilterControlProps) { - return props.onChangeQuery({ - [QueryKeys.ArtistLike]: i.target.value - })} - /> -} - -interface AndNodeControlProps { - query: any, - onChangeQuery: (q: Query) => void, -} -function AndNodeControl(props: AndNodeControlProps) { - const onChangeSubQuery = (a: Query, b: Query) => { - props.onChangeQuery({ - [QueryKeys.AndQuerySignature]: true, - [QueryKeys.OperandA]: a, - [QueryKeys.OperandB]: b - }); - } - - return - {props.query && isAndQuery(props.query) && <> - And - { onChangeSubQuery(q, props.query.b); }} /> - { onChangeSubQuery(props.query.a, q); }} /> - } - ; -} - -interface OrNodeControlProps { - query: any, - onChangeQuery: (q: Query) => void, -} -function OrNodeControl(props: OrNodeControlProps) { - const onChangeSubQuery = (a: Query, b: Query) => { - props.onChangeQuery({ - [QueryKeys.OrQuerySignature]: true, - [QueryKeys.OperandA]: a, - [QueryKeys.OperandB]: b - }); - } - - return - {props.query && isOrQuery(props.query) && <> - Or - { onChangeSubQuery(q, props.query.b); }} /> - { onChangeSubQuery(props.query.a, q); }} /> - } - ; -} - -export interface IProps { - query: Query | undefined, - onChangeQuery: (query: Query) => void, -} - -export function FilterControlLeaf(props: IProps) { - const selectTypeOptions: string[] = ['Title', 'Artist']; - const selectTypeOption: string = (props.query && isTitleQuery(props.query) && 'Title') || - (props.query && isArtistQuery(props.query) && 'Artist') || - "Unknown"; - - const selectInsertOptions: string[] = ['And', 'Or']; - - const handleQueryOnChange = (event: any) => { - switch (event.target.value) { - case 'Title': { - props.onChangeQuery({ - [QueryKeys.TitleLike]: '' - }) - break; - } - case 'Artist': { - props.onChangeQuery({ - [QueryKeys.ArtistLike]: '' - }) - break; - } - } - } - - const handleInsertElem = (event: any) => { - switch (event.target.value) { - case 'And': { - props.onChangeQuery({ - [QueryKeys.AndQuerySignature]: true, - [QueryKeys.OperandA]: props.query || { [QueryKeys.TitleLike]: '' }, - [QueryKeys.OperandB]: { - [QueryKeys.TitleLike]: '' - } - }) - break; - } - case 'Or': { - props.onChangeQuery({ - [QueryKeys.OrQuerySignature]: true, - [QueryKeys.OperandA]: props.query || { [QueryKeys.TitleLike]: '' }, - [QueryKeys.OperandB]: { - [QueryKeys.TitleLike]: '' - } - }) - break; - } - } - } - - return - {/* The selector for inserting another element here. */} - - {/* The selector for the type of filter element. */} - - {props.query && isTitleQuery(props.query) && } - {props.query && isArtistQuery(props.query) && } - ; -} - -export function FilterControlNode(props: IProps) { - return <> - {props.query && isAndQuery(props.query) && } - {props.query && isOrQuery(props.query) && } - ; -} - -export default function FilterControl(props: IProps) { - const isLeaf = (query: Query | undefined) => { - return query && (isTitleQuery(query) || isArtistQuery(query)); - } - const isNode = (query: Query | undefined) => !isLeaf(query); - - return <> - {isLeaf(props.query) && } - {isNode(props.query) && } - -} \ No newline at end of file diff --git a/client/src/components/ItemList.tsx b/client/src/components/ItemList.tsx deleted file mode 100644 index 422224d..0000000 --- a/client/src/components/ItemList.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; -import List from '@material-ui/core/List'; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - flexGrow: 1, - maxWidth: 752, - }, - }), -); - -export default function ItemList(props:any) { - const classes = useStyles(); - - return ( -
- - {props.children} - -
- ); -} diff --git a/client/src/components/ItemListArtistItem.tsx b/client/src/components/ItemListArtistItem.tsx deleted file mode 100644 index b4458bb..0000000 --- a/client/src/components/ItemListArtistItem.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { useEffect } from 'react'; -import ItemListItem from './ItemListItem'; -import { ArtistDisplayItem, LoadingArtistDisplayItem } from '../types/DisplayItem'; - -export interface IProps { - getDetails: () => Promise -} - -export default function ItemListArtistItem(props: IProps) { - const [ artist, setArtist ] = React.useState({ loadingArtist: true }); - - useEffect(() => { - props.getDetails() - .then((details:ArtistDisplayItem) => { setArtist(details); }); - }); - - return -} diff --git a/client/src/components/ItemListItem.tsx b/client/src/components/ItemListItem.tsx deleted file mode 100644 index e4d1430..0000000 --- a/client/src/components/ItemListItem.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { DisplayItem, isSong, isLoadingSong, isArtist, isLoadingArtist } from '../types/DisplayItem'; -import ItemListLoadedSongItem from './ItemListLoadedSongItem'; -import ItemListLoadingSongItem from './ItemListLoadingSongItem'; -import ItemListLoadedArtistItem from './ItemListLoadedArtistItem'; -import ItemListLoadingArtistItem from './ItemListLoadingArtistItem'; - -export interface IProps { - item: DisplayItem -} - -export default function ItemListItem(props: IProps) { - return <> - {isSong(props.item) && } - {isLoadingSong(props.item) && } - {isArtist(props.item) && } - {isLoadingArtist(props.item) && } - -} diff --git a/client/src/components/ItemListLoadedArtistItem.tsx b/client/src/components/ItemListLoadedArtistItem.tsx deleted file mode 100644 index 2a8937b..0000000 --- a/client/src/components/ItemListLoadedArtistItem.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemText from '@material-ui/core/ListItemText'; -import GroupIcon from '@material-ui/icons/Group'; -import Chip from '@material-ui/core/Chip'; - -import { ArtistDisplayItem } from '../types/DisplayItem'; - -export interface IProps { - item: ArtistDisplayItem -} - -export default function ItemListLoadedArtistItem(props: IProps) { - return ( - - - - - - {props.item.tagNames.map((tag: any) => { - return - })} - {props.item.storeLinks.map((link: any) => { - return - - {link.icon} - - ; - })} - - ); -} diff --git a/client/src/components/ItemListLoadedSongItem.tsx b/client/src/components/ItemListLoadedSongItem.tsx deleted file mode 100644 index d3968a8..0000000 --- a/client/src/components/ItemListLoadedSongItem.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemText from '@material-ui/core/ListItemText'; -import MusicNoteIcon from '@material-ui/icons/MusicNote'; -import Chip from '@material-ui/core/Chip'; - -import { SongDisplayItem } from '../types/DisplayItem'; - -export interface IProps { - item: SongDisplayItem -} - -export default function ItemListLoadedSongItem(props: IProps) { - var artists = props.item.artistNames.length ? props.item.artistNames[0] : "Unknown"; - for (var i: number = 1; i < props.item.artistNames.length; i++) { - artists = artists.concat(", " + props.item.artistNames[i]); - } - - return ( - - - - - - {props.item.tagNames.map((tag: any) => { - return - })} - {props.item.storeLinks.map((link: any) => { - return - - {link.icon} - - ; - })} - - ); -} diff --git a/client/src/components/ItemListLoadingArtistItem.tsx b/client/src/components/ItemListLoadingArtistItem.tsx deleted file mode 100644 index 614030d..0000000 --- a/client/src/components/ItemListLoadingArtistItem.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import GroupIcon from '@material-ui/icons/Group'; -import CircularProgress from '@material-ui/core/CircularProgress'; - -import { LoadingArtistDisplayItem } from '../types/DisplayItem'; - -export interface IProps { - item: LoadingArtistDisplayItem -} - -export default function ItemListLoadingArtistItem(props: IProps) { - return ( - - - - - - - ); -} diff --git a/client/src/components/ItemListLoadingSongItem.tsx b/client/src/components/ItemListLoadingSongItem.tsx deleted file mode 100644 index 361ded6..0000000 --- a/client/src/components/ItemListLoadingSongItem.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import MusicNoteIcon from '@material-ui/icons/MusicNote'; -import CircularProgress from '@material-ui/core/CircularProgress'; - -import { LoadingSongDisplayItem } from '../types/DisplayItem'; - -export interface IProps { - item: LoadingSongDisplayItem -} - -export default function ItemListLoadingSongItem(props: IProps) { - return ( - - - - - - - ); -} diff --git a/client/src/components/ItemListSongItem.tsx b/client/src/components/ItemListSongItem.tsx deleted file mode 100644 index f3586b5..0000000 --- a/client/src/components/ItemListSongItem.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { useEffect } from 'react'; -import ItemListItem from './ItemListItem'; -import { SongDisplayItem, LoadingSongDisplayItem } from '../types/DisplayItem'; - -export interface IProps { - getDetails: () => Promise -} - -export default function ItemListSongItem(props: IProps) { - const [ song, setSong ] = React.useState({ loadingSong: true }); - - useEffect(() => { - props.getDetails() - .then((details:SongDisplayItem) => { setSong(details); }); - }); - - return -} diff --git a/client/src/components/QueryBrowseWindow.tsx b/client/src/components/QueryBrowseWindow.tsx deleted file mode 100644 index 16dac6c..0000000 --- a/client/src/components/QueryBrowseWindow.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useState, useEffect } from 'react'; - -import { Query, toApiQuery, QueryOrdering, TypesIncluded, QueryKeys, OrderKey } from '../types/Query'; -import FilterControl from './FilterControl'; -import * as serverApi from '../api'; -import BrowseWindow, { Item } from './BrowseWindow'; -import { FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox, Select, MenuItem } from '@material-ui/core'; - -const _ = require('lodash'); - -interface ItemTypeCheckboxesProps { - types: TypesIncluded, - onChange: (types: TypesIncluded) => void; -} - -function ItemTypeCheckboxes(props: ItemTypeCheckboxesProps) { - const songChange = (v: any) => { - props.onChange({ - [QueryKeys.Songs]: v.target.checked, - [QueryKeys.Artists]: props.types[QueryKeys.Artists], - [QueryKeys.Tags]: props.types[QueryKeys.Tags] - }); - } - const artistChange = (v: any) => { - props.onChange({ - [QueryKeys.Songs]: props.types[QueryKeys.Songs], - [QueryKeys.Artists]: v.target.checked, - [QueryKeys.Tags]: props.types[QueryKeys.Tags] - }); - } - const tagChange = (v: any) => { - props.onChange({ - [QueryKeys.Songs]: props.types[QueryKeys.Songs], - [QueryKeys.Artists]: props.types[QueryKeys.Artists], - [QueryKeys.Tags]: v.target.checked - }); - } - - return - Result types - - } - label="Songs" - /> - } - label="Artists" - /> - } - label="Tags" - /> - - ; -} - -interface OrderingWidgetProps { - ordering: QueryOrdering, - onChange: (o: QueryOrdering) => void; -} - -function OrderingWidget(props: OrderingWidgetProps) { - const onTypeChange = (e: any) => { - props.onChange({ - [QueryKeys.OrderBy]: { - [QueryKeys.OrderKey]: e.target.value, - }, - [QueryKeys.Ascending]: props.ordering[QueryKeys.Ascending], - }); - } - const onAscendingChange = (e: any) => { - props.onChange({ - [QueryKeys.OrderBy]: props.ordering[QueryKeys.OrderBy], - [QueryKeys.Ascending]: (e.target.value === 'asc'), - }); - } - - return - Ordering - - - - - ; -} - -function toServerOrdering(o: QueryOrdering | undefined): serverApi.Ordering { - if (!o) { - return { - orderBy: { - type: serverApi.OrderByType.Name - }, - ascending: true - }; - } - - const keys = { - [OrderKey.Name]: serverApi.OrderByType.Name, - }; - - return { - orderBy: { - type: keys[o[QueryKeys.OrderBy][QueryKeys.OrderKey]] - }, - ascending: o[QueryKeys.Ascending], - } -} - -export interface IProps { - query: Query | undefined, - typesIncluded: TypesIncluded | undefined, - resultOrder: QueryOrdering | undefined, - onQueryChange: (q: Query) => void, - onTypesChange: (t: TypesIncluded) => void, - onOrderChange: (o: QueryOrdering) => void, -} - -export default function QueryBrowseWindow(props: IProps) { - const [songs, setSongs] = useState([]); - const [artists, setArtists] = useState([]); - //const [tags, setTags] = useState([]); - - var items: Item[] = []; - props.typesIncluded && props.typesIncluded[QueryKeys.Songs] && items.push(...songs); - props.typesIncluded && props.typesIncluded[QueryKeys.Artists] && items.push(...artists); - - useEffect(() => { - if (!props.query) { return; } - const q = _.cloneDeep(props.query); - const r = _.cloneDeep(props.resultOrder); - const t = _.cloneDeep(props.typesIncluded); - - const request: serverApi.QueryRequest = { - query: toApiQuery(props.query), - offsetsLimits: { - songOffset: 0, - songLimit: 5, // TODO - artistOffset: 0, - artistLimit: 5, - tagOffset: 0, - tagLimit: 5, - }, - ordering: toServerOrdering(props.resultOrder), - } - const requestOpts = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request) - }; - fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) - .then((response: any) => response.json()) - .then((json: any) => { - const match = _.isEqual(q, props.query) && _.isEqual(r, props.resultOrder) && _.isEqual(t, props.typesIncluded); - 'songs' in json && match && setSongs(json.songs); - 'artists' in json && match && setArtists(json.artists); - }); - }, [ props.query, props.resultOrder, props.typesIncluded ]); - - return <> - - Query - - - - - - -} diff --git a/client/src/components/Window.tsx b/client/src/components/Window.tsx new file mode 100644 index 0000000..52856da --- /dev/null +++ b/client/src/components/Window.tsx @@ -0,0 +1,178 @@ +import React, { useState, useEffect } from 'react'; +import { ThemeProvider, CssBaseline, createMuiTheme, AppBar, Box } from '@material-ui/core'; +import { QueryElem, toApiQuery } from '../lib/query/Query'; +import QueryBuilder from './querybuilder/QueryBuilder'; +import * as serverApi from '../api'; +import { SongTable } from './tables/ResultsTable'; +import stringifyList from '../lib/stringifyList'; +var _ = require('lodash'); + +const darkTheme = createMuiTheme({ + palette: { + type: 'dark' + }, +}); + +export async function getArtists(filter: string) { + const query = filter.length > 0 ? { + prop: serverApi.QueryElemProperty.artistName, + propOperand: filter, + propOperator: serverApi.QueryFilterOp.Like, + } : {}; + + var q: serverApi.QueryRequest = { + query: query, + offsetsLimits: { + artistOffset: 0, + artistLimit: 100, + }, + ordering: { + orderBy: { + type: serverApi.OrderByType.Name, + }, + ascending: true, + }, + }; + + const requestOpts = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(q), + }; + + return (async () => { + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + let json: any = await response.json(); + const names: string[] = json.artists.map((elem: any) => { return elem.name; }); + return [...new Set(names)]; + })(); +} + +export async function getSongTitles(filter: string) { + const query = filter.length > 0 ? { + prop: serverApi.QueryElemProperty.songTitle, + propOperand: filter, + propOperator: serverApi.QueryFilterOp.Like, + } : {}; + + var q: serverApi.QueryRequest = { + query: query, + 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), + }; + + return (async () => { + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + let json: any = await response.json(); + const titles: string[] = json.songs.map((elem: any) => { return elem.title; }); + return [...new Set(titles)]; + })(); +} + +export default function Window(props: any) { + interface ResultsFor { + for: QueryElem, + results: any[], + }; + + const [query, setQuery] = useState(null); + const [resultsFor, setResultsFor] = useState(null); + + const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query)); + const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : []; + + const songGetters = { + getTitle: (song: any) => song.title, + getArtist: (song: any) => stringifyList(song.artists, (a: any) => a.name), + getAlbum: (song: any) => stringifyList(song.albums, (a: any) => a.name), + } + + const doQuery = async (_query: QueryElem) => { + var q: serverApi.QueryRequest = { + query: toApiQuery(_query), + 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), + }; + + return (async () => { + const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts) + let json: any = await response.json(); + if(_.isEqual(query, _query)) { + setResultsFor({ + for: _query, + results: json.songs, + }) + } + })(); + } + + useEffect(() => { + if (query) { + doQuery(query); + } else { + setResultsFor(null); + } + }, [query]); + + return + + + + error + + + + + + + + + + + + +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBAddElemMenu.tsx b/client/src/components/querybuilder/QBAddElemMenu.tsx new file mode 100644 index 0000000..b0fc151 --- /dev/null +++ b/client/src/components/querybuilder/QBAddElemMenu.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Menu, MenuItem } from '@material-ui/core'; +import NestedMenuItem from "material-ui-nested-menu-item"; +import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../lib/query/Query'; +import QBSelectWithRequest from './QBSelectWithRequest'; +import { Requests } from './QueryBuilder'; + +export interface MenuProps { + anchorEl: null | HTMLElement, + onClose: () => void, + onCreateQuery: (q: QueryElem) => void, + requestFunctions: Requests, +} + +export function QBAddElemMenu(props: MenuProps) { + let anchorEl = props.anchorEl; + let onClose = props.onClose; + + return + New query element + + { + onClose(); + props.onCreateQuery({ + a: QueryLeafBy.SongTitle, + leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, + b: s + }); + }} + style={{ width: 300 }} + /> + + + { + onClose(); + props.onCreateQuery({ + a: QueryLeafBy.ArtistName, + leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, + b: s + }); + }} + style={{ width: 300 }} + /> + + +} diff --git a/client/src/components/querybuilder/QBAndBlock.tsx b/client/src/components/querybuilder/QBAndBlock.tsx new file mode 100644 index 0000000..398517d --- /dev/null +++ b/client/src/components/querybuilder/QBAndBlock.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Box, Paper } from '@material-ui/core'; + +export default function QBAndBlock(props: any) { + return + + + {props.children.map((child: any, idx: number) => { + return + {child} + + })} + + + +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBEditButton.tsx b/client/src/components/querybuilder/QBEditButton.tsx new file mode 100644 index 0000000..2692c29 --- /dev/null +++ b/client/src/components/querybuilder/QBEditButton.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { IconButton } from '@material-ui/core'; +import SearchIcon from '@material-ui/icons/Search'; +import CheckIcon from '@material-ui/icons/Check'; + +export interface IProps { + editing: boolean +} + +export default function QBEditButton(props: any) { + return + {(!props.editing) && } + {(props.editing) && } + +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBLeafElem.tsx b/client/src/components/querybuilder/QBLeafElem.tsx new file mode 100644 index 0000000..f4904ea --- /dev/null +++ b/client/src/components/querybuilder/QBLeafElem.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem } from '../../lib/query/Query'; +import { Chip, Typography, IconButton, Box } from '@material-ui/core'; +import { QBPlaceholder } from './QBPlaceholder'; +import DeleteIcon from '@material-ui/icons/Delete'; +import { Requests } from './QueryBuilder'; + +export interface ElemChipProps { + label: any, + extraElements?: any, +} + +export function LabeledElemChip(props: ElemChipProps) { + const label = + {props.label} + {props.extraElements} + + return +} + +export interface LeafProps { + elem: QueryLeafElem, + onReplace: (q: QueryElem) => void, + extraElements?: any, +} + +export function QBQueryElemArtistEquals(props: LeafProps) { + return +} + +export function QBQueryElemArtistLike(props: LeafProps) { + return +} + +export function QBQueryElemTitleEquals(props: LeafProps) { + return +} + +export function QBQueryElemTitleLike(props: LeafProps) { + return +} + +export interface DeleteButtonProps { + onClick?: (e: any) => void, +} + +export function QBQueryElemDeleteButton(props: DeleteButtonProps) { + return + + +} + +export interface IProps { + elem: QueryLeafElem, + onReplace: (q: QueryElem | null) => void, + editingQuery: boolean, + requestFunctions: Requests, +} + +export function QBLeafElem(props: IProps) { + let e = props.elem; + + const extraElements = props.editingQuery ? + + props.onReplace(null)} + /> + + : undefined; + + if (e.a == QueryLeafBy.ArtistName && + e.leafOp == QueryLeafOp.Equals && + typeof e.b == "string") { + return + } else if (e.a == QueryLeafBy.ArtistName && + e.leafOp == QueryLeafOp.Like && + typeof e.b == "string") { + return + } if (e.a == QueryLeafBy.SongTitle && + e.leafOp == QueryLeafOp.Equals && + typeof e.b == "string") { + return + } else if (e.a == QueryLeafBy.SongTitle && + e.leafOp == QueryLeafOp.Like && + typeof e.b == "string") { + return + } else if (e.leafOp == QueryLeafOp.Placeholder) { + return + } + + throw "Unsupported leaf element"; +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBNodeElem.tsx b/client/src/components/querybuilder/QBNodeElem.tsx new file mode 100644 index 0000000..5551390 --- /dev/null +++ b/client/src/components/querybuilder/QBNodeElem.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import QBOrBlock from './QBOrBlock'; +import QBAndBlock from './QBAndBlock'; +import { QueryNodeElem, QueryNodeOp, QueryElem, isNodeElem, simplify } from '../../lib/query/Query'; +import { QBLeafElem } from './QBLeafElem'; +import { QBQueryElem } from './QBQueryElem'; +import { O_APPEND } from 'constants'; +import { Requests } from './QueryBuilder'; + +export interface NodeProps { + elem: QueryNodeElem, + onReplace: (q: QueryElem | null) => void, + editingQuery: boolean, + requestFunctions: Requests, +} + +export function QBNodeElem(props: NodeProps) { + let e = props.elem; + + const onReplace = (idx: number, q: QueryElem | null) => { + var ops = e.operands; + if (q) { + ops[idx] = q; + } else { + ops.splice(idx, 1); + } + let newNode = simplify({ operands: ops, nodeOp: e.nodeOp }); + props.onReplace(newNode); + } + + const children = e.operands.map((o: any, idx: number) => { + return onReplace(idx, q)} + editingQuery={props.editingQuery} + requestFunctions={props.requestFunctions} + /> + }); + + if (e.nodeOp == QueryNodeOp.And) { + return {children} + } else if (e.nodeOp == QueryNodeOp.Or) { + return {children} + } + + throw "Unsupported node element"; +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBOrBlock.tsx b/client/src/components/querybuilder/QBOrBlock.tsx new file mode 100644 index 0000000..e25b575 --- /dev/null +++ b/client/src/components/querybuilder/QBOrBlock.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; + +export interface IProps { + children: any, +} + +export default function QBOrBlock(props: any) { + const firstChild = Array.isArray(props.children) && props.children.length >= 1 ? + props.children[0] : undefined; + + const otherChildren = Array.isArray(props.children) && props.children.length > 1 ? + props.children.slice(1) : []; + + return + + {firstChild} + + {otherChildren.map((child: any, idx: number) => { + return + + Or + + + {child} + + ; + })} + +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBPlaceholder.tsx b/client/src/components/querybuilder/QBPlaceholder.tsx new file mode 100644 index 0000000..00847c0 --- /dev/null +++ b/client/src/components/querybuilder/QBPlaceholder.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Chip } from '@material-ui/core'; +import { QBAddElemMenu } from './QBAddElemMenu'; +import { QueryElem } from '../../lib/query/Query'; +import { Requests } from './QueryBuilder'; + +export interface IProps { + onReplace: (q: QueryElem) => void, + requestFunctions: Requests, +} + +export function QBPlaceholder(props: IProps & any) { + const [anchorEl, setAnchorEl] = React.useState(null); + + const onOpen = (event: any) => { + setAnchorEl(event.currentTarget); + }; + const onClose = () => { + setAnchorEl(null); + }; + const onCreate = (q: QueryElem) => { + props.onReplace(q); + }; + + return <> + + + +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBQueryElem.tsx b/client/src/components/querybuilder/QBQueryElem.tsx new file mode 100644 index 0000000..8fafebf --- /dev/null +++ b/client/src/components/querybuilder/QBQueryElem.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { QueryLeafElem, QueryNodeElem, QueryElem, isLeafElem, isNodeElem } from '../../lib/query/Query'; +import { QBLeafElem } from './QBLeafElem'; +import { QBNodeElem } from './QBNodeElem'; +import { Requests } from './QueryBuilder'; + +export interface IProps { + elem: QueryLeafElem | QueryNodeElem, + onReplace: (q: QueryElem | null) => void, + editingQuery: boolean, + requestFunctions: Requests, +} + +export function QBQueryElem(props: IProps) { + let e = props.elem; + + if (isLeafElem(e)) { + return + } else if (isNodeElem(e)) { + return + } + + throw new Error("Unsupported query element"); +} \ No newline at end of file diff --git a/client/src/components/querybuilder/QBSelectWithRequest.tsx b/client/src/components/querybuilder/QBSelectWithRequest.tsx new file mode 100644 index 0000000..b4caac1 --- /dev/null +++ b/client/src/components/querybuilder/QBSelectWithRequest.tsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect } from 'react'; +import TextField from '@material-ui/core/TextField'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +interface IProps { + getNewOptions: (textInput: string) => Promise, + label: string, + onSubmit: (s: string, exactMatch: boolean) => void, +} + +// Autocompleted combo box which can make asynchronous requests +// to get new options. +// Based on Material UI example: https://material-ui.com/components/autocomplete/ +export default function QBSelectWithRequest(props: IProps & any) { + interface OptionsFor { + forInput: string, + options: string[], + }; + + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(null); + const [input, setInput] = useState(""); + + const { getNewOptions, label, onSubmit, ...restProps } = props; + + const loading: boolean = !options || options.forInput !== input; + + const updateOptions = (forInput: string, options: any[]) => { + if (forInput === input) { + console.log("setting options."); + setOptions({ + forInput: forInput, + options: options, + }); + } + } + + const startRequest = (_input: string) => { + console.log('starting req', _input); + setInput(_input); + (async () => { + const newOptions = await getNewOptions(_input); + console.log('new options', newOptions); + updateOptions(_input, newOptions); + })(); + }; + + // // Ensure a new request is made whenever the loading option is enabled. + // useEffect(() => { + // startRequest(input); + // }, []); + + // Ensure options are cleared whenever the element is closed. + // useEffect(() => { + // if (!open) { + // setOptions(null); + // } + // }, [open]); + + useEffect(() => { + startRequest(input); + }, [input]); + + const onInputChange = (e: any, val: any, reason: any) => { + if (reason === 'reset') { + // User selected a preset option. + props.onSubmit(val, true); + } else { + // User changed text, start a new request. + setInput(val); + } + } + + console.log("Render props:", props); + + return ( + { + setOpen(true); + }} + onClose={() => { + setOpen(false); + }} + getOptionSelected={(option, value) => option === value} + getOptionLabel={(option) => option} + options={options ? options.options : null} + loading={loading} + freeSolo={true} + value={input} + onInputChange={onInputChange} + onKeyDown={(e: any) => { + // Prevent the event from propagating, because + // that would trigger keyboard navigation of the menu. + e.stopPropagation(); + if (e.key === 'Enter') { + // User submitted free-form value. + props.onSubmit(input, options && options.options.includes(input)); + } + }} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} diff --git a/client/src/components/querybuilder/QueryBuilder.tsx b/client/src/components/querybuilder/QueryBuilder.tsx new file mode 100644 index 0000000..dc73d57 --- /dev/null +++ b/client/src/components/querybuilder/QueryBuilder.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { Box } from '@material-ui/core'; +import QBQueryButton from './QBEditButton'; +import { QBQueryElem } from './QBQueryElem'; +import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/query/Query'; + + +export interface Requests { + getArtists: (filter: string) => Promise, + getSongTitles: (filter: string) => Promise, +} + +export interface IProps { + query: QueryElem | null, + onChangeQuery: (q: QueryElem | null) => void, + requestFunctions: Requests, +} + +export default function QueryBuilder(props: IProps) { + const [editing, setEditing] = useState(false); + + const simpleQuery = simplify(props.query); + const showQuery = editing ? + addPlaceholders(simpleQuery, null) : simpleQuery; + + const onReplace = (q: any) => { + const newQ = removePlaceholders(q); + setEditing(false); + props.onChangeQuery(newQ); + } + + return <> + + + setEditing(!editing)} + editing={editing} + /> + + + {showQuery && } + + + +} \ No newline at end of file diff --git a/client/src/components/tables/ResultsTable.tsx b/client/src/components/tables/ResultsTable.tsx new file mode 100644 index 0000000..b701222 --- /dev/null +++ b/client/src/components/tables/ResultsTable.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody } from '@material-ui/core'; + +export interface SongGetters { + getTitle: (song: any) => string, + getArtist: (song: any) => string, + getAlbum: (song: any) => string, +} + +export interface IProps { + songs: any[], + songGetters: SongGetters, +} + +export function SongTable(props: IProps) { + const useTableStyles = makeStyles({ + table: { + minWidth: 650, + }, + }); + const classes = useTableStyles(); + + return ( + + + + + Title + Artist + Album + + + + {props.songs.map((song:any) => { + const title = props.songGetters.getTitle(song); + const artist = props.songGetters.getArtist(song); + const album = props.songGetters.getAlbum(song); + + return + {title} + {artist} + {album} + + })} + +
+
+ ); +} \ No newline at end of file diff --git a/client/src/lib/query/Query.tsx b/client/src/lib/query/Query.tsx new file mode 100644 index 0000000..ad617b3 --- /dev/null +++ b/client/src/lib/query/Query.tsx @@ -0,0 +1,186 @@ +import * as serverApi from '../../api'; + +export enum QueryLeafBy { + ArtistName = 0, + AlbumName, + TagName, + SongTitle +} + +export enum QueryLeafOp { + Equals = 0, + Like, + Placeholder, // Special op which indicates that this leaf is not filled in yet. +} + +export type QueryLeafOperand = string | number; + +export interface QueryLeafElem { + a: QueryLeafBy; + leafOp: QueryLeafOp; + b: QueryLeafOperand; +}; +export function isLeafElem(q: QueryElem): q is QueryLeafElem { + return 'leafOp' in q; +} + +export enum QueryNodeOp { + And = 0, + Or, +} + +export interface QueryNodeElem { + operands: QueryElem[]; + nodeOp: QueryNodeOp; +} +export function isNodeElem(q: QueryElem): q is QueryNodeElem { + return 'nodeOp' in q; +} + + +export function queryOr(...args: QueryElem[]) { + return { + operands: args, + nodeOp: QueryNodeOp.Or + } +} + +export function queryAnd(...args: QueryElem[]) { + return { + operands: args, + nodeOp: QueryNodeOp.And + }; +} + +export type QueryElem = QueryLeafElem | QueryNodeElem; + +// Take a query and add placeholders. The placeholders are empty +// leaves. They should be placed so that all possible node combinations +// from the existing nodes could have an added combinational leaf. +// In other words: for AND/OR, this should result in a query that has +// placeholders for all AND/OR combinations with existing nodes. +export function addPlaceholders( + q: QueryElem | null, + inNode: null | QueryNodeOp.And | QueryNodeOp.Or, +): QueryElem { + + const makePlaceholder = () => { + return { + a: 0, + leafOp: QueryLeafOp.Placeholder, + b: "" + } + }; + + const otherOp: Record = { + [QueryNodeOp.And]: QueryNodeOp.Or, + [QueryNodeOp.Or]: QueryNodeOp.And, + } + + if (q == null) { + return makePlaceholder(); + } else if (isNodeElem(q)) { + var operands = q.operands.map((op: any, idx: number) => { + return addPlaceholders(op, q.nodeOp); + }); + operands.push(makePlaceholder()); + const newBlock = { operands: operands, nodeOp: q.nodeOp }; + + if (inNode == null) { + return { operands: [newBlock, makePlaceholder()], nodeOp: otherOp[q.nodeOp] }; + } else { + return newBlock; + } + } else if (isLeafElem(q) && + q.leafOp != QueryLeafOp.Placeholder && + inNode !== null) { + return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] }; + } else if (isLeafElem(q) && + q.leafOp != QueryLeafOp.Placeholder && + inNode === null) { + return { + operands: [ + { operands: [q, makePlaceholder()], nodeOp: QueryNodeOp.And }, + makePlaceholder(), + ], nodeOp: QueryNodeOp.Or + } + } + + return q; +} + +// See addPlaceholders. +export function removePlaceholders(q: QueryElem | null): QueryElem | null { + if (q && isNodeElem(q)) { + var newOperands: QueryElem[] = []; + + q.operands.forEach((op: any) => { + if (isLeafElem(op) && op.leafOp == QueryLeafOp.Placeholder) { + return; + } + const newOp = removePlaceholders(op); + if (newOp) { + newOperands.push(newOp); + } + }) + + if (newOperands.length == 0) { + return null; + } + if (newOperands.length == 1) { + return newOperands[0]; + } + return { operands: newOperands, nodeOp: q.nodeOp }; + } else if (q && isLeafElem(q) && q.leafOp == QueryLeafOp.Placeholder) { + return null; + } + + return q; +} + +export function simplify(q: QueryElem | null): QueryElem | null { + if (q && isNodeElem(q)) { + var newOperands: QueryElem[] = []; + q.operands.forEach((o: QueryElem) => { + const s = simplify(o); + if (s !== null) { newOperands.push(s); } + }) + if (newOperands.length === 0) { return null; } + if (newOperands.length === 1) { return newOperands[0]; } + return { operands: newOperands, nodeOp: q.nodeOp }; + } + + return q; +} + +export function toApiQuery(q: QueryElem) : serverApi.Query { + const propsMapping: any = { + [QueryLeafBy.SongTitle]: serverApi.QueryElemProperty.songTitle, + [QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName, + } + const leafOpsMapping: any = { + [QueryLeafOp.Equals]: serverApi.QueryFilterOp.Eq, + [QueryLeafOp.Like]: serverApi.QueryFilterOp.Like, + } + const nodeOpsMapping: any = { + [QueryNodeOp.And]: serverApi.QueryElemOp.And, + [QueryNodeOp.Or]: serverApi.QueryElemOp.Or, + } + + if(isLeafElem(q)) { + const r: serverApi.QueryElem = { + prop: propsMapping[q.a], + propOperator: leafOpsMapping[q.leafOp], + propOperand: q.b, + } + return r; + } else if(isNodeElem(q)) { + const r = { + children: q.operands.map((op: any) => toApiQuery(op)), + childrenOperator: nodeOpsMapping[q.nodeOp] + } + return r; + } + + return {}; +} \ No newline at end of file diff --git a/client/src/lib/stringifyList.tsx b/client/src/lib/stringifyList.tsx new file mode 100644 index 0000000..dc883ba --- /dev/null +++ b/client/src/lib/stringifyList.tsx @@ -0,0 +1,13 @@ +export default function stringifyList( + s: any[], + stringifyElem?: (e: any) => string, +) { + const stringify = stringifyElem || ((e: any) => e); + var r = ""; + if (s.length > 0) { r += stringify(s[0]) } + for (let i = 1; i < s.length; i++) { + r += ", " + stringify(s[i]); + } + + return r; +} \ No newline at end of file diff --git a/client/src/types/DisplayItem.tsx b/client/src/types/DisplayItem.tsx deleted file mode 100644 index bfa76d0..0000000 --- a/client/src/types/DisplayItem.tsx +++ /dev/null @@ -1,44 +0,0 @@ -export interface SongDisplayItem { - title:String, - artistNames:String[], - tagNames:String[], - storeLinks: { - icon: JSX.Element, - url: String, - }[] -} - -export interface LoadingSongDisplayItem { - loadingSong: boolean, -} - -export interface ArtistDisplayItem { - name:String, - tagNames:String[], - storeLinks: { - icon: JSX.Element, - url: String, - }[] -} - -export interface LoadingArtistDisplayItem { - loadingArtist: boolean, -} - -export type DisplayItem = SongDisplayItem | LoadingSongDisplayItem | ArtistDisplayItem | LoadingArtistDisplayItem; - -export function isSong(item: DisplayItem): item is SongDisplayItem { - return "title" in item; -} - -export function isLoadingSong(item: DisplayItem): item is LoadingSongDisplayItem { - return "loadingSong" in item; -} - -export function isArtist(item: DisplayItem): item is ArtistDisplayItem { - return "name" in item; -} - -export function isLoadingArtist(item: DisplayItem): item is LoadingArtistDisplayItem { - return "loadingArtist" in item; -} \ No newline at end of file diff --git a/client/src/types/DragTypes.tsx b/client/src/types/DragTypes.tsx deleted file mode 100644 index e30b478..0000000 --- a/client/src/types/DragTypes.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const dragTypes = { - ListItem: 'list item' -} \ No newline at end of file diff --git a/client/src/types/Query.tsx b/client/src/types/Query.tsx deleted file mode 100644 index b112b19..0000000 --- a/client/src/types/Query.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { QueryElemProperty, QueryFilterOp, QueryElemOp } from '../api'; - -export enum QueryKeys { - TitleLike = 'tl', - ArtistLike = 'al', - AndQuerySignature = 'and', - OrQuerySignature = 'or', - OperandA = 'a', - OperandB = 'b', - Name = 'n', - ArtistRanking = 'an', - TagRanking = 'tn', - Songs = 's', - Artists = 'at', - Tags = 't', - OrderBy = 'ob', - OrderKey = 'ok', - Ascending = 'asc' -} - -export interface TitleQuery { - [QueryKeys.TitleLike]: String -}; -export function isTitleQuery(q: Query): q is TitleQuery { - return QueryKeys.TitleLike in q; -} -export function TitleToApiQuery(q: TitleQuery) { - return { - 'prop': QueryElemProperty.songTitle, - 'propOperand': '%' + q[QueryKeys.TitleLike] + '%', - 'propOperator': QueryFilterOp.Like, - } -} - -export interface ArtistQuery { - [QueryKeys.ArtistLike]: String -}; -export function isArtistQuery(q: Query): q is ArtistQuery { - return QueryKeys.ArtistLike in q; -} -export function ArtistToApiQuery(q: ArtistQuery) { - return { - 'prop': QueryElemProperty.artistName, - 'propOperand': '%' + q[QueryKeys.ArtistLike] + '%', - 'propOperator': QueryFilterOp.Like, - } -} - -export interface AndQuery { - [QueryKeys.AndQuerySignature]: any, - [QueryKeys.OperandA]: T, - [QueryKeys.OperandB]: T, -} -export function isAndQuery(q: Query): q is AndQuery { - return QueryKeys.AndQuerySignature in q; -} -export function AndToApiQuery(q: AndQuery) { - return { - 'childrenOperator': QueryElemOp.And, - 'children': [ - toApiQuery(q.a), - toApiQuery(q.b), - ] - } -} - -export interface OrQuery { - [QueryKeys.OrQuerySignature]: any, - [QueryKeys.OperandA]: T, - [QueryKeys.OperandB]: T, -} -export function isOrQuery(q: Query): q is OrQuery { - return QueryKeys.OrQuerySignature in q; -} -export function OrToApiQuery(q: OrQuery) { - return { - 'childrenOperator': QueryElemOp.Or, - 'children': [ - toApiQuery(q.a), - toApiQuery(q.b), - ] - } -} - -export type Query = TitleQuery | ArtistQuery | AndQuery | OrQuery; - -export enum OrderKey { - Name = 'n', -} - -export interface QueryOrdering { - [QueryKeys.OrderBy]: { - [QueryKeys.OrderKey]: OrderKey, - } - [QueryKeys.Ascending]: boolean, -} - -export interface TypesIncluded { - [QueryKeys.Songs]: boolean, - [QueryKeys.Artists]: boolean, - [QueryKeys.Tags]: boolean, -} - -export function isQuery(q: any): q is Query { - return q != null && - (isTitleQuery(q) || isArtistQuery(q) || isAndQuery(q) || isOrQuery(q)); -} - -export function isQueryOrdering(q: any): q is QueryOrdering { - return q != null && - QueryKeys.OrderBy in q && - QueryKeys.OrderKey in q[QueryKeys.OrderBy] && - QueryKeys.Ascending in q; -} - -export function isTypesIncluded(q: any): q is TypesIncluded { - return q != null && - QueryKeys.Songs in q && - QueryKeys.Artists in q && - QueryKeys.Tags in q; -} - -export function toApiQuery(q: Query): any { - return (isTitleQuery(q) && TitleToApiQuery(q)) || - (isArtistQuery(q) && ArtistToApiQuery(q)) || - (isAndQuery(q) && AndToApiQuery(q)) || - (isOrQuery(q) && OrToApiQuery(q)) || - {}; -} \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json index f2850b7..2254446 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable", diff --git a/resources/logo.svg b/resources/logo.svg new file mode 100644 index 0000000..ddec38e --- /dev/null +++ b/resources/logo.svg @@ -0,0 +1,108 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/logo_src.svg b/resources/logo_src.svg new file mode 100644 index 0000000..bce6312 --- /dev/null +++ b/resources/logo_src.svg @@ -0,0 +1,129 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + muDBase + + + + + + + + + + + + + + + diff --git a/server/endpoints/QueryEndpointHandler.ts b/server/endpoints/QueryEndpointHandler.ts index 4707b32..3be1197 100644 --- a/server/endpoints/QueryEndpointHandler.ts +++ b/server/endpoints/QueryEndpointHandler.ts @@ -104,7 +104,9 @@ function addLeafWhere(knexQuery: any, queryElem: api.QueryElem, type: WhereType) if (!queryElem.propOperator) throw "Cannot create where clause without an operator."; const operator = queryElem.propOperator || api.QueryFilterOp.Eq; const a = queryElem.prop && propertyKeys[queryElem.prop]; - const b = queryElem.propOperand || ""; + const b = operator === api.QueryFilterOp.Like ? + '%' + (queryElem.propOperand || "") + '%' + : (queryElem.propOperand || ""); if (Object.keys(simpleLeafOps).includes(operator)) { if (type == WhereType.And) { @@ -295,13 +297,19 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, return await getLinkedObjects(knex, ObjectType.Song, ObjectType.Tag, await songIdsPromise); })() : (async () => { return {}; })(); + const songsAlbumsPromise: Promise> = (songLimit && songLimit > 0) ? + (async () => { + return await getLinkedObjects(knex, ObjectType.Song, ObjectType.Album, await songIdsPromise); + })() : + (async () => { return {}; })(); const [ songs, artists, tags, songsArtists, - songsTags + songsTags, + songsAlbums, ] = await Promise.all([ songsPromise, @@ -309,6 +317,7 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, tagsPromise, songsArtistsPromise, songsTagsPromise, + songsAlbumsPromise, ]); const response: api.QueryResponse = { @@ -330,7 +339,13 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, name: tag['tags.name'], }; }), - albums: [], //FIXME + albums: songsAlbums[song['songs.id']].map((album: any) => { + return { + albumId: album['albums.id'], + name: album['albums.name'], + storeLinks: asJson(album['albums.storeLinks']), + }; + }), } }), artists: artists.map((artist: any) => { @@ -353,94 +368,4 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, } catch (e) { catchUnhandledErrors(e); } - - // try { - // const songLimit = reqObject.offsetsLimits.songLimit; - // const songOffset = reqObject.offsetsLimits.songOffset; - // const tagLimit = reqObject.offsetsLimits.tagLimit; - // const tagOffset = reqObject.offsetsLimits.tagOffset; - // const artistLimit = reqObject.offsetsLimits.artistLimit; - // const artistOffset = reqObject.offsetsLimits.artistOffset; - - // const songs = (songLimit && songLimit > 0) && await models.Song.findAll({ - // // NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938. - // // Custom pagination is implemented before responding. - // where: getSequelizeWhere(reqObject.query, QueryType.Song), - // order: getSequelizeOrder(reqObject.ordering, QueryType.Song), - // include: [ models.Artist, models.Album, models.Tag, models.Ranking ], - // //limit: reqObject.limit, - // //offset: reqObject.offset, - // }) - // const artists = (artistLimit && artistLimit > 0) && await models.Artist.findAll({ - // // NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938. - // // Custom pagination is implemented before responding. - // where: getSequelizeWhere(reqObject.query, QueryType.Artist), - // order: getSequelizeOrder(reqObject.ordering, QueryType.Artist), - // include: [models.Song, models.Album, models.Tag], - // //limit: reqObject.limit, - // //offset: reqObject.offset, - // }) - // const tags = (tagLimit && tagLimit > 0) && await models.Tag.findAll({ - // // NOTE: have to disable limit and offset because of bug: https://github.com/sequelize/sequelize/issues/11938. - // // Custom pagination is implemented before responding. - // where: getSequelizeWhere(reqObject.query, QueryType.Tag), - // order: getSequelizeOrder(reqObject.ordering, QueryType.Tag), - // include: [models.Song, models.Album, models.Artist], - // //limit: reqObject.limit, - // //offset: reqObject.offset, - // }) - - // const response: api.QueryResponse = { - // songs: ((songLimit || -1) <= 0) ? [] : await Promise.all(songs.map(async (song: any) => { - // const artists = song.getArtists(); - // const tags = song.getTags(); - // const rankings = song.getRankings(); - // return { - // songId: song.id, - // title: song.title, - // storeLinks: song.storeLinks, - // artists: (await artists).map((artist: any) => { - // return { - // artistId: artist.id, - // name: artist.name, - // } - // }), - // tags: (await tags).map((tag: any) => { - // return { - // tagId: tag.id, - // name: tag.name, - // } - // }), - // rankings: await (await rankings).map(async (ranking: any) => { - // const maybeTagContext: api.TagDetails | undefined = await ranking.getTagContext(); - // const maybeArtistContext: api.ArtistDetails | undefined = await ranking.getArtistContext(); - // const maybeContext = maybeTagContext || maybeArtistContext; - // return { - // rankingId: ranking.id, - // type: api.ItemType.Song, - // rankedId: song.id, - // context: maybeContext, - // value: ranking.value, - // } - // }) - // }; - // }).slice(songOffset || 0, (songOffset || 0) + (songLimit || 10))), - // // TODO: custom pagination due to bug mentioned above - // artists: ((artistLimit || -1) <= 0) ? [] : await Promise.all(artists.map(async (artist: any) => { - // return { - // artistId: artist.id, - // name: artist.name, - // }; - // }).slice(artistOffset || 0, (artistOffset || 0) + (artistLimit || 10))), - // tags: ((tagLimit || -1) <= 0) ? [] : await Promise.all(tags.map(async (tag: any) => { - // return { - // tagId: tag.id, - // name: tag.name, - // }; - // }).slice(tagOffset || 0, (tagOffset || 0) + (tagLimit || 10))), - // }; - // res.send(response); - // } catch (e) { - // catchUnhandledErrors(e); - // } } \ No newline at end of file diff --git a/server/knexfile.ts b/server/knexfile.ts index 18d26c5..1377c10 100644 --- a/server/knexfile.ts +++ b/server/knexfile.ts @@ -12,6 +12,6 @@ export default > { // In production, we base the config on an environment // variable setting. - production: JSON.parse(process.env.MUDBASE_DB_CONFIG || "") + production: JSON.parse(process.env.MUDBASE_DB_CONFIG || "{}") };