Add edit dialogs for all item types. Names and store links can be edited.

master
Sander Vocke 4 years ago
parent df60e91bf3
commit cda62f0a80
  1. 118
      client/src/components/common/EditItemDialog.tsx
  2. 93
      client/src/components/common/EditableText.tsx
  3. 126
      client/src/components/common/ExternalLinksEditor.tsx
  4. 111
      client/src/components/windows/album/AlbumWindow.tsx
  5. 110
      client/src/components/windows/artist/ArtistWindow.tsx
  6. 97
      client/src/components/windows/tag/TagWindow.tsx
  7. 14
      client/src/components/windows/track/TrackWindow.tsx
  8. 2
      client/src/lib/backend/tags.tsx

@ -0,0 +1,118 @@
import React, { useState } from 'react';
import { Button, Dialog, DialogActions, Divider, Typography, Box, TextField, IconButton } from "@material-ui/core";
import { ExternalLinksEditor } from './ExternalLinksEditor';
import UndoIcon from '@material-ui/icons/Undo';
import { ResourceType } from '../../api/api';
let _ = require('lodash')
export enum EditablePropertyType {
Text = 0,
}
export interface EditableProperty {
metadataKey: string,
title: string,
type: EditablePropertyType
}
function EditTextProperty(props: {
title: string,
originalValue: string,
currentValue: string,
onChange: (v: string) => void
}) {
return <Box display="flex" alignItems="center" width="100%">
<TextField
// Here we "abuse" the label to show the original title.
// emptying the text box means going back to the original.
variant="outlined"
value={props.currentValue}
label={props.title}
helperText={(props.currentValue != props.originalValue) &&
"Current: " + props.originalValue || undefined}
error={(props.currentValue != props.originalValue)}
onChange={(e: any) => {
props.onChange((e.target.value == "") ?
props.originalValue : e.target.value)
}}
fullWidth={true}
/>
{props.currentValue != props.originalValue && <IconButton
onClick={() => {
props.onChange(props.originalValue)
}}
><UndoIcon /></IconButton>}
</Box>
}
function PropertyEditor(props: {
originalMetadata: any,
currentMetadata: any,
onChange: (metadata: any) => void,
editableProperties: EditableProperty[]
}) {
return <Box display="flex" width="100%">
{props.editableProperties.map(
(p: EditableProperty) => {
if (p.type == EditablePropertyType.Text) {
return <EditTextProperty
title={p.title}
originalValue={props.originalMetadata[p.metadataKey]}
currentValue={props.currentMetadata[p.metadataKey]}
onChange={(v: string) => props.onChange({ ...props.currentMetadata, [p.metadataKey]: v })}
/>
}
return undefined;
}
)}
</Box >
}
export default function EditItemDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: any) => void,
id: number,
metadata: any,
defaultExternalLinksQuery: string,
editableProperties: EditableProperty[],
resourceType: ResourceType,
editStoreLinks: boolean,
}) {
let [editingMetadata, setEditingMetadata] = useState<any>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<PropertyEditor
originalMetadata={props.metadata}
currentMetadata={editingMetadata}
onChange={setEditingMetadata}
editableProperties={props.editableProperties}
/>
{props.editStoreLinks && <><Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: any) => setEditingMetadata(v)}
defaultQuery={props.defaultExternalLinksQuery}
resourceType={props.resourceType}
/></>}
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -1,93 +0,0 @@
import React, { useState } from 'react';
import { Box, IconButton, TextField } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import UndoIcon from '@material-ui/icons/Undo';
import { useTheme } from '@material-ui/core/styles';
// This component is an editable text. It shows up as normal text,
// but will display an edit icon on hover. When clicked, this
// enables a text input to make a new suggestion.
// The text can show a striked-through version of the old text,
// with the new value next to it and an undo button.
export interface IProps {
defaultValue: string,
changedValue: string | null, // Null == not changed
editingValue: string | null, // Null == not editing
editingLabel: string,
onChangeEditingValue: (v: string | null) => void,
onChangeChangedValue: (v: string | null) => void,
}
export default function EditableText(props: IProps) {
let editingValue = props.editingValue;
let defaultValue = props.defaultValue;
let changedValue = props.changedValue;
let onChangeEditingValue = props.onChangeEditingValue;
let onChangeChangedValue = props.onChangeChangedValue;
let editing = editingValue !== null;
let editingLabel = props.editingLabel;
const theme = useTheme();
const [hovering, setHovering] = useState<Boolean>(false);
const editButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => onChangeEditingValue(changedValue || defaultValue)}
>
<EditIcon />
</IconButton>
</Box>
const discardChangesButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => {
onChangeChangedValue(null);
onChangeEditingValue(null);
}}
>
<UndoIcon />
</IconButton>
</Box>
if (editing) {
return <Box display="flex" alignItems="center">
<TextField
variant="outlined"
value={editingValue || ""}
label={editingLabel}
inputProps={{ style: { fontSize: '2rem' } }}
onChange={(e: any) => onChangeEditingValue(e.target.value)}
/>
<IconButton
onClick={() => {
onChangeChangedValue(editingValue === defaultValue ? null : editingValue);
onChangeEditingValue(null);
}}
><CheckIcon /></IconButton>
</Box>
} else if (changedValue) {
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>
<del style={{ color: theme.palette.text.secondary }}>{defaultValue}</del>
{changedValue}
{editButton}
{discardChangesButton}
</Box>
}
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>{defaultValue}{editButton}</Box>;
}

@ -1,33 +1,33 @@
import { IntegrationWith, Name, ResourceType, StoreLinks } from '../../api/api';
import { IntegrationState, useIntegrations } from '../../lib/integration/useIntegrations';
import StoreLinkIcon, { whichStore } from './StoreLinkIcon';
import { $enum } from "ts-enum-util";
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { AppBar, Box, Button, Dialog, DialogActions, Divider, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core"; import { IntegrationAlbum, IntegrationArtist, IntegrationFeature, IntegrationTrack } from '../../lib/integration/Integration';
import { TrackMetadata } from "./TrackWindow"; import { Box, List, ListItem, ListItemIcon, ListItemText, IconButton, Typography, FormControl, FormControlLabel, MenuItem, Radio, RadioGroup, Select, TextField } from '@material-ui/core';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search'; import SearchIcon from '@material-ui/icons/Search';
import CancelIcon from '@material-ui/icons/Cancel'; import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import { $enum } from "ts-enum-util";
import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations';
import { IntegrationFeature, IntegrationTrack } from '../../../lib/integration/Integration';
import { TabPanel } from '@material-ui/lab';
import { v1 } from 'uuid';
import { IntegrationWith } from '../../../api/api';
let _ = require('lodash') let _ = require('lodash')
export type ItemWithExternalLinksProperties = StoreLinks & Name;
export function ProvideLinksWidget(props: { export function ProvideLinksWidget(props: {
providers: IntegrationState[], providers: IntegrationState[],
metadata: TrackMetadata, metadata: ItemWithExternalLinksProperties,
store: IntegrationWith, store: IntegrationWith,
onChange: (link: string | undefined) => void, onChange: (link: string | undefined) => void,
defaultQuery: string,
resourceType: ResourceType,
}) { }) {
let defaultQuery = `${props.metadata.name}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.album && ` ${props.metadata.album.name}`}`;
let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>( let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined props.providers.length > 0 ? 0 : undefined
); );
let [query, setQuery] = useState<string>(defaultQuery) let [query, setQuery] = useState<string>(props.defaultQuery)
let [results, setResults] = useState<IntegrationTrack[] | undefined>(undefined); let [results, setResults] = useState<
IntegrationTrack[] | IntegrationAlbum[] | IntegrationArtist[] | undefined>(undefined);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ? let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined; props.providers[selectedProviderIdx] : undefined;
@ -39,7 +39,7 @@ export function ProvideLinksWidget(props: {
// Ensure results are cleared when input state changes. // Ensure results are cleared when input state changes.
useEffect(() => { useEffect(() => {
setResults(undefined); setResults(undefined);
setQuery(defaultQuery); setQuery(props.defaultQuery);
}, [props.store, props.providers, props.metadata]) }, [props.store, props.providers, props.metadata])
return <Box display="flex" flexDirection="column" alignItems="left"> return <Box display="flex" flexDirection="column" alignItems="left">
@ -63,17 +63,44 @@ export function ProvideLinksWidget(props: {
/> />
<IconButton <IconButton
onClick={() => { onClick={() => {
selectedProvider?.integration.searchTrack(query, 10) switch (props.resourceType) {
.then((tracks: IntegrationTrack[]) => setResults(tracks)) case ResourceType.Track:
selectedProvider?.integration.searchTrack(query, 10)
.then((tracks: IntegrationTrack[]) => setResults(tracks))
break;
case ResourceType.Album:
selectedProvider?.integration.searchAlbum(query, 10)
.then((albums: IntegrationAlbum[]) => setResults(albums))
break;
case ResourceType.Artist:
selectedProvider?.integration.searchArtist(query, 10)
.then((artists: IntegrationArtist[]) => setResults(artists))
break;
}
}} }}
><SearchIcon /></IconButton> ><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>} {results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl> <FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}> <RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results && results.map((result: IntegrationTrack, idx: number) => { {results && (results as any).map((result: IntegrationTrack | IntegrationAlbum | IntegrationArtist, idx: number) => {
let pretty = `"${result.title}" var pretty = "";
${result.artist && ` by ${result.artist.name}`} switch (props.resourceType) {
${result.album && ` (${result.album.name})`}`; case ResourceType.Track:
let rt = result as IntegrationTrack;
pretty = `"${rt.title}"
${rt.artist && ` by ${rt.artist.name}`}
${rt.album && ` (${rt.album.name})`}`;
break;
case ResourceType.Album:
let ral = result as IntegrationAlbum;
pretty = `"${ral.name}"
${ral.artist && ` by ${ral.artist.name}`}`;
break;
case ResourceType.Artist:
let rar = result as IntegrationArtist;
pretty = rar.name || "(Unknown Artist)";
break;
}
return <FormControlLabel return <FormControlLabel
value={result.url || idx} value={result.url || idx}
control={<Radio checked={(result.url || idx) === currentLink} />} control={<Radio checked={(result.url || idx) === currentLink} />}
@ -92,14 +119,16 @@ export function ProvideLinksWidget(props: {
} }
export function ExternalLinksEditor(props: { export function ExternalLinksEditor(props: {
metadata: TrackMetadata, metadata: ItemWithExternalLinksProperties,
original: TrackMetadata, original: ItemWithExternalLinksProperties,
onChange: (v: TrackMetadata) => void, onChange: (v: any) => void,
defaultQuery: string,
resourceType: ResourceType,
}) { }) {
let [selectedIdx, setSelectedIdx] = useState<number>(0); let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations(); let integrations = useIntegrations();
let getLinksSet = (metadata: TrackMetadata) => { let getLinksSet = (metadata: ItemWithExternalLinksProperties) => {
return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => { return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => {
var maybeLink: string | null = null; var maybeLink: string | null = null;
metadata.storeLinks && metadata.storeLinks.forEach((link: string) => { metadata.storeLinks && metadata.storeLinks.forEach((link: string) => {
@ -145,7 +174,7 @@ export function ExternalLinksEditor(props: {
> >
<ListItemIcon>{linksSet[store] !== null ? <CheckIcon style={{ color: color }} /> : <CancelIcon style={{ color: color }} />}</ListItemIcon> <ListItemIcon>{linksSet[store] !== null ? <CheckIcon style={{ color: color }} /> : <CancelIcon style={{ color: color }} />}</ListItemIcon>
<ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon> <ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon>
<ListItemText style={{ color: color }} primary={store} /> <ListItemText style={{ color: color }} primary={store} />
{maybeLink && <a href={maybeLink} target="_blank"> {maybeLink && <a href={maybeLink} target="_blank">
<ListItemIcon><IconButton><OpenInNewIcon style={{ color: color }} /></IconButton></ListItemIcon> <ListItemIcon><IconButton><OpenInNewIcon style={{ color: color }} /></IconButton></ListItemIcon>
</a>} </a>}
@ -184,51 +213,10 @@ export function ExternalLinksEditor(props: {
}) })
} }
}} }}
defaultQuery={props.defaultQuery}
resourceType={props.resourceType}
/> />
} }
</Box> </Box>
</Box > </Box >
} }
export default function EditTrackDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: TrackMetadata) => void,
id: number,
metadata: TrackMetadata,
}) {
enum EditTrackTabs {
Details = 0,
ExternalLinks,
}
let [editingMetadata, setEditingMetadata] = useState<TrackMetadata>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<Typography>Under construction</Typography>
<Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: TrackMetadata) => setEditingMetadata(v)}
/>
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -4,16 +4,16 @@ import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../../api/api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable'; import TrackTable from '../../tables/ResultsTable';
import { modifyAlbum } from '../../../lib/saveChanges'; import { modifyAlbum, modifyTrack } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, queryTracks } from '../../../lib/backend/queries'; import { queryAlbums, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
import { Album, Name, Id, StoreLinks, AlbumRefs } from '../../../api/api'; import { Album, Name, Id, StoreLinks, AlbumRefs, Artist, Tag, Track, ResourceType } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type AlbumMetadata = serverApi.QueryResponseAlbumDetails; export type AlbumMetadata = serverApi.QueryResponseAlbumDetails;
export type AlbumMetadataChanges = serverApi.PatchAlbumRequest; export type AlbumMetadataChanges = serverApi.PatchAlbumRequest;
@ -47,7 +47,7 @@ export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
} }
} }
export async function getAlbumMetadata(id: number) : Promise<AlbumMetadata> { export async function getAlbumMetadata(id: number): Promise<AlbumMetadata> {
let result: any = await queryAlbums( let result: any = await queryAlbums(
{ {
a: QueryLeafBy.AlbumId, a: QueryLeafBy.AlbumId,
@ -77,18 +77,21 @@ export function AlbumWindowControlled(props: {
let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state; let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state;
let { dispatch } = props; let { dispatch } = props;
let auth = useAuth(); let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the album's metadata. // Effect to get the album's metadata.
useEffect(() => { useEffect(() => {
getAlbumMetadata(albumId) if (metadata === null) {
.then((m: AlbumMetadata) => { getAlbumMetadata(albumId)
dispatch({ .then((m: AlbumMetadata) => {
type: AlbumWindowStateActions.SetMetadata, dispatch({
value: m type: AlbumWindowStateActions.SetMetadata,
}); value: m
}) });
.catch((e: any) => { handleNotLoggedIn(auth, e) }) })
}, [albumId, dispatch]); .catch((e: any) => { handleNotLoggedIn(auth, e) })
}
}, [albumId, dispatch, metadata]);
// Effect to get the album's tracks. // Effect to get the album's tracks.
useEffect(() => { useEffect(() => {
@ -110,23 +113,7 @@ export function AlbumWindowControlled(props: {
})(); })();
}, [tracksOnAlbum, albumId, dispatch]); }, [tracksOnAlbum, albumId, dispatch]);
const [editingName, setEditingName] = useState<string | null>(null); const name = <Typography variant="h4">{metadata?.name || "(Unknown album name)"}</Typography>
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: AlbumWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link); const store = whichStore(link);
@ -141,23 +128,6 @@ export function AlbumWindowControlled(props: {
</a> </a>
}); });
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyAlbum(props.state.id, pendingChanges || { mbApi_typename: 'album' })
.then(() => {
setApplying(false);
props.dispatch({
type: AlbumWindowStateActions.Reload
})
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
m={1} m={1}
@ -179,14 +149,13 @@ export function AlbumWindowControlled(props: {
{storeLinks} {storeLinks}
</Box> </Box>
</Box> </Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>} </Box>}
</Box> </Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
@ -199,5 +168,39 @@ export function AlbumWindowControlled(props: {
/>} />}
{!props.state.tracksOnAlbum && <CircularProgress />} {!props.state.tracksOnAlbum && <CircularProgress />}
</Box> </Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchAlbumRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
artists: undefined,
tags: undefined,
trackIds: v.trackIds || v.tracks?.map(
(a: (Track & Id)) => { return a.id }
) || undefined,
artistIds: v.artistIds || v.artists?.map(
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyAlbum(albumId, v_modified)
.then(() => dispatch({
type: AlbumWindowStateActions.Reload
}))
}}
id={albumId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Album}
editStoreLinks={true}
/>}
</Box> </Box>
} }

@ -4,15 +4,16 @@ import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../../api/api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable'; import TrackTable from '../../tables/ResultsTable';
import { modifyArtist } from '../../../lib/saveChanges'; import { modifyAlbum, modifyArtist } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, queryTracks } from '../../../lib/backend/queries'; import { queryArtists, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
import { Track, Id, Artist, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type ArtistMetadata = serverApi.QueryResponseArtistDetails; export type ArtistMetadata = serverApi.QueryResponseArtistDetails;
export type ArtistMetadataChanges = serverApi.PatchArtistRequest; export type ArtistMetadataChanges = serverApi.PatchArtistRequest;
@ -51,7 +52,7 @@ export interface IProps {
dispatch: (action: any) => void, dispatch: (action: any) => void,
} }
export async function getArtistMetadata(id: number) : Promise<ArtistMetadata> { export async function getArtistMetadata(id: number): Promise<ArtistMetadata> {
let response: any = await queryArtists( let response: any = await queryArtists(
{ {
a: QueryLeafBy.ArtistId, a: QueryLeafBy.ArtistId,
@ -81,18 +82,21 @@ export function ArtistWindowControlled(props: {
let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state; let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state;
let { dispatch } = props; let { dispatch } = props;
let auth = useAuth(); let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the artist's metadata. // Effect to get the artist's metadata.
useEffect(() => { useEffect(() => {
getArtistMetadata(artistId) if (metadata === null) {
.then((m: ArtistMetadata) => { getArtistMetadata(artistId)
dispatch({ .then((m: ArtistMetadata) => {
type: ArtistWindowStateActions.SetMetadata, dispatch({
value: m type: ArtistWindowStateActions.SetMetadata,
}); value: m
}) });
.catch((e: any) => { handleNotLoggedIn(auth, e) }) })
}, [artistId, dispatch]); .catch((e: any) => { handleNotLoggedIn(auth, e) })
}
}, [artistId, dispatch, metadata]);
// Effect to get the artist's tracks. // Effect to get the artist's tracks.
useEffect(() => { useEffect(() => {
@ -114,23 +118,7 @@ export function ArtistWindowControlled(props: {
})(); })();
}, [tracksByArtist, dispatch, artistId]); }, [tracksByArtist, dispatch, artistId]);
const [editingName, setEditingName] = useState<string | null>(null); const name = <Typography variant="h4">{metadata?.name || "(Unknown artist)"}</Typography>
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: ArtistWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link); const store = whichStore(link);
@ -145,23 +133,6 @@ export function ArtistWindowControlled(props: {
</a> </a>
}); });
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyArtist(props.state.id, pendingChanges || { mbApi_typename: 'artist' })
.then(() => {
setApplying(false);
props.dispatch({
type: ArtistWindowStateActions.Reload
})
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
m={1} m={1}
@ -183,14 +154,13 @@ export function ArtistWindowControlled(props: {
{storeLinks} {storeLinks}
</Box> </Box>
</Box> </Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>} </Box>}
</Box> </Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
@ -203,5 +173,39 @@ export function ArtistWindowControlled(props: {
/>} />}
{!props.state.tracksByArtist && <CircularProgress />} {!props.state.tracksByArtist && <CircularProgress />}
</Box> </Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchArtistRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
albums: undefined,
tags: undefined,
albumIds: v.albumIds || v.albums?.map(
(a: (Album & Id)) => { return a.id }
) || undefined,
trackIds: v.trackIds || v.tracks?.map(
(t: (Track & Id)) => { return t.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyArtist(artistId, v_modified)
.then(() => dispatch({
type: ArtistWindowStateActions.Reload
}))
}}
id={artistId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={true}
/>}
</Box> </Box>
} }

@ -4,13 +4,14 @@ import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../../api/api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable'; import TrackTable from '../../tables/ResultsTable';
import { modifyTag } from '../../../lib/backend/tags'; import { modifyTag } from '../../../lib/backend/tags';
import { queryTags, queryTracks } from '../../../lib/backend/queries'; import { queryTags, queryTracks } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Id, Track, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export interface FullTagMetadata extends serverApi.QueryResponseTagDetails { export interface FullTagMetadata extends serverApi.QueryResponseTagDetails {
fullName: string[], fullName: string[],
@ -49,7 +50,7 @@ export function TagWindowReducer(state: TagWindowState, action: any) {
} }
} }
export async function getTagMetadata(id: number) : Promise<FullTagMetadata> { export async function getTagMetadata(id: number): Promise<FullTagMetadata> {
let tags: any = await queryTags( let tags: any = await queryTags(
{ {
a: QueryLeafBy.TagId, a: QueryLeafBy.TagId,
@ -93,17 +94,20 @@ export function TagWindowControlled(props: {
let pendingChanges = props.state.pendingChanges; let pendingChanges = props.state.pendingChanges;
let { id: tagId, tracksWithTag } = props.state; let { id: tagId, tracksWithTag } = props.state;
let dispatch = props.dispatch; let dispatch = props.dispatch;
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the tag's metadata. // Effect to get the tag's metadata.
useEffect(() => { useEffect(() => {
getTagMetadata(tagId) if (metadata === null) {
.then((m: TagMetadata) => { getTagMetadata(tagId)
dispatch({ .then((m: TagMetadata) => {
type: TagWindowStateActions.SetMetadata, dispatch({
value: m type: TagWindowStateActions.SetMetadata,
}); value: m
}) });
}, [tagId, dispatch]); })
}
}, [tagId, dispatch, metadata]);
// Effect to get the tag's tracks. // Effect to get the tag's tracks.
useEffect(() => { useEffect(() => {
@ -124,23 +128,8 @@ export function TagWindowControlled(props: {
})(); })();
}, [tracksWithTag, tagId, dispatch]); }, [tracksWithTag, tagId, dispatch]);
const [editingName, setEditingName] = useState<string | null>(null); const name = <Typography variant="h4">{metadata?.name || "(Unknown tag name)"}</Typography>
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: TagWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const fullName = <Box display="flex" alignItems="center"> const fullName = <Box display="flex" alignItems="center">
{metadata?.fullName.map((n: string, i: number) => { {metadata?.fullName.map((n: string, i: number) => {
if (metadata?.fullName && i === metadata?.fullName.length - 1) { if (metadata?.fullName && i === metadata?.fullName.length - 1) {
@ -153,22 +142,6 @@ export function TagWindowControlled(props: {
})} })}
</Box> </Box>
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyTag(props.state.id, pendingChanges || { mbApi_typename: 'tag' })
.then(() => {
setApplying(false);
props.dispatch({
type: TagWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
m={1} m={1}
@ -186,12 +159,11 @@ export function TagWindowControlled(props: {
{fullName} {fullName}
</Box> </Box>
</Box>} </Box>}
</Box> <Box m={1}>
<Box <IconButton
m={1} onClick={() => { setEditing(true); }}
width="80%" ><EditIcon /></IconButton>
> </Box>
{maybeSubmitButton}
</Box> </Box>
<Box <Box
m={1} m={1}
@ -205,5 +177,30 @@ export function TagWindowControlled(props: {
/>} />}
{!props.state.tracksWithTag && <CircularProgress />} {!props.state.tracksWithTag && <CircularProgress />}
</Box> </Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTagRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified: serverApi.PatchTagRequest = {
mbApi_typename: 'tag',
name: v.name,
parent: undefined,
parentId: v.parentId || v.parent?.id || undefined,
};
modifyTag(tagId, v_modified)
.then(() => dispatch({
type: TagWindowStateActions.Reload
}))
}}
id={tagId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={false}
/>}
</Box> </Box>
} }

@ -11,11 +11,11 @@ import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryTracks } from '../../../lib/backend/queries'; import { queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import EditTrackDialog from './EditTrackDialog';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import { modifyTrack } from '../../../lib/saveChanges'; import { modifyTrack } from '../../../lib/saveChanges';
import { getTrack } from '../../../lib/backend/tracks'; import { getTrack } from '../../../lib/backend/tracks';
import { Artist, Id, Tag } from '../../../api/api'; import { Artist, Id, ResourceType, Tag } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
export type TrackMetadata = serverApi.QueryResponseTrackDetails; export type TrackMetadata = serverApi.QueryResponseTrackDetails;
@ -70,7 +70,7 @@ export function TrackWindowControlled(props: {
} }
}, [trackId, dispatch, metadata]); }, [trackId, dispatch, metadata]);
const title = <Typography variant="h4">{metadata?.name || "(Unknown title)"}</Typography> const title = <Typography variant="h4">{metadata?.name || "(Unknown track title)"}</Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: (serverApi.Artist & serverApi.Name)) => { const artists = metadata?.artists && metadata?.artists.map((artist: (serverApi.Artist & serverApi.Name)) => {
return <Typography> return <Typography>
@ -139,7 +139,7 @@ export function TrackWindowControlled(props: {
</Box> </Box>
</Box>} </Box>}
</Box> </Box>
{metadata && <EditTrackDialog {metadata && <EditItemDialog
open={editing} open={editing}
onClose={() => { setEditing(false); }} onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTrackRequest) => { onSubmit={(v: serverApi.PatchTrackRequest) => {
@ -164,6 +164,12 @@ export function TrackWindowControlled(props: {
}} }}
id={trackId} id={trackId}
metadata={metadata} metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Title', type: EditablePropertyType.Text },
]}
resourceType={ResourceType.Track}
editStoreLinks={true}
defaultExternalLinksQuery={`${metadata.name}${metadata.artists && ` ${metadata.artists[0].name}`}${metadata.album && ` ${metadata.album.name}`}`}
/>} />}
</Box> </Box>
} }

@ -17,7 +17,7 @@ export async function createTag(details: serverApi.PostTagRequest) {
export async function modifyTag(id: number, details: serverApi.PatchTagRequest) { export async function modifyTag(id: number, details: serverApi.PatchTagRequest) {
const requestOpts = { const requestOpts = {
method: 'PUT', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details), body: JSON.stringify(details),
}; };

Loading…
Cancel
Save