Removed old UI. Started with a mock-up and simple query rendering.

pull/16/head
Sander Vocke 5 years ago
parent fc25e1ba8d
commit 4f3cd30e77
  1. 130
      client/src/App.tsx
  2. 115
      client/src/components/AppBar.tsx
  3. 68
      client/src/components/BrowseWindow.tsx
  4. 19
      client/src/components/DraggableItemListItem.tsx
  5. 46
      client/src/components/EditArtistDialog.tsx
  6. 85
      client/src/components/EditSongDialog.tsx
  7. 189
      client/src/components/FilterControl.tsx
  8. 24
      client/src/components/ItemList.tsx
  9. 18
      client/src/components/ItemListArtistItem.tsx
  10. 19
      client/src/components/ItemListItem.tsx
  11. 35
      client/src/components/ItemListLoadedArtistItem.tsx
  12. 41
      client/src/components/ItemListLoadedSongItem.tsx
  13. 22
      client/src/components/ItemListLoadingArtistItem.tsx
  14. 22
      client/src/components/ItemListLoadingSongItem.tsx
  15. 18
      client/src/components/ItemListSongItem.tsx
  16. 198
      client/src/components/QueryBrowseWindow.tsx
  17. 37
      client/src/components/Window.tsx
  18. 16
      client/src/components/querybuilder/QBAndBlock.tsx
  19. 26
      client/src/components/querybuilder/QBOrBlock.tsx
  20. 25
      client/src/components/querybuilder/QBQueryElem.tsx
  21. 29
      client/src/components/querybuilder/QBQueryLeafElem.tsx
  22. 35
      client/src/components/querybuilder/QBQueryNodeElem.tsx
  23. 63
      client/src/lib/Query.tsx
  24. 44
      client/src/types/DisplayItem.tsx
  25. 3
      client/src/types/DragTypes.tsx
  26. 129
      client/src/types/Query.tsx

@ -1,137 +1,29 @@
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";
import { Typography } from '@material-ui/core';
import Mockup from './components/Mockup';
import Window from './components/Window';
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);
}
function App() {
return (
<div style={{ maxWidth: '100%' }}>
<Router>
<DndProvider backend={HTML5Backend}>
<Switch>
<Redirect exact from='/' to="/query" />
<Route path='/query'>
<AppBar activeTab={AppBarActiveTab.Query} onActiveTabChange={onAppBarTabChange} />
<QueryBrowseWindow
query={itemQuery}
typesIncluded={itemTypes}
resultOrder={itemOrder}
onQueryChange={onQueryChange}
onTypesChange={onTypesChange}
onOrderChange={onOrderChange}
/>
<Route path="/mockup">
<Mockup />
</Route>
<Route path="/">
<Window/>
</Route>
</Switch>
</div>
);
}
function AppMockup() {
return <Mockup/>
}
function App() {
return (
<Router>
<DndProvider backend={HTML5Backend}>
<AppMockup />
</DndProvider>
</Router>
);

@ -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 (
<div className={classes.root}>
<MuiAppBar position="static">
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="open drawer"
>
<MenuIcon />
</IconButton>
<Typography className={classes.title} variant="h6" noWrap>MuDBase</Typography>
<div className={classes.search}>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
<InputBase
placeholder="Search…"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
inputProps={{ 'aria-label': 'search' }}
/>
</div>
</Toolbar>
<Tabs value={props.activeTab} onChange={(evt:any, idx:any) => { props.onActiveTabChange(idx); }}>
<Tab label="Query"/>
</Tabs>
</MuiAppBar>
</div>
);
}

@ -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 <GooglePlayIcon height='30px' width='30px' />;
}
return <StoreIcon />;
}
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 <Paper>
<ItemList>
{props.items.map((item: Item) => {
const di = toDisplayItem(item);
return di && <DraggableItemListItem item={di} />;
})}
</ItemList>
</Paper>;
}

@ -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 <div
ref={drag}
>
<ItemListItem {...props} />
</div>;
}

@ -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 <Dialog
open={props.dialogOpen}
onClose={props.onClose}
>
<Typography variant='h6' gutterBottom>
Artist Details
</Typography>
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
label="Name"
value={props.artistProperties.name}
onChange={(i: any) => onNameChange(i.target.value)}
fullWidth
/>
</Grid>
</Grid>
<Button variant="contained" onClick={props.onSubmit}>Submit</Button>
</Dialog>
}

@ -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 <Dialog
open={props.dialogOpen}
onClose={props.onClose}
>
<Typography variant='h6' gutterBottom>
Song Details
</Typography>
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
label="Song Title"
value={props.songProperties.title}
onChange={(i: any) => onTitleChange(i.target.value)}
fullWidth
/>
</Grid>
<Grid item xs={12}>
{ // 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.
}
<Autocomplete
options={props.artists}
getOptionLabel={(option) => option.name as string}
onChange={(event, newValue) => {
if(newValue) {
onArtistChange(newValue.id);
} else {
onArtistChange(undefined);
}
}}
renderInput={
(params) =>
<TextField {...params}
label="Artist"
fullWidth
/>
}
/>
</Grid>
</Grid>
<Button variant="contained" onClick={props.onSubmit}>Submit</Button>
</Dialog>
}

@ -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 <TextField
label="Title"
value={props.query[QueryKeys.TitleLike]}
onChange={(i: any) => props.onChangeQuery({
[QueryKeys.TitleLike]: i.target.value
})}
/>
}
interface ArtistFilterControlProps {
query: ArtistQuery,
onChangeQuery: (q: Query) => void,
}
function ArtistFilterControl(props: ArtistFilterControlProps) {
return <TextField
label="Name"
value={props.query[QueryKeys.ArtistLike]}
onChange={(i: any) => 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 <Paper>
{props.query && isAndQuery(props.query) && <>
<Typography>And</Typography>
<FilterControl query={props.query.a} onChangeQuery={(q: Query) => { onChangeSubQuery(q, props.query.b); }} />
<FilterControl query={props.query.b} onChangeQuery={(q: Query) => { onChangeSubQuery(props.query.a, q); }} />
</>}
</Paper>;
}
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 <Paper>
{props.query && isOrQuery(props.query) && <>
<Typography>Or</Typography>
<FilterControl query={props.query.a} onChangeQuery={(q: Query) => { onChangeSubQuery(q, props.query.b); }} />
<FilterControl query={props.query.b} onChangeQuery={(q: Query) => { onChangeSubQuery(props.query.a, q); }} />
</>}
</Paper>;
}
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 <Paper>
{/* The selector for inserting another element here. */}
<Select
onChange={handleInsertElem}
>
{selectInsertOptions.map((option: string) => {
return <MenuItem value={option}>{option}</MenuItem>
})}
</Select>
{/* The selector for the type of filter element. */}
<Select
value={selectTypeOption}
onChange={handleQueryOnChange}
>
{selectTypeOptions.map((option: string) => {
return <MenuItem value={option}>{option}</MenuItem>
})}
</Select>
{props.query && isTitleQuery(props.query) && <TitleFilterControl query={props.query} onChangeQuery={props.onChangeQuery} />}
{props.query && isArtistQuery(props.query) && <ArtistFilterControl query={props.query} onChangeQuery={props.onChangeQuery} />}
</Paper>;
}
export function FilterControlNode(props: IProps) {
return <>
{props.query && isAndQuery(props.query) && <AndNodeControl {...props} />}
{props.query && isOrQuery(props.query) && <OrNodeControl {...props} />}
</>;
}
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) && <FilterControlLeaf {...props} />}
{isNode(props.query) && <FilterControlNode {...props} />}
</>
}

@ -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 (
<div className={classes.root}>
<List dense={true}>
{props.children}
</List>
</div>
);
}

@ -1,18 +0,0 @@
import React, { useEffect } from 'react';
import ItemListItem from './ItemListItem';
import { ArtistDisplayItem, LoadingArtistDisplayItem } from '../types/DisplayItem';
export interface IProps {
getDetails: () => Promise<ArtistDisplayItem>
}
export default function ItemListArtistItem(props: IProps) {
const [ artist, setArtist ] = React.useState<ArtistDisplayItem | LoadingArtistDisplayItem>({ loadingArtist: true });
useEffect(() => {
props.getDetails()
.then((details:ArtistDisplayItem) => { setArtist(details); });
});
return <ItemListItem item={artist}/>
}

@ -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) && <ItemListLoadedSongItem item={props.item}/>}
{isLoadingSong(props.item) && <ItemListLoadingSongItem item={props.item}/>}
{isArtist(props.item) && <ItemListLoadedArtistItem item={props.item}/>}
{isLoadingArtist(props.item) && <ItemListLoadingArtistItem item={props.item}/>}
</>
}

@ -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 (
<ListItem>
<ListItemIcon>
<GroupIcon />
</ListItemIcon>
<ListItemText
primary={props.item.name}
/>
{props.item.tagNames.map((tag: any) => {
return <Chip label={tag}/>
})}
{props.item.storeLinks.map((link: any) => {
return <a href={link.url} target="_blank" rel="noopener noreferrer">
<ListItemIcon>
{link.icon}
</ListItemIcon>
</a>;
})}
</ListItem>
);
}

@ -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 (
<ListItem>
<ListItemIcon>
<MusicNoteIcon />
</ListItemIcon>
<ListItemText
primary={props.item.title}
secondary={artists}
/>
{props.item.tagNames.map((tag: any) => {
return <Chip label={tag}/>
})}
{props.item.storeLinks.map((link: any) => {
return <a href={link.url} target="_blank" rel="noopener noreferrer">
<ListItemIcon>
{link.icon}
</ListItemIcon>
</a>;
})}
</ListItem>
);
}

@ -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 (
<ListItem>
<ListItemIcon>
<GroupIcon />
</ListItemIcon>
<CircularProgress size={24}/>
</ListItem>
);
}

@ -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 (
<ListItem>
<ListItemIcon>
<MusicNoteIcon />
</ListItemIcon>
<CircularProgress size={24}/>
</ListItem>
);
}

@ -1,18 +0,0 @@
import React, { useEffect } from 'react';
import ItemListItem from './ItemListItem';
import { SongDisplayItem, LoadingSongDisplayItem } from '../types/DisplayItem';
export interface IProps {
getDetails: () => Promise<SongDisplayItem>
}
export default function ItemListSongItem(props: IProps) {
const [ song, setSong ] = React.useState<SongDisplayItem | LoadingSongDisplayItem>({ loadingSong: true });
useEffect(() => {
props.getDetails()
.then((details:SongDisplayItem) => { setSong(details); });
});
return <ItemListItem item={song}/>
}

@ -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 <FormControl component='fieldset'>
<FormLabel component='legend'>Result types</FormLabel>
<FormGroup>
<FormControlLabel
control={<Checkbox checked={props.types[QueryKeys.Songs]} onChange={songChange} name='Songs' />}
label="Songs"
/>
<FormControlLabel
control={<Checkbox checked={props.types[QueryKeys.Artists]} onChange={artistChange} name='Artists' />}
label="Artists"
/>
<FormControlLabel
control={<Checkbox checked={props.types[QueryKeys.Tags]} onChange={tagChange} name='Tags' />}
label="Tags"
/>
</FormGroup>
</FormControl>;
}
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 <FormControl component='fieldset'>
<FormLabel component='legend'>Ordering</FormLabel>
<FormGroup>
<Select
onChange={onTypeChange}
value={props.ordering[QueryKeys.OrderBy][QueryKeys.OrderKey]}
>
<MenuItem value={OrderKey.Name}>Name</MenuItem>
</Select>
<Select
onChange={onAscendingChange}
value={props.ordering[QueryKeys.Ascending] ? 'asc' : 'desc'}
>
<MenuItem value={'asc'}>Ascending</MenuItem>
<MenuItem value={'desc'}>Descending</MenuItem>
</Select>
</FormGroup>
</FormControl>;
}
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<serverApi.SongDetails[]>([]);
const [artists, setArtists] = useState<serverApi.ArtistDetails[]>([]);
//const [tags, setTags] = useState<serverApi.TagDetails[]>([]);
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 <>
<FormControl component='fieldset'>
<FormLabel component='legend'>Query</FormLabel>
<FilterControl
query={props.query}
onChangeQuery={props.onQueryChange}
/>
</FormControl>
<ItemTypeCheckboxes
types={props.typesIncluded || {
[QueryKeys.Songs]: true,
[QueryKeys.Artists]: true,
[QueryKeys.Tags]: true,
}}
onChange={props.onTypesChange}
/>
<OrderingWidget
ordering={props.resultOrder || {
[QueryKeys.OrderBy]: {
[QueryKeys.OrderKey]: OrderKey.Name
},
[QueryKeys.Ascending]: true
}}
onChange={props.onOrderChange}
/>
<BrowseWindow items={items} />
</>
}

@ -0,0 +1,37 @@
import React from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { QBQueryElem } from './querybuilder/QBQueryElem';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, QueryNodeElem, queryOr, queryAnd } from '../lib/Query';
const darkTheme = createMuiTheme({
palette: {
type: 'dark'
},
});
export default function Window(props: any) {
let queens = new QueryLeafElem(
QueryLeafBy.ArtistName,
QueryLeafOp.Equals,
"Queens of the Stone Age"
);
let muse = new QueryLeafElem(
QueryLeafBy.ArtistName,
QueryLeafOp.Equals,
"Muse"
);
let dawnbros = new QueryLeafElem(
QueryLeafBy.ArtistName,
QueryLeafOp.Equals,
"Dawn Brothers"
);
let query = queryOr(
queryAnd(queens, muse),
dawnbros
);
return <ThemeProvider theme={darkTheme}>
<CssBaseline />
<QBQueryElem elem={query} />
</ThemeProvider>
}

@ -0,0 +1,16 @@
import React from 'react';
import { Box, Paper } from '@material-ui/core';
export default function QBAndBlock(props: any) {
return <Paper elevation={3}>
<Box display="flex" flexDirection="column" alignItems="center">
<Box m={0.5} />
{props.children.map((child: any) => {
return <Box m={0.5}>
{child}
</Box>
})}
<Box m={0.5} />
</Box>
</Paper>
}

@ -0,0 +1,26 @@
import React from 'react';
import { Box, Typography } from '@material-ui/core';
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 <Box display="flex" alignItems="center">
<Box m={1}>
{firstChild}
</Box>
{otherChildren.map((child: any) => {
return <>
<Box m={1}>
<Typography variant="button">Or</Typography>
</Box>
<Box m={1}>
{child}
</Box>
</>;
})}
</Box>
}

@ -0,0 +1,25 @@
import React from 'react';
import { QueryLeafElem, QueryNodeElem } from '../../lib/Query';
import { QBQueryLeafElem } from './QBQueryLeafElem';
import { QBQueryNodeElem } from './QBQueryNodeElem';
export interface IProps {
elem: QueryLeafElem | QueryNodeElem,
}
export function QBQueryElem(props: IProps) {
let e = props.elem;
let renderLeaf = (l: any) => {
return <QBQueryLeafElem elem={l} />
}
if (e instanceof QueryLeafElem) {
return renderLeaf(e);
} else if (e instanceof QueryNodeElem) {
return <QBQueryNodeElem elem={e}
renderLeaf={renderLeaf} />
}
throw "Unsupported query element";
}

@ -0,0 +1,29 @@
import React from 'react';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryNodeElem, QueryNodeOp } from '../../lib/Query';
import { Chip, Typography } from '@material-ui/core';
export interface LeafProps {
elem: QueryLeafElem
}
export function QBQueryElemArtistEquals(props: LeafProps) {
let e = props.elem;
const label = <Typography>
By {e.b}
</Typography>;
return <Chip label={label} />
}
export function QBQueryLeafElem(props: LeafProps) {
let e = props.elem;
if (e.a == QueryLeafBy.ArtistName &&
e.op == QueryLeafOp.Equals &&
typeof e.b == "string") {
return <QBQueryElemArtistEquals {...props} />
}
throw "Unsupported leaf element";
}

@ -0,0 +1,35 @@
import React from 'react';
import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock';
import { QueryNodeElem, QueryNodeOp } from '../../lib/Query';
export interface NodeProps {
elem: QueryNodeElem,
renderLeaf: (leaf: any) => any,
}
export function QBQueryNodeElem(props: NodeProps) {
let e = props.elem;
if (e.op == QueryNodeOp.And) {
return <QBAndBlock>
{e.operands.map((o: any) => {
if(o instanceof QueryNodeElem) {
return <QBQueryNodeElem elem={o} renderLeaf={props.renderLeaf}/>
}
return props.renderLeaf(o);
})}
</QBAndBlock>
} else if (e.op == QueryNodeOp.Or) {
return <QBOrBlock>
{e.operands.map((o: any) => {
if(o instanceof QueryNodeElem) {
return <QBQueryNodeElem elem={o} renderLeaf={props.renderLeaf}/>
}
return props.renderLeaf(o);
})}
</QBOrBlock>
}
throw "Unsupported node element";
}

@ -0,0 +1,63 @@
export enum QueryLeafBy {
ArtistName = 0,
AlbumName,
TagName,
SongTitle
}
export enum QueryLeafOp {
Equals = 0,
Like,
}
export type QueryLeafOperand = string | number;
export class QueryLeafElem {
a: QueryLeafBy;
op: QueryLeafOp;
b: QueryLeafOperand;
constructor(
a: QueryLeafBy,
op: QueryLeafOp,
b: QueryLeafOperand
) {
this.a = a;
this.op = op;
this.b = b;
}
};
export enum QueryNodeOp {
And = 0,
Or,
}
export class QueryNodeElem {
operands: QueryElem[];
op: QueryNodeOp;
constructor(
operands: QueryElem[],
op: QueryNodeOp
) {
this.operands = operands;
this.op = op;
}
}
export function queryOr(...args: QueryElem[]) {
return new QueryNodeElem(
args,
QueryNodeOp.Or
);
}
export function queryAnd(...args: QueryElem[]) {
return new QueryNodeElem(
args,
QueryNodeOp.And
);
}
export type QueryElem = QueryLeafElem | QueryNodeElem;

@ -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;
}

@ -1,3 +0,0 @@
export const dragTypes = {
ListItem: 'list item'
}

@ -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<T> {
[QueryKeys.AndQuerySignature]: any,
[QueryKeys.OperandA]: T,
[QueryKeys.OperandB]: T,
}
export function isAndQuery(q: Query): q is AndQuery<Query> {
return QueryKeys.AndQuerySignature in q;
}
export function AndToApiQuery(q: AndQuery<Query>) {
return {
'childrenOperator': QueryElemOp.And,
'children': [
toApiQuery(q.a),
toApiQuery(q.b),
]
}
}
export interface OrQuery<T> {
[QueryKeys.OrQuerySignature]: any,
[QueryKeys.OperandA]: T,
[QueryKeys.OperandB]: T,
}
export function isOrQuery(q: Query): q is OrQuery<Query> {
return QueryKeys.OrQuerySignature in q;
}
export function OrToApiQuery(q: OrQuery<Query>) {
return {
'childrenOperator': QueryElemOp.Or,
'children': [
toApiQuery(q.a),
toApiQuery(q.b),
]
}
}
export type Query = TitleQuery | ArtistQuery | AndQuery<Query> | OrQuery<Query>;
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)) ||
{};
}
Loading…
Cancel
Save