feat: v0.0.8

This commit is contained in:
Gabe Farrell 2025-06-16 21:55:39 -04:00
parent 00e7782be2
commit 80b6f4deaa
66 changed files with 1559 additions and 916 deletions

View file

@ -226,6 +226,7 @@ type Artist = {
listen_count: number
musicbrainz_id: string
time_listened: number
is_primary: boolean
}
type Album = {
id: number,

View file

@ -16,7 +16,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
const selectItem = (title: string, id: number) => {
if (selected === id) {
setSelected(0)
onSelect({id: id, title: title})
onSelect({id: 0, title: ''})
} else {
setSelected(id)
onSelect({id: id, title: title})

View file

@ -35,92 +35,52 @@ export default function TopItemList<T extends Item>({ data, separators, type, cl
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
const navigate = useNavigate();
const handleItemClick = (type: string, id: number) => {
navigate(`/${type.toLowerCase()}/${id}`);
};
const handleArtistClick = (event: React.MouseEvent) => {
// Stop the click from navigating to the album page
event.stopPropagation();
};
// Also stop keyboard events on the inner links from bubbling up
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
event.stopPropagation();
}
const itemClasses = `flex items-center gap-2`
switch (type) {
case "album": {
const album = item as Album;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("album", album.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("album", album.id)}
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View album: ${album.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(album.image, "small")} alt={album.title} />
<div>
<div style={{fontSize: 12}} className={itemClasses}>
<Link to={`/album/${album.id}`}>
<img loading="lazy" src={imageUrl(album.image, "small")} alt={album.title} className="min-w-[48px]" />
</Link>
<div>
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
<span style={{fontSize: 14}}>{album.title}</span>
<br />
{album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span>
:
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
</div>
}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</Link>
<br />
{album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span>
:
<div>
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
</div>
}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</div>
</div>
);
}
case "track": {
const track = item as Track;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("track", track.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("track", track.id)}
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View track: ${track.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(track.image, "small")} alt={track.title} />
<div style={{fontSize: 12}} className={itemClasses}>
<Link to={`/track/${track.id}`}>
<img loading="lazy" src={imageUrl(track.image, "small")} alt={track.title} className="min-w-[48px]" />
</Link>
<div>
<span style={{fontSize: 14}}>{track.title}</span>
<Link to={`/track/${track.id}`} className="hover:text-(--color-fg-secondary)">
<span style={{fontSize: 14}}>{track.title}</span>
</Link>
<br />
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<div>
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div>
<div className="color-fg-secondary">{track.listen_count} plays</div>
</div>
</div>
</div>
);
}
@ -128,12 +88,12 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
const artist = item as Artist;
return (
<div style={{fontSize: 12}}>
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
<div>
<span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div>
</div>
<Link className={itemClasses+' mt-1 mb-[6px] hover:text-(--color-fg-secondary)'} to={`/artist/${artist.id}`}>
<img loading="lazy" src={imageUrl(artist.image, "small")} alt={artist.name} className="min-w-[48px]" />
<div>
<span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div>
</div>
</Link>
</div>
);

View file

@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { createAlias, deleteAlias, getAliases, getAlbum, setPrimaryAlias, type Album, type Alias } from "api/api";
import { Modal } from "./Modal";
import { AsyncButton } from "../AsyncButton";
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
import { Modal } from "../Modal";
import { AsyncButton } from "../../AsyncButton";
import { useEffect, useState } from "react";
import { Trash } from "lucide-react";
import SetVariousArtists from "./SetVariousArtist";
import SetPrimaryArtist from "./SetPrimaryArtist";
interface Props {
type: string
@ -18,7 +19,6 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<Alias[]>([])
const [variousArtists, setVariousArtists] = useState(false)
const { isPending, isError, data, error } = useQuery({
queryKey: [
@ -125,7 +125,10 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
</div>
</div>
{ type.toLowerCase() === "album" &&
<>
<SetVariousArtists id={id} />
<SetPrimaryArtist id={id} type="album" />
</>
}
</div>
</Modal>

View file

@ -0,0 +1,99 @@
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">
<h2>Set Primary Artist</h2>
<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>
);
}

View file

@ -73,6 +73,7 @@ export default function SetVariousArtists({ id }: Props) {
<option value="false">False</option>
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
)

View file

@ -34,15 +34,11 @@ export default function MergeModal(props: Props) {
}
const toggleSelect = ({title, id}: {title: string, id: number}) => {
if (mergeTarget.id === 0) {
setMergeTarget({title: title, id: id})
} else {
setMergeTarget({title:"", id: 0})
}
setMergeTarget({title: title, id: id})
}
useEffect(() => {
console.log(mergeTarget)
console.log("mergeTarget",mergeTarget)
}, [mergeTarget])
const doMerge = () => {

View file

@ -33,7 +33,7 @@ export default function Home() {
<TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.5)} />
<LastPlays limit={Math.floor(homeItems * 2.7)} />
</div>
</div>
</main>

View file

@ -7,8 +7,8 @@ import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/EditModal";
import EditModal from "~/components/modals/EditModal";
import RenameModal from "~/components/modals/EditModal/EditModal";
import EditModal from "~/components/modals/EditModal/EditModal";
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
@ -69,9 +69,9 @@ export default function MediaLayout(props: Props) {
content={title}
/>
<div className="w-19/20 mx-auto pt-12">
<div className="flex gap-8 flex-wrap relative">
<div className="flex gap-8 flex-wrap md:flex-nowrap relative">
<div className="flex flex-col justify-around">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
</div>
<div className="flex flex-col items-start">
<h3>{props.type}</h3>