User interface overhaul #16
Merged
sander
merged 15 commits from ui_overhaul
into master
5 years ago
42 changed files with 1357 additions and 1305 deletions
After Width: | Height: | Size: 9.7 KiB |
@ -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,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<QueryElem | null>(null); |
||||||
|
const [resultsFor, setResultsFor] = useState<ResultsFor | null>(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 <ThemeProvider theme={darkTheme}> |
||||||
|
<CssBaseline /> |
||||||
|
<AppBar position="static" style={{ background: 'grey' }}> |
||||||
|
<Box m={0.5} display="flex" alignItems="center"> |
||||||
|
<img height="30px" src={process.env.PUBLIC_URL + "/logo.svg"} alt="error"></img> |
||||||
|
</Box> |
||||||
|
</AppBar> |
||||||
|
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> |
||||||
|
<Box |
||||||
|
m={1} |
||||||
|
width="80%" |
||||||
|
> |
||||||
|
<QueryBuilder |
||||||
|
query={query} |
||||||
|
onChangeQuery={setQuery} |
||||||
|
requestFunctions={{ |
||||||
|
getArtists: getArtists, |
||||||
|
getSongTitles: getSongTitles, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
<Box |
||||||
|
m={1} |
||||||
|
width="80%" |
||||||
|
> |
||||||
|
<SongTable |
||||||
|
songs={showResults} |
||||||
|
songGetters={songGetters} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
|
||||||
|
</ThemeProvider> |
||||||
|
} |
@ -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 <Menu |
||||||
|
anchorEl={anchorEl} |
||||||
|
keepMounted |
||||||
|
open={Boolean(anchorEl)} |
||||||
|
onClose={onClose} |
||||||
|
> |
||||||
|
<MenuItem disabled={true}>New query element</MenuItem> |
||||||
|
<NestedMenuItem |
||||||
|
label="Song" |
||||||
|
parentMenuOpen={Boolean(anchorEl)} |
||||||
|
> |
||||||
|
<QBSelectWithRequest |
||||||
|
label="Title" |
||||||
|
getNewOptions={props.requestFunctions.getSongTitles} |
||||||
|
onSubmit={(s: string, exact: boolean) => { |
||||||
|
onClose(); |
||||||
|
props.onCreateQuery({ |
||||||
|
a: QueryLeafBy.SongTitle, |
||||||
|
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, |
||||||
|
b: s |
||||||
|
}); |
||||||
|
}} |
||||||
|
style={{ width: 300 }} |
||||||
|
/> |
||||||
|
</NestedMenuItem> |
||||||
|
<NestedMenuItem |
||||||
|
label="Artist" |
||||||
|
parentMenuOpen={Boolean(anchorEl)} |
||||||
|
> |
||||||
|
<QBSelectWithRequest |
||||||
|
label="Name" |
||||||
|
getNewOptions={props.requestFunctions.getArtists} |
||||||
|
onSubmit={(s: string, exact: boolean) => { |
||||||
|
onClose(); |
||||||
|
props.onCreateQuery({ |
||||||
|
a: QueryLeafBy.ArtistName, |
||||||
|
leafOp: exact ? QueryLeafOp.Equals : QueryLeafOp.Like, |
||||||
|
b: s |
||||||
|
}); |
||||||
|
}} |
||||||
|
style={{ width: 300 }} |
||||||
|
/> |
||||||
|
</NestedMenuItem> |
||||||
|
</Menu > |
||||||
|
} |
@ -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, idx: number) => { |
||||||
|
return <Box m={0.5} key={idx}> |
||||||
|
{child} |
||||||
|
</Box> |
||||||
|
})} |
||||||
|
<Box m={0.5} /> |
||||||
|
</Box> |
||||||
|
</Paper> |
||||||
|
} |
@ -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 <IconButton {...props}> |
||||||
|
{(!props.editing) && <SearchIcon style={{ fontSize: 80 }} />} |
||||||
|
{(props.editing) && <CheckIcon style={{ fontSize: 80 }} />} |
||||||
|
</IconButton> |
||||||
|
} |
@ -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 = <Box display="flex" alignItems="center"> |
||||||
|
<Typography>{props.label}</Typography> |
||||||
|
{props.extraElements} |
||||||
|
</Box> |
||||||
|
return <Chip label={label} /> |
||||||
|
} |
||||||
|
|
||||||
|
export interface LeafProps { |
||||||
|
elem: QueryLeafElem, |
||||||
|
onReplace: (q: QueryElem) => void, |
||||||
|
extraElements?: any, |
||||||
|
} |
||||||
|
|
||||||
|
export function QBQueryElemArtistEquals(props: LeafProps) { |
||||||
|
return <LabeledElemChip |
||||||
|
label={"By " + props.elem.b} |
||||||
|
extraElements={props.extraElements} |
||||||
|
/> |
||||||
|
} |
||||||
|
|
||||||
|
export function QBQueryElemArtistLike(props: LeafProps) { |
||||||
|
return <LabeledElemChip label={"Artist like \"" + props.elem.b + "\""} |
||||||
|
extraElements={props.extraElements} |
||||||
|
/> |
||||||
|
} |
||||||
|
|
||||||
|
export function QBQueryElemTitleEquals(props: LeafProps) { |
||||||
|
return <LabeledElemChip |
||||||
|
label={"\"" + props.elem.b + "\""} |
||||||
|
extraElements={props.extraElements} |
||||||
|
/> |
||||||
|
} |
||||||
|
|
||||||
|
export function QBQueryElemTitleLike(props: LeafProps) { |
||||||
|
return <LabeledElemChip |
||||||
|
label={"Title like \"" + props.elem.b + "\""} |
||||||
|
extraElements={props.extraElements} |
||||||
|
/> |
||||||
|
} |
||||||
|
|
||||||
|
export interface DeleteButtonProps { |
||||||
|
onClick?: (e: any) => void, |
||||||
|
} |
||||||
|
|
||||||
|
export function QBQueryElemDeleteButton(props: DeleteButtonProps) { |
||||||
|
return <IconButton |
||||||
|
onClick={props.onClick} |
||||||
|
disableRipple={true} |
||||||
|
size="small" |
||||||
|
> |
||||||
|
<DeleteIcon /> |
||||||
|
</IconButton> |
||||||
|
} |
||||||
|
|
||||||
|
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 ? |
||||||
|
<Box m={0.5}> |
||||||
|
<QBQueryElemDeleteButton |
||||||
|
onClick={() => props.onReplace(null)} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
: undefined; |
||||||
|
|
||||||
|
if (e.a == QueryLeafBy.ArtistName && |
||||||
|
e.leafOp == QueryLeafOp.Equals && |
||||||
|
typeof e.b == "string") { |
||||||
|
return <QBQueryElemArtistEquals |
||||||
|
{...props} |
||||||
|
extraElements={extraElements} |
||||||
|
/> |
||||||
|
} else if (e.a == QueryLeafBy.ArtistName && |
||||||
|
e.leafOp == QueryLeafOp.Like && |
||||||
|
typeof e.b == "string") { |
||||||
|
return <QBQueryElemArtistLike |
||||||
|
{...props} |
||||||
|
extraElements={extraElements} |
||||||
|
/> |
||||||
|
} if (e.a == QueryLeafBy.SongTitle && |
||||||
|
e.leafOp == QueryLeafOp.Equals && |
||||||
|
typeof e.b == "string") { |
||||||
|
return <QBQueryElemTitleEquals |
||||||
|
{...props} |
||||||
|
extraElements={extraElements} |
||||||
|
/> |
||||||
|
} else if (e.a == QueryLeafBy.SongTitle && |
||||||
|
e.leafOp == QueryLeafOp.Like && |
||||||
|
typeof e.b == "string") { |
||||||
|
return <QBQueryElemTitleLike |
||||||
|
{...props} |
||||||
|
extraElements={extraElements} |
||||||
|
/> |
||||||
|
} else if (e.leafOp == QueryLeafOp.Placeholder) { |
||||||
|
return <QBPlaceholder |
||||||
|
onReplace={props.onReplace} |
||||||
|
requestFunctions={props.requestFunctions} |
||||||
|
/> |
||||||
|
} |
||||||
|
|
||||||
|
throw "Unsupported leaf element"; |
||||||
|
} |
@ -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 <QBQueryElem |
||||||
|
elem={o} |
||||||
|
onReplace={(q: QueryElem | null) => onReplace(idx, q)} |
||||||
|
editingQuery={props.editingQuery} |
||||||
|
requestFunctions={props.requestFunctions} |
||||||
|
/> |
||||||
|
}); |
||||||
|
|
||||||
|
if (e.nodeOp == QueryNodeOp.And) { |
||||||
|
return <QBAndBlock>{children}</QBAndBlock> |
||||||
|
} else if (e.nodeOp == QueryNodeOp.Or) { |
||||||
|
return <QBOrBlock>{children}</QBOrBlock> |
||||||
|
} |
||||||
|
|
||||||
|
throw "Unsupported node element"; |
||||||
|
} |
@ -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 <Box |
||||||
|
display="flex" |
||||||
|
alignItems="center" |
||||||
|
> |
||||||
|
<Box m={1}> |
||||||
|
{firstChild} |
||||||
|
</Box> |
||||||
|
{otherChildren.map((child: any, idx: number) => { |
||||||
|
return <Box display="flex" alignItems="center" key={idx}> |
||||||
|
<Box m={1}> |
||||||
|
<Typography variant="button">Or</Typography> |
||||||
|
</Box> |
||||||
|
<Box m={1}> |
||||||
|
{child} |
||||||
|
</Box> |
||||||
|
</Box>; |
||||||
|
})} |
||||||
|
</Box> |
||||||
|
} |
@ -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 | HTMLElement>(null); |
||||||
|
|
||||||
|
const onOpen = (event: any) => { |
||||||
|
setAnchorEl(event.currentTarget); |
||||||
|
}; |
||||||
|
const onClose = () => { |
||||||
|
setAnchorEl(null); |
||||||
|
}; |
||||||
|
const onCreate = (q: QueryElem) => { |
||||||
|
props.onReplace(q); |
||||||
|
}; |
||||||
|
|
||||||
|
return <> |
||||||
|
<Chip |
||||||
|
variant="outlined" |
||||||
|
label="" |
||||||
|
style={{ width: "50px" }} |
||||||
|
clickable={true} |
||||||
|
onClick={onOpen} |
||||||
|
component="div" |
||||||
|
/> |
||||||
|
<QBAddElemMenu |
||||||
|
anchorEl={anchorEl} |
||||||
|
onClose={onClose} |
||||||
|
onCreateQuery={onCreate} |
||||||
|
requestFunctions={props.requestFunctions} |
||||||
|
/> |
||||||
|
</> |
||||||
|
} |
@ -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 <QBLeafElem |
||||||
|
elem={e} |
||||||
|
onReplace={props.onReplace} |
||||||
|
editingQuery={props.editingQuery} |
||||||
|
requestFunctions={props.requestFunctions} |
||||||
|
/> |
||||||
|
} else if (isNodeElem(e)) { |
||||||
|
return <QBNodeElem |
||||||
|
elem={e} |
||||||
|
onReplace={props.onReplace} |
||||||
|
editingQuery={props.editingQuery} |
||||||
|
requestFunctions={props.requestFunctions} |
||||||
|
/> |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Unsupported query element"); |
||||||
|
} |
@ -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<string[]>, |
||||||
|
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<OptionsFor | null>(null); |
||||||
|
const [input, setInput] = useState<string>(""); |
||||||
|
|
||||||
|
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 ( |
||||||
|
<Autocomplete |
||||||
|
{...restProps} |
||||||
|
open={open} |
||||||
|
onOpen={() => { |
||||||
|
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) => ( |
||||||
|
<TextField |
||||||
|
{...params} |
||||||
|
label={label} |
||||||
|
variant="outlined" |
||||||
|
InputProps={{ |
||||||
|
...params.InputProps, |
||||||
|
endAdornment: ( |
||||||
|
<React.Fragment> |
||||||
|
{loading ? <CircularProgress color="inherit" size={20} /> : null} |
||||||
|
{params.InputProps.endAdornment} |
||||||
|
</React.Fragment> |
||||||
|
), |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -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<string[]>, |
||||||
|
getSongTitles: (filter: string) => Promise<string[]>, |
||||||
|
} |
||||||
|
|
||||||
|
export interface IProps { |
||||||
|
query: QueryElem | null, |
||||||
|
onChangeQuery: (q: QueryElem | null) => void, |
||||||
|
requestFunctions: Requests, |
||||||
|
} |
||||||
|
|
||||||
|
export default function QueryBuilder(props: IProps) { |
||||||
|
const [editing, setEditing] = useState<boolean>(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 <> |
||||||
|
<Box display="flex" alignItems="center"> |
||||||
|
<Box m={2}> |
||||||
|
<QBQueryButton |
||||||
|
onClick={() => setEditing(!editing)} |
||||||
|
editing={editing} |
||||||
|
/> |
||||||
|
</Box> |
||||||
|
<Box m={2}> |
||||||
|
{showQuery && <QBQueryElem |
||||||
|
elem={showQuery} |
||||||
|
onReplace={onReplace} |
||||||
|
editingQuery={editing} |
||||||
|
requestFunctions={props.requestFunctions} |
||||||
|
/>} |
||||||
|
</Box> |
||||||
|
</Box> |
||||||
|
</> |
||||||
|
} |
@ -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 ( |
||||||
|
<TableContainer component={Paper}> |
||||||
|
<Table className={classes.table} aria-label="a dense table"> |
||||||
|
<TableHead> |
||||||
|
<TableRow> |
||||||
|
<TableCell align="left">Title</TableCell> |
||||||
|
<TableCell align="left">Artist</TableCell> |
||||||
|
<TableCell align="left">Album</TableCell> |
||||||
|
</TableRow> |
||||||
|
</TableHead> |
||||||
|
<TableBody> |
||||||
|
{props.songs.map((song:any) => { |
||||||
|
const title = props.songGetters.getTitle(song); |
||||||
|
const artist = props.songGetters.getArtist(song); |
||||||
|
const album = props.songGetters.getAlbum(song); |
||||||
|
|
||||||
|
return <TableRow key={title}> |
||||||
|
<TableCell align="left">{title}</TableCell> |
||||||
|
<TableCell align="left">{artist}</TableCell> |
||||||
|
<TableCell align="left">{album}</TableCell> |
||||||
|
</TableRow> |
||||||
|
})} |
||||||
|
</TableBody> |
||||||
|
</Table> |
||||||
|
</TableContainer> |
||||||
|
); |
||||||
|
} |
@ -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, QueryNodeOp> = { |
||||||
|
[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 {}; |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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)) || |
|
||||||
{}; |
|
||||||
} |
|
After Width: | Height: | Size: 9.7 KiB |
After Width: | Height: | Size: 11 KiB |
Loading…
Reference in new issue