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 @@
+
+
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
-}
\ 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
-}
\ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
\ 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
+}
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 @@
+
+
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 @@
+
+
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 || "{}")
};