mirror of
https://github.com/gabehf/Koito.git
synced 2026-04-23 04:21:51 -07:00
Feat: Edit Artists
This commit is contained in:
parent
e36eb48036
commit
c641707be9
15 changed files with 678 additions and 283 deletions
69
client/app/components/ComboBox.tsx
Normal file
69
client/app/components/ComboBox.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useDeferredValue, useEffect, useState } from "react";
|
||||
import { search, type SearchResponse } from "api/api";
|
||||
import { useCombobox } from "downshift";
|
||||
|
||||
interface Props {
|
||||
onSelection: (selection: any) => void;
|
||||
filterFunction: (r: SearchResponse) => SearchResponse;
|
||||
}
|
||||
|
||||
export default function ComboBox({ onSelection, filterFunction }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deferredQuery) {
|
||||
search(deferredQuery).then((r) => {
|
||||
const filtered = filterFunction(r);
|
||||
setData([...filtered.artists, ...filtered.albums, ...filtered.tracks]);
|
||||
});
|
||||
}
|
||||
}, [deferredQuery]);
|
||||
|
||||
const { isOpen, getMenuProps, getInputProps, getItemProps, selectedItem } =
|
||||
useCombobox({
|
||||
items: data,
|
||||
itemToString(item) {
|
||||
return item ? item.title || item.name : "";
|
||||
},
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
setQuery(inputValue);
|
||||
},
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
if (newSelectedItem) {
|
||||
setQuery(newSelectedItem.name);
|
||||
onSelection(selectedItem);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
{...getInputProps()}
|
||||
value={query}
|
||||
placeholder="Add an artist"
|
||||
className="mx-auto fg bg rounded-md p-3 w-full"
|
||||
/>
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`bg rounded-b-md p-3 absolute ${
|
||||
!(isOpen && data.length) && "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
data &&
|
||||
data.map((item, index) => (
|
||||
<li
|
||||
className="fg py-2 px-3 rounded-md shadow-sm cursor-pointer hover:bg-(--color-bg-tertiary)"
|
||||
key={item.id}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
{item.title || item.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
client/app/components/modals/EditModal/AliasManager.tsx
Normal file
139
client/app/components/modals/EditModal/AliasManager.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
getAliases,
|
||||
setPrimaryAlias,
|
||||
type Alias,
|
||||
} from "api/api";
|
||||
import { AsyncButton } from "../../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export default function AliasManager({ type, id }: Props) {
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setError] = useState<string>();
|
||||
const [displayData, setDisplayData] = useState<Alias[]>([]);
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"aliases",
|
||||
{
|
||||
type: type,
|
||||
id: id,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => {
|
||||
const params = queryKey[1] as { type: string; id: number };
|
||||
return getAliases(params.type, params.id);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const handleSetPrimary = (alias: string) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
setPrimaryAlias(type, id, alias).then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleNewAlias = () => {
|
||||
setError(undefined);
|
||||
if (input === "") {
|
||||
setError("no input");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
createAlias(type, id, input).then((r) => {
|
||||
if (r.ok) {
|
||||
setDisplayData([
|
||||
...displayData,
|
||||
{ alias: input, source: "Manual", is_primary: false, id: id },
|
||||
]);
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteAlias = (alias: string) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
deleteAlias(type, id, alias).then((r) => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.alias != alias));
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h3>Alias Manager</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>
|
||||
{v.alias} (source: {v.source})
|
||||
</div>
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleSetPrimary(v.alias)}
|
||||
disabled={v.is_primary}
|
||||
>
|
||||
Set Primary
|
||||
</AsyncButton>
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleDeleteAlias(v.alias)}
|
||||
confirm
|
||||
disabled={v.is_primary}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a new alias"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleNewAlias}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
client/app/components/modals/EditModal/ArtistManager.tsx
Normal file
167
client/app/components/modals/EditModal/ArtistManager.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { type Artist, type SearchResponse } from "api/api";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AsyncButton } from "../../AsyncButton";
|
||||
import ComboBox from "~/components/ComboBox";
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export default function ArtistManager({ type, id }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setError] = useState<string>();
|
||||
const [displayData, setDisplayData] = useState<Artist[]>([]);
|
||||
const [addArtistTarget, setAddArtistTarget] = useState<Artist>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||
queryFn: () => {
|
||||
return fetch(
|
||||
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
|
||||
).then((r) => r.json()) as Promise<Artist[]>;
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectArtist = useCallback(
|
||||
(artist: Artist) => {
|
||||
setAddArtistTarget(artist);
|
||||
},
|
||||
[type, id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const handleSetPrimary = (artist: Artist, val: boolean) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
fetch(
|
||||
`/apis/web/v1/artists/primary?artist_id=${
|
||||
artist.id
|
||||
}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
).then(async (r) => {
|
||||
if (r.ok) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||
});
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddArtist = () => {
|
||||
setError(undefined);
|
||||
if (!addArtistTarget) {
|
||||
setError("no artist selected");
|
||||
return;
|
||||
}
|
||||
const form = new URLSearchParams();
|
||||
form.append("add_artist", String(addArtistTarget.id));
|
||||
setLoading(true);
|
||||
fetch(`/apis/web/v1/${type}?id=${id}`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
}).then(async (r) => {
|
||||
if (r.ok) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||
});
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteArtist = (artist: number) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
const form = new URLSearchParams();
|
||||
form.append("remove_artist", String(artist));
|
||||
fetch(`/apis/web/v1/${type}?id=${id}`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
}).then(async (r) => {
|
||||
if (r.ok) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["get-artists-" + type.toLowerCase(), { id: id }],
|
||||
});
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h3>Artist Manager</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.name}>
|
||||
{v.name}
|
||||
</div>
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleSetPrimary(v, true)}
|
||||
disabled={v.is_primary}
|
||||
>
|
||||
Set Primary
|
||||
</AsyncButton>
|
||||
{type == "track" && (
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleDeleteArtist(v.id)}
|
||||
confirm
|
||||
disabled={v.is_primary}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{type == "track" && (
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<ComboBox
|
||||
onSelection={handleSelectArtist}
|
||||
filterFunction={(r: SearchResponse) => {
|
||||
r.albums = [];
|
||||
r.tracks = [];
|
||||
const ids = displayData.map((d) => d.id);
|
||||
r.artists = r.artists.filter((a) => !ids.includes(a.id));
|
||||
return r;
|
||||
}}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleAddArtist}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="error">{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,8 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
getAliases,
|
||||
setPrimaryAlias,
|
||||
updateMbzId,
|
||||
type Alias,
|
||||
} from "api/api";
|
||||
import { Modal } from "../Modal";
|
||||
import { AsyncButton } from "../../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
import AliasManager from "./AliasManager";
|
||||
import SetVariousArtists from "./SetVariousArtist";
|
||||
import SetPrimaryArtist from "./SetPrimaryArtist";
|
||||
import UpdateMbzID from "./UpdateMbzID";
|
||||
import ArtistManager from "./ArtistManager";
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
|
|
@ -23,141 +12,22 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function EditModal({ open, setOpen, type, id }: Props) {
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setError] = useState<string>();
|
||||
const [displayData, setDisplayData] = useState<Alias[]>([]);
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"aliases",
|
||||
{
|
||||
type: type,
|
||||
id: id,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => {
|
||||
const params = queryKey[1] as { type: string; id: number };
|
||||
return getAliases(params.type, params.id);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const handleSetPrimary = (alias: string) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
setPrimaryAlias(type, id, alias).then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleNewAlias = () => {
|
||||
setError(undefined);
|
||||
if (input === "") {
|
||||
setError("no input");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
createAlias(type, id, input).then((r) => {
|
||||
if (r.ok) {
|
||||
setDisplayData([
|
||||
...displayData,
|
||||
{ alias: input, source: "Manual", is_primary: false, id: id },
|
||||
]);
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteAlias = (alias: string) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
deleteAlias(type, id, alias).then((r) => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.alias != alias));
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
type = type.toLowerCase();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal maxW={1000} isOpen={open} onClose={handleClose}>
|
||||
<div className="flex flex-col items-start gap-6 w-full">
|
||||
<div className="w-full">
|
||||
<h3>Alias Manager</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>
|
||||
{v.alias} (source: {v.source})
|
||||
</div>
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleSetPrimary(v.alias)}
|
||||
disabled={v.is_primary}
|
||||
>
|
||||
Set Primary
|
||||
</AsyncButton>
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleDeleteAlias(v.alias)}
|
||||
confirm
|
||||
disabled={v.is_primary}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a new alias"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleNewAlias}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{type.toLowerCase() === "album" && (
|
||||
<AliasManager id={id} type={type} />
|
||||
{type === "album" && (
|
||||
<>
|
||||
<SetVariousArtists id={id} />
|
||||
<SetPrimaryArtist id={id} type="album" />
|
||||
</>
|
||||
)}
|
||||
{type.toLowerCase() === "track" && (
|
||||
<SetPrimaryArtist id={id} type="track" />
|
||||
)}
|
||||
{type !== "artist" && <ArtistManager id={id} type={type} />}
|
||||
<UpdateMbzID type={type} id={id} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAlbum, type Artist } from "api/api";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function SetPrimaryArtist({ id, type }: Props) {
|
||||
const [err, setErr] = useState("");
|
||||
const [primary, setPrimary] = useState<Artist>();
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"get-artists-" + type.toLowerCase(),
|
||||
{
|
||||
id: id,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return fetch(
|
||||
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
|
||||
).then((r) => r.json()) as Promise<Artist[]>;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
for (let a of data) {
|
||||
if (a.is_primary) {
|
||||
setPrimary(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const updatePrimary = (artist: number, val: boolean) => {
|
||||
setErr("");
|
||||
setSuccess("");
|
||||
fetch(
|
||||
`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
).then((r) => {
|
||||
if (r.ok) {
|
||||
setSuccess("successfully updated primary artists");
|
||||
} else {
|
||||
r.json().then((r) => setErr(r.error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h3>Set Primary Artist</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<select
|
||||
name="mark-various-artists"
|
||||
id="mark-various-artists"
|
||||
className="w-60 px-3 py-2 rounded-md"
|
||||
value={primary?.name || ""}
|
||||
onChange={(e) => {
|
||||
for (let a of data) {
|
||||
if (a.name === e.target.value) {
|
||||
setPrimary(a);
|
||||
updatePrimary(a.id, true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select an artist
|
||||
</option>
|
||||
{data.map((a) => (
|
||||
<option key={a.id} value={a.name}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue