Feat: Edit Artists

This commit is contained in:
onespaceman 2026-02-16 14:15:10 -05:00
parent e36eb48036
commit c641707be9
15 changed files with 678 additions and 283 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

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

View file

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