Compare commits

..

No commits in common. '1bceeeb2f615ebc52066121a5f5234096bd0c06b' and 'fed2c5b95684964481539216fa7aa2ed8f830dba' have entirely different histories.

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

@ -73,14 +73,8 @@ 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,56 +1,45 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"
import { getStats, type Stats, type ApiError } from "api/api"; import { getStats } 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]),
});
if (isPending) { const { isPending, isError, data, error } = useQuery({
return ( queryKey: ['stats', 'all_time'],
<div className="w-[200px]"> queryFn: ({ queryKey }) => getStats(queryKey[1]),
<h2>All Time Stats</h2> })
<p>Loading...</p>
</div> if (isPending) {
); return (
} else if (isError) { <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> <div>
<h2>All Time Stats</h2> <h2>All Time Stats</h2>
<p className="error">Error: {error.message}</p> <div>
<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,14 +67,12 @@ 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,14 +37,11 @@ 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,14 +28,11 @@ 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,13 +35,9 @@ const TopTracks = (props: Props) => {
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { }
return ( if (isError) {
<div className="w-[300px]"> return <p className="error">Error:{error.message}</p>;
<h2>Top Tracks</h2>
<p className="error">Error: {error.message}</p>
</div>
);
} }
if (!data.items) return; if (!data.items) return;

@ -1,11 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
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";
@ -14,149 +8,133 @@ 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(() => { useEffect(() => {
if (data) { if (data) {
setDisplayData(data); setDisplayData(data)
} }
}, [data]); }, [data])
if (isError) { if (isError) {
return <p className="error">Error: {error.message}</p>; return (
} <p className="error">Error: {error.message}</p>
if (isPending) { )
return <p>Loading...</p>; }
} if (isPending) {
return (
<p>Loading...</p>
)
}
const handleSetPrimary = (alias: string) => { const handleSetPrimary = (alias: string) => {
setError(undefined); setError(undefined)
setLoading(true); setLoading(true)
setPrimaryAlias(type, id, alias).then((r) => { setPrimaryAlias(type, id, alias)
if (r.ok) { .then(r => {
window.location.reload(); if (r.ok) {
} else { window.location.reload()
r.json().then((r) => setError(r.error)); } else {
} r.json().then((r) => setError(r.error))
}); }
setLoading(false); })
}; setLoading(false)
}
const handleNewAlias = () => { const handleNewAlias = () => {
setError(undefined); setError(undefined)
if (input === "") { if (input === "") {
setError("alias must be provided"); setError("alias must be provided")
return; 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)
} }
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) => { const handleDeleteAlias = (alias: string) => {
setError(undefined); setError(undefined)
setLoading(true); setLoading(true)
deleteAlias(type, id, alias).then((r) => { deleteAlias(type, id, alias)
if (r.ok) { .then(r => {
setDisplayData(displayData.filter((v) => v.alias != alias)); if (r.ok) {
} else { setDisplayData(displayData.filter((v) => v.alias != alias))
r.json().then((r) => setError(r.error)); } else {
} r.json().then((r) => setError(r.error))
}); }
setLoading(false); })
}; setLoading(false)
}
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false)
setInput(""); 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}> <div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div>
{v.alias} (source: {v.source}) <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> </div>
<AsyncButton { type.toLowerCase() === "album" &&
loading={loading} <>
onClick={() => handleSetPrimary(v.alias)} <SetVariousArtists id={id} />
disabled={v.is_primary} <SetPrimaryArtist id={id} type="album" />
> </>
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>
{err && <p className="error">{err}</p>} </Modal>
</div> )
</div>
{type.toLowerCase() === "album" && (
<>
<SetVariousArtists id={id} />
<SetPrimaryArtist id={id} type="album" />
</>
)}
{type.toLowerCase() === "track" && (
<SetPrimaryArtist id={id} type="track" />
)}
</div>
</Modal>
);
} }

@ -5,111 +5,86 @@ 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({ export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
musicbrainzId, const [query, setQuery] = useState('');
type, const [loading, setLoading] = useState(false)
id, const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
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)
setError(""); const formData = new FormData
const formData = new FormData(); formData.set(`${type.toLowerCase()}_id`, id.toString())
formData.set(`${type.toLowerCase()}_id`, id.toString()); formData.set("image_url", url)
formData.set("image_url", url); replaceImage(formData)
replaceImage(formData) .then((r) => {
.then((r) => { if (r.ok) {
if (r.status >= 200 && r.status < 300) { window.location.reload()
window.location.reload(); } else {
} else { console.log(r)
r.json().then((r) => setError(r.error)); setLoading(false)
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={`Enter image URL, or drag-and-drop a local file`} placeholder={`Image URL`}
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`}
/> />
</div> { query != "" ?
</button> <div className="flex gap-2 mt-4">
</> <AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
) : ( </div> :
"" ''}
)} { type === "Album" && musicbrainzId ?
<p className="error">{error}</p> <>
</div> <h3 className="mt-5">Suggested Image (Click to Apply)</h3>
</Modal> <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`} />
</div>
</button>
</>
: ''
}
</div>
</Modal>
)
} }

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

@ -20,66 +20,50 @@ 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 <MediaLayout type="Artist"
type="Artist" title={artist.name}
title={artist.name} img={artist.image}
img={artist.image} id={artist.id}
id={artist.id} musicbrainzId={artist.musicbrainz_id}
musicbrainzId={artist.musicbrainz_id} imgItemId={artist.id}
imgItemId={artist.id} mergeFunc={mergeArtists}
mergeFunc={mergeArtists} mergeCleanerFunc={(r, id) => {
mergeCleanerFunc={(r, id) => { r.albums = []
r.albums = []; r.tracks = []
r.tracks = []; for (let i = 0; i < r.artists.length; i ++) {
for (let i = 0; i < r.artists.length; i++) { if (r.artists[i].id === id) {
if (r.artists[i].id === id) { delete r.artists[i]
delete r.artists[i]; }
} }
} return r
return r; }}
}} subContent={<div className="flex flex-col gap-2 items-start">
subContent={ {artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
<div className="flex flex-col gap-2 items-start"> {<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
{artist.listen_count && ( {<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>}
<p> </div>}
{artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
</p>
)}
{
<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 className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
</div> </div>
<div className="flex flex-col gap-20"> <div className="flex flex-col gap-20">
<div className="flex gap-15 mt-10 flex-wrap"> <div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} /> <LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} /> <TopTracks limit={8} period={period} artistId={artist.id} />
<ActivityGrid configurable artistId={artist.id} /> <ActivityGrid configurable artistId={artist.id} />
</div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div> </div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div>
</MediaLayout> </MediaLayout>
); );
} }

@ -8,73 +8,55 @@ 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", { throw new Response("Failed to load album for track", { status: res.status })
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 <MediaLayout type="Track"
type="Track" title={track.title}
title={track.title} img={track.image}
img={track.image} id={track.id}
id={track.id} musicbrainzId={album.musicbrainz_id}
musicbrainzId={album.musicbrainz_id} imgItemId={track.album_id}
imgItemId={track.album_id} mergeFunc={mergeTracks}
mergeFunc={mergeTracks} mergeCleanerFunc={(r, id) => {
mergeCleanerFunc={(r, id) => { r.albums = []
r.albums = []; r.artists = []
r.artists = []; for (let i = 0; i < r.tracks.length; i ++) {
for (let i = 0; i < r.tracks.length; i++) { if (r.tracks[i].id === id) {
if (r.tracks[i].id === id) { delete r.tracks[i]
delete r.tracks[i]; }
} }
} return r
return r; }}
}} subContent={<div className="flex flex-col gap-2 items-start">
subContent={ <Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
<div className="flex flex-col gap-2 items-start"> {track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link> {<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
{track.listen_count && ( {<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>}
<p> </div>}
{track.listen_count} play{track.listen_count > 1 ? "s" : ""} >
</p> <div className="mt-10">
)} <PeriodSelector setter={setPeriod} current={period} />
{ </div>
<p title={Math.floor(track.time_listened / 60 / 60) + " hours"}> <div className="flex flex-wrap gap-20 mt-10">
{timeListenedString(track.time_listened)} <LastPlays limit={20} trackId={track.id}/>
</p> <ActivityGrid trackId={track.id} configurable />
} </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,108 +1,102 @@
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, let r = 0, g = 0, b = 0;
g = 0, hex = hex.replace('#', '');
b = 0;
hex = hex.replace("#", ""); if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
if (hex.length === 3) { g = parseInt(hex[1] + hex[1], 16);
r = parseInt(hex[0] + hex[0], 16); b = parseInt(hex[2] + hex[2], 16);
g = parseInt(hex[1] + hex[1], 16); } else if (hex.length === 6) {
b = parseInt(hex[2] + hex[2], 16); r = parseInt(hex.substring(0, 2), 16);
} else if (hex.length === 6) { g = parseInt(hex.substring(2, 4), 16);
r = parseInt(hex.substring(0, 2), 16); b = parseInt(hex.substring(4, 6), 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), const max = Math.max(r, g, b), min = Math.min(r, g, b);
min = Math.min(r, g, b); let h = 0, s = 0, l = (max + min) / 2;
let h = 0,
s = 0, if (max !== min) {
l = (max + min) / 2; const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max !== min) { switch (max) {
const d = max - min; case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); case g: h = ((b - r) / d + 2); break;
switch (max) { case b: h = ((r - g) / d + 4); break;
case r: }
h = (g - b) / d + (g < b ? 6 : 0); h /= 6;
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 ""
let minutes = Math.floor(seconds / 60); if (seconds > (120 * 60) - 1) {
return `${minutes} minutes listened`; let hours = Math.floor(seconds / 60 / 60)
}; 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