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