Compare commits

...

5 Commits

@ -16,60 +16,65 @@ interface getActivityArgs {
track_id: number; track_id: number;
} }
function getLastListens( async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) {
const err = await r.json();
throw Error(err.error);
}
return (await r.json()) as T;
}
async function getLastListens(
args: getItemsArgs args: getItemsArgs
): Promise<PaginatedResponse<Listen>> { ): Promise<PaginatedResponse<Listen>> {
return fetch( const r = await fetch(
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}` `/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>); );
return handleJson<PaginatedResponse<Listen>>(r);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { async function getTopTracks(
if (args.artist_id) { args: getItemsArgs
return fetch( ): Promise<PaginatedResponse<Track>> {
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}` let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else if (args.album_id) { if (args.artist_id) url += `&artist_id=${args.artist_id}`;
return fetch( else if (args.album_id) url += `&album_id=${args.album_id}`;
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>); const r = await fetch(url);
} else { return handleJson<PaginatedResponse<Track>>(r);
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
}
} }
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { async function getTopAlbums(
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`; args: getItemsArgs
if (args.artist_id) { ): Promise<PaginatedResponse<Album>> {
return fetch(baseUri + `&artist_id=${args.artist_id}`).then( let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
(r) => r.json() as Promise<PaginatedResponse<Album>> if (args.artist_id) url += `&artist_id=${args.artist_id}`;
);
} else { const r = await fetch(url);
return fetch(baseUri).then( return handleJson<PaginatedResponse<Album>>(r);
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
}
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { async function getTopArtists(
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`; args: getItemsArgs
return fetch(baseUri).then( ): Promise<PaginatedResponse<Artist>> {
(r) => r.json() as Promise<PaginatedResponse<Artist>> const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
); const r = await fetch(url);
return handleJson<PaginatedResponse<Artist>>(r);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { async function getActivity(
return fetch( args: getActivityArgs
): Promise<ListenActivityItem[]> {
const r = await fetch(
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}` `/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
).then((r) => r.json() as Promise<ListenActivityItem[]>); );
return handleJson<ListenActivityItem[]>(r);
} }
function getStats(period: string): Promise<Stats> { async function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then( const r = await fetch(`/apis/web/v1/stats?period=${period}`);
(r) => r.json() as Promise<Stats>
); return handleJson<Stats>(r);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
@ -416,4 +421,5 @@ export type {
ApiError, ApiError,
Config, Config,
NowPlaying, NowPlaying,
Stats,
}; };

@ -73,8 +73,14 @@ export default function ActivityGrid({
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[500px]">
<h2>Activity</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) return <p className="error">Error:{error.message}</p>;
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/ // from https://css-tricks.com/snippets/javascript/lighten-darken-color/
function LightenDarkenColor(hex: string, lum: number) { function LightenDarkenColor(hex: string, lum: number) {

@ -1,45 +1,56 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getStats } from "api/api" import { getStats, type Stats, type ApiError } from "api/api";
export default function AllTimeStats() { export default function AllTimeStats() {
const { isPending, isError, data, error } = useQuery({
queryKey: ["stats", "all_time"],
queryFn: ({ queryKey }) => getStats(queryKey[1]),
});
const { isPending, isError, data, error } = useQuery({ if (isPending) {
queryKey: ['stats', 'all_time'],
queryFn: ({ queryKey }) => getStats(queryKey[1]),
})
if (isPending) {
return (
<div className="w-[200px]">
<h2>All Time Stats</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
const numberClasses = 'header-font font-bold text-xl'
return ( return (
<div className="w-[200px]">
<h2>All Time Stats</h2>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<>
<div> <div>
<h2>All Time Stats</h2> <h2>All Time Stats</h2>
<div> <p className="error">Error: {error.message}</p>
<span className={numberClasses} title={data.minutes_listened + " minutes"}>{Math.floor(data.minutes_listened / 60)}</span> Hours Listened
</div>
<div>
<span className={numberClasses}>{data.listen_count}</span> Plays
</div>
<div>
<span className={numberClasses}>{data.artist_count}</span> Artists
</div>
<div>
<span className={numberClasses}>{data.album_count}</span> Albums
</div>
<div>
<span className={numberClasses}>{data.track_count}</span> Tracks
</div>
</div> </div>
) </>
);
}
const numberClasses = "header-font font-bold text-xl";
return (
<div>
<h2>All Time Stats</h2>
<div>
<span
className={numberClasses}
title={Math.floor(data.minutes_listened / 60) + " hours"}
>
{data.minutes_listened}
</span>{" "}
Minutes Listened
</div>
<div>
<span className={numberClasses}>{data.listen_count}</span> Plays
</div>
<div>
<span className={numberClasses}>{data.track_count}</span> Tracks
</div>
<div>
<span className={numberClasses}>{data.album_count}</span> Albums
</div>
<div>
<span className={numberClasses}>{data.artist_count}</span> Artists
</div>
</div>
);
} }

@ -67,12 +67,14 @@ export default function LastPlays(props: Props) {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (!data.items) return;
const listens = items ?? data.items; const listens = items ?? data.items;

@ -37,11 +37,14 @@ export default function TopAlbums(props: Props) {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Albums</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (!data.items) return;
return ( return (
<div> <div>

@ -28,11 +28,14 @@ export default function TopArtists(props: Props) {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) {
return (
<div className="w-[300px]">
<h2>Top Artists</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (!data.items) return;
return ( return (
<div> <div>

@ -35,9 +35,13 @@ const TopTracks = (props: Props) => {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} } else if (isError) {
if (isError) { return (
return <p className="error">Error:{error.message}</p>; <div className="w-[300px]">
<h2>Top Tracks</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (!data.items) return; if (!data.items) return;

@ -1,5 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api"; import {
createAlias,
deleteAlias,
getAliases,
setPrimaryAlias,
type Alias,
} from "api/api";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { AsyncButton } from "../../AsyncButton"; import { AsyncButton } from "../../AsyncButton";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -8,133 +14,149 @@ import SetVariousArtists from "./SetVariousArtist";
import SetPrimaryArtist from "./SetPrimaryArtist"; import SetPrimaryArtist from "./SetPrimaryArtist";
interface Props { interface Props {
type: string type: string;
id: number id: number;
open: boolean open: boolean;
setOpen: Function setOpen: Function;
} }
export default function EditModal({ open, setOpen, type, id }: Props) { export default function EditModal({ open, setOpen, type, id }: Props) {
const [input, setInput] = useState('') const [input, setInput] = useState("");
const [loading, setLoading ] = useState(false) const [loading, setLoading] = useState(false);
const [err, setError ] = useState<string>() const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<Alias[]>([]) const [displayData, setDisplayData] = useState<Alias[]>([]);
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: [
'aliases', "aliases",
{ {
type: type, type: type,
id: id id: id,
}, },
], ],
queryFn: ({ queryKey }) => { queryFn: ({ queryKey }) => {
const params = queryKey[1] as { type: string; id: number }; const params = queryKey[1] as { type: string; id: number };
return getAliases(params.type, params.id); return getAliases(params.type, params.id);
}, },
}); });
useEffect(() => {
if (data) {
setDisplayData(data)
}
}, [data])
if (isError) { useEffect(() => {
return ( if (data) {
<p className="error">Error: {error.message}</p> setDisplayData(data);
)
}
if (isPending) {
return (
<p>Loading...</p>
)
} }
}, [data]);
const handleSetPrimary = (alias: string) => { if (isError) {
setError(undefined) return <p className="error">Error: {error.message}</p>;
setLoading(true) }
setPrimaryAlias(type, id, alias) if (isPending) {
.then(r => { return <p>Loading...</p>;
if (r.ok) { }
window.location.reload()
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
const handleNewAlias = () => { const handleSetPrimary = (alias: string) => {
setError(undefined) setError(undefined);
if (input === "") { setLoading(true);
setError("alias must be provided") setPrimaryAlias(type, id, alias).then((r) => {
return if (r.ok) {
} window.location.reload();
setLoading(true) } else {
createAlias(type, id, input) r.json().then((r) => setError(r.error));
.then(r => { }
if (r.ok) { });
setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}]) setLoading(false);
} else { };
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
const handleDeleteAlias = (alias: string) => { const handleNewAlias = () => {
setError(undefined) setError(undefined);
setLoading(true) if (input === "") {
deleteAlias(type, id, alias) setError("alias must be provided");
.then(r => { return;
if (r.ok) {
setDisplayData(displayData.filter((v) => v.alias != alias))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
} }
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 handleClose = () => { const handleDeleteAlias = (alias: string) => {
setOpen(false) setError(undefined);
setInput('') 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);
};
const handleClose = () => {
setOpen(false);
setInput("");
};
return ( return (
<Modal maxW={1000} isOpen={open} onClose={handleClose}> <Modal maxW={1000} isOpen={open} onClose={handleClose}>
<div className="flex flex-col items-start gap-6 w-full"> <div className="flex flex-col items-start gap-6 w-full">
<div className="w-full"> <div className="w-full">
<h2>Alias Manager</h2> <h2>Alias Manager</h2>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{displayData.map((v) => ( {displayData.map((v) => (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div> <div className="bg p-3 rounded-md flex-grow" key={v.alias}>
<AsyncButton loading={loading} onClick={() => handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary</AsyncButton> {v.alias} (source: {v.source})
<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> </div>
{ type.toLowerCase() === "album" && <AsyncButton
<> loading={loading}
<SetVariousArtists id={id} /> onClick={() => handleSetPrimary(v.alias)}
<SetPrimaryArtist id={id} type="album" /> 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> </div>
</Modal> {err && <p className="error">{err}</p>}
) </div>
</div>
{type.toLowerCase() === "album" && (
<>
<SetVariousArtists id={id} />
<SetPrimaryArtist id={id} type="album" />
</>
)}
{type.toLowerCase() === "track" && (
<SetPrimaryArtist id={id} type="track" />
)}
</div>
</Modal>
);
} }

@ -5,86 +5,111 @@ import SearchResults from "../SearchResults";
import { AsyncButton } from "../AsyncButton"; import { AsyncButton } from "../AsyncButton";
interface Props { interface Props {
type: string type: string;
id: number id: number;
musicbrainzId?: string musicbrainzId?: string;
open: boolean open: boolean;
setOpen: Function setOpen: Function;
} }
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) { export default function ImageReplaceModal({
const [query, setQuery] = useState(''); musicbrainzId,
const [loading, setLoading] = useState(false) type,
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true) id,
open,
setOpen,
}: Props) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true);
const doImageReplace = (url: string) => { const doImageReplace = (url: string) => {
setLoading(true) setLoading(true);
const formData = new FormData setError("");
formData.set(`${type.toLowerCase()}_id`, id.toString()) const formData = new FormData();
formData.set("image_url", url) formData.set(`${type.toLowerCase()}_id`, id.toString());
replaceImage(formData) formData.set("image_url", url);
.then((r) => { replaceImage(formData)
if (r.ok) { .then((r) => {
window.location.reload() if (r.status >= 200 && r.status < 300) {
} else { window.location.reload();
console.log(r) } else {
setLoading(false) r.json().then((r) => setError(r.error));
} setLoading(false);
}) }
.catch((err) => console.log(err)) })
} .catch((err) => setError(err));
};
const closeModal = () => { const closeModal = () => {
setOpen(false) setOpen(false);
setQuery('') setQuery("");
} setError("");
};
return ( return (
<Modal isOpen={open} onClose={closeModal}> <Modal isOpen={open} onClose={closeModal}>
<h2>Replace Image</h2> <h2>Replace Image</h2>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<input <input
type="text" type="text"
autoFocus autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal // i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Image URL`} placeholder={`Enter image URL, or drag-and-drop a local file`}
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/>
{query != "" ? (
<div className="flex gap-2 mt-4">
<AsyncButton
loading={loading}
onClick={() => doImageReplace(query)}
>
Submit
</AsyncButton>
</div>
) : (
""
)}
{type === "Album" && musicbrainzId ? (
<>
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
<button
className="mt-4"
disabled={loading}
onClick={() =>
doImageReplace(
`https://coverartarchive.org/release/${musicbrainzId}/front`
)
}
>
<div className={`relative`}>
{suggestedImgLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
style={{ width: 20, height: 20 }}
/>
</div>
)}
<img
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
onLoad={() => setSuggestedImgLoading(false)}
onError={() => setSuggestedImgLoading(false)}
className={`block w-[130px] h-auto ${
suggestedImgLoading ? "opacity-0" : "opacity-100"
} transition-opacity duration-300`}
/> />
{ query != "" ? </div>
<div className="flex gap-2 mt-4"> </button>
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton> </>
</div> : ) : (
''} ""
{ type === "Album" && musicbrainzId ? )}
<> <p className="error">{error}</p>
<h3 className="mt-5">Suggested Image (Click to Apply)</h3> </div>
<button </Modal>
className="mt-4" );
disabled={loading}
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
>
<div className={`relative`}>
{suggestedImgLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
style={{ width: 20, height: 20 }}
/>
</div>
)}
<img
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
onLoad={() => setSuggestedImgLoading(false)}
onError={() => setSuggestedImgLoading(false)}
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
</div>
</button>
</>
: ''
}
</div>
</Modal>
)
} }

@ -19,42 +19,58 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Album() { export default function Album() {
const album = useLoaderData() as Album; const album = useLoaderData() as Album;
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
console.log(album) console.log(album);
return ( return (
<MediaLayout type="Album" <MediaLayout
title={album.title} type="Album"
img={album.image} title={album.title}
id={album.id} img={album.image}
musicbrainzId={album.musicbrainz_id} id={album.id}
imgItemId={album.id} musicbrainzId={album.musicbrainz_id}
mergeFunc={mergeAlbums} imgItemId={album.id}
mergeCleanerFunc={(r, id) => { mergeFunc={mergeAlbums}
r.artists = [] mergeCleanerFunc={(r, id) => {
r.tracks = [] r.artists = [];
for (let i = 0; i < r.albums.length; i ++) { r.tracks = [];
if (r.albums[i].id === id) { for (let i = 0; i < r.albums.length; i++) {
delete r.albums[i] if (r.albums[i].id === id) {
} delete r.albums[i];
} }
return r }
}} return r;
subContent={<div className="flex flex-col gap-2 items-start"> }}
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>} subContent={
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>} <div className="flex flex-col gap-2 items-start">
{<p title={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>} {album.listen_count && (
</div>} <p>
> {album.listen_count} play{album.listen_count > 1 ? "s" : ""}
<div className="mt-10"> </p>
<PeriodSelector setter={setPeriod} current={period} /> )}
</div> {
<div className="flex flex-wrap gap-20 mt-10"> <p title={Math.floor(album.time_listened / 60 / 60) + " hours"}>
<LastPlays limit={30} albumId={album.id} /> {timeListenedString(album.time_listened)}
<TopTracks limit={12} period={period} albumId={album.id} /> </p>
<ActivityGrid configurable albumId={album.id} /> }
{
<p title={new Date(album.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(album.first_listen * 1000).toLocaleDateString()}
</p>
}
</div> </div>
}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} />
<ActivityGrid configurable albumId={album.id} />
</div>
</MediaLayout> </MediaLayout>
); );
} }

@ -20,50 +20,66 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Artist() { export default function Artist() {
const artist = useLoaderData() as Artist; const artist = useLoaderData() as Artist;
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
// remove canonical name from alias list // remove canonical name from alias list
console.log(artist.aliases) console.log(artist.aliases);
let index = artist.aliases.indexOf(artist.name); let index = artist.aliases.indexOf(artist.name);
if (index !== -1) { if (index !== -1) {
artist.aliases.splice(index, 1); artist.aliases.splice(index, 1);
} }
return ( return (
<MediaLayout type="Artist" <MediaLayout
title={artist.name} type="Artist"
img={artist.image} title={artist.name}
id={artist.id} img={artist.image}
musicbrainzId={artist.musicbrainz_id} id={artist.id}
imgItemId={artist.id} musicbrainzId={artist.musicbrainz_id}
mergeFunc={mergeArtists} imgItemId={artist.id}
mergeCleanerFunc={(r, id) => { mergeFunc={mergeArtists}
r.albums = [] mergeCleanerFunc={(r, id) => {
r.tracks = [] r.albums = [];
for (let i = 0; i < r.artists.length; i ++) { r.tracks = [];
if (r.artists[i].id === id) { for (let i = 0; i < r.artists.length; i++) {
delete r.artists[i] if (r.artists[i].id === id) {
} delete r.artists[i];
} }
return r }
}} return r;
subContent={<div className="flex flex-col gap-2 items-start"> }}
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>} subContent={
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>} <div className="flex flex-col gap-2 items-start">
{<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>} {artist.listen_count && (
</div>} <p>
> {artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
<div className="mt-10"> </p>
<PeriodSelector setter={setPeriod} current={period} /> )}
{
<p title={Math.floor(artist.time_listened / 60 / 60) + " hours"}>
{timeListenedString(artist.time_listened)}
</p>
}
{
<p title={new Date(artist.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(artist.first_listen * 1000).toLocaleDateString()}
</p>
}
</div> </div>
<div className="flex flex-col gap-20"> }
<div className="flex gap-15 mt-10 flex-wrap"> >
<LastPlays limit={20} artistId={artist.id} /> <div className="mt-10">
<TopTracks limit={8} period={period} artistId={artist.id} /> <PeriodSelector setter={setPeriod} current={period} />
<ActivityGrid configurable artistId={artist.id} /> </div>
</div> <div className="flex flex-col gap-20">
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} /> <div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} />
<ActivityGrid configurable artistId={artist.id} />
</div> </div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div>
</MediaLayout> </MediaLayout>
); );
} }

@ -8,55 +8,73 @@ import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils"; import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`); let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load track", { status: res.status }); throw new Response("Failed to load track", { status: res.status });
} }
const track: Track = await res.json(); const track: Track = await res.json();
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`) res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load album for track", { status: res.status }) throw new Response("Failed to load album for track", {
} status: res.status,
const album: Album = await res.json() });
return {track: track, album: album}; }
const album: Album = await res.json();
return { track: track, album: album };
} }
export default function Track() { export default function Track() {
const { track, album } = useLoaderData(); const { track, album } = useLoaderData();
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
return ( return (
<MediaLayout type="Track" <MediaLayout
title={track.title} type="Track"
img={track.image} title={track.title}
id={track.id} img={track.image}
musicbrainzId={album.musicbrainz_id} id={track.id}
imgItemId={track.album_id} musicbrainzId={album.musicbrainz_id}
mergeFunc={mergeTracks} imgItemId={track.album_id}
mergeCleanerFunc={(r, id) => { mergeFunc={mergeTracks}
r.albums = [] mergeCleanerFunc={(r, id) => {
r.artists = [] r.albums = [];
for (let i = 0; i < r.tracks.length; i ++) { r.artists = [];
if (r.tracks[i].id === id) { for (let i = 0; i < r.tracks.length; i++) {
delete r.tracks[i] if (r.tracks[i].id === id) {
} delete r.tracks[i];
} }
return r }
}} return r;
subContent={<div className="flex flex-col gap-2 items-start"> }}
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link> subContent={
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>} <div className="flex flex-col gap-2 items-start">
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>} <Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>} {track.listen_count && (
</div>} <p>
> {track.listen_count} play{track.listen_count > 1 ? "s" : ""}
<div className="mt-10"> </p>
<PeriodSelector setter={setPeriod} current={period} /> )}
</div> {
<div className="flex flex-wrap gap-20 mt-10"> <p title={Math.floor(track.time_listened / 60 / 60) + " hours"}>
<LastPlays limit={20} trackId={track.id}/> {timeListenedString(track.time_listened)}
<ActivityGrid trackId={track.id} configurable /> </p>
</div> }
</MediaLayout> {
) <p title={new Date(track.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(track.first_listen * 1000).toLocaleDateString()}
</p>
}
</div>
}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={20} trackId={track.id} />
<ActivityGrid trackId={track.id} configurable />
</div>
</MediaLayout>
);
} }

@ -1,102 +1,108 @@
import Timeframe from "~/types/timeframe" import Timeframe from "~/types/timeframe";
const timeframeToInterval = (timeframe: Timeframe): string => { const timeframeToInterval = (timeframe: Timeframe): string => {
switch (timeframe) { switch (timeframe) {
case Timeframe.Day: case Timeframe.Day:
return "1 day" return "1 day";
case Timeframe.Week: case Timeframe.Week:
return "1 week" return "1 week";
case Timeframe.Month: case Timeframe.Month:
return "1 month" return "1 month";
case Timeframe.Year: case Timeframe.Year:
return "1 year" return "1 year";
case Timeframe.AllTime: case Timeframe.AllTime:
return "99 years" return "99 years";
} }
} };
function timeSince(date: Date) { function timeSince(date: Date) {
const now = new Date(); const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const intervals = [ const intervals = [
{ label: 'year', seconds: 31536000 }, { label: "year", seconds: 31536000 },
{ label: 'month', seconds: 2592000 }, { label: "month", seconds: 2592000 },
{ label: 'week', seconds: 604800 }, { label: "week", seconds: 604800 },
{ label: 'day', seconds: 86400 }, { label: "day", seconds: 86400 },
{ label: 'hour', seconds: 3600 }, { label: "hour", seconds: 3600 },
{ label: 'minute', seconds: 60 }, { label: "minute", seconds: 60 },
{ label: 'second', seconds: 1 }, { label: "second", seconds: 1 },
]; ];
for (const interval of intervals) { for (const interval of intervals) {
const count = Math.floor(seconds / interval.seconds); const count = Math.floor(seconds / interval.seconds);
if (count >= 1) { if (count >= 1) {
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`; return `${count} ${interval.label}${count !== 1 ? "s" : ""} ago`;
}
} }
}
return 'just now'; return "just now";
} }
export { timeSince } export { timeSince };
type hsl = { type hsl = {
h: number, h: number;
s: number, s: number;
l: number, l: number;
} };
const hexToHSL = (hex: string): hsl => { const hexToHSL = (hex: string): hsl => {
let r = 0, g = 0, b = 0; let r = 0,
hex = hex.replace('#', ''); g = 0,
b = 0;
if (hex.length === 3) { hex = hex.replace("#", "");
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16); if (hex.length === 3) {
b = parseInt(hex[2] + hex[2], 16); r = parseInt(hex[0] + hex[0], 16);
} else if (hex.length === 6) { g = parseInt(hex[1] + hex[1], 16);
r = parseInt(hex.substring(0, 2), 16); b = parseInt(hex[2] + hex[2], 16);
g = parseInt(hex.substring(2, 4), 16); } else if (hex.length === 6) {
b = parseInt(hex.substring(4, 6), 16); r = parseInt(hex.substring(0, 2), 16);
} g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
}
r /= 255; r /= 255;
g /= 255; g /= 255;
b /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b); const max = Math.max(r, g, b),
let h = 0, s = 0, l = (max + min) / 2; min = Math.min(r, g, b);
let h = 0,
if (max !== min) { s = 0,
const d = max - min; l = (max + min) / 2;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { if (max !== min) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break; const d = max - min;
case g: h = ((b - r) / d + 2); break; s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
case b: h = ((r - g) / d + 4); break; switch (max) {
} case r:
h /= 6; h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
} }
h /= 6;
}
return { return {
h: Math.round(h * 360), h: Math.round(h * 360),
s: Math.round(s * 100), s: Math.round(s * 100),
l: Math.round(l * 100) l: Math.round(l * 100),
}; };
}; };
const timeListenedString = (seconds: number) => { const timeListenedString = (seconds: number) => {
if (!seconds) return "" if (!seconds) return "";
if (seconds > (120 * 60) - 1) { let minutes = Math.floor(seconds / 60);
let hours = Math.floor(seconds / 60 / 60) return `${minutes} minutes listened`;
return `${hours} hours listened` };
} else {
let minutes = Math.floor(seconds / 60)
return `${minutes} minutes listened`
}
}
export {hexToHSL, timeListenedString} export { hexToHSL, timeListenedString };
export type {hsl} export type { hsl };

Loading…
Cancel
Save