mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-18 19:56:33 -07:00
feat: add ranks to top items charts (#122)
This commit is contained in:
parent
d3faa9728e
commit
f51771bc34
5 changed files with 491 additions and 366 deletions
|
|
@ -1,17 +1,45 @@
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import ArtistLinks from "./ArtistLinks";
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
|
import {
|
||||||
|
imageUrl,
|
||||||
|
type Album,
|
||||||
|
type Artist,
|
||||||
|
type Track,
|
||||||
|
type PaginatedResponse,
|
||||||
|
} from "api/api";
|
||||||
|
|
||||||
type Item = Album | Track | Artist;
|
type Item = Album | Track | Artist;
|
||||||
|
|
||||||
interface Props<T extends Item> {
|
interface Props<T extends Item> {
|
||||||
data: PaginatedResponse<T>
|
data: PaginatedResponse<T>;
|
||||||
separators?: ConstrainBoolean
|
separators?: ConstrainBoolean;
|
||||||
|
ranked?: boolean;
|
||||||
type: "album" | "track" | "artist";
|
type: "album" | "track" | "artist";
|
||||||
className?: string,
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopItemList<T extends Item>({ data, separators, type, className }: Props<T>) {
|
export default function TopItemList<T extends Item>({
|
||||||
|
data,
|
||||||
|
separators,
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
ranked,
|
||||||
|
}: Props<T>) {
|
||||||
|
const currentParams = new URLSearchParams(location.search);
|
||||||
|
const page = Math.max(parseInt(currentParams.get("page") || "1"), 1);
|
||||||
|
|
||||||
|
let lastRank = 0;
|
||||||
|
|
||||||
|
const calculateRank = (data: Item[], page: number, index: number): number => {
|
||||||
|
if (
|
||||||
|
index === 0 ||
|
||||||
|
data[index] == undefined ||
|
||||||
|
!(data[index].listen_count === data[index - 1].listen_count)
|
||||||
|
) {
|
||||||
|
lastRank = index + 1 + (page - 1) * 100;
|
||||||
|
}
|
||||||
|
return lastRank;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-1 ${className} min-w-[200px]`}>
|
<div className={`flex flex-col gap-1 ${className} min-w-[200px]`}>
|
||||||
|
|
@ -22,10 +50,18 @@ export default function TopItemList<T extends Item>({ data, separators, type, cl
|
||||||
key={key}
|
key={key}
|
||||||
style={{ fontSize: 12 }}
|
style={{ fontSize: 12 }}
|
||||||
className={`${
|
className={`${
|
||||||
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
|
separators && index !== data.items.length - 1
|
||||||
|
? "border-b border-(--color-fg-tertiary) mb-1 pb-2"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ItemCard item={item} type={type} key={type+item.id} />
|
<ItemCard
|
||||||
|
ranked={ranked}
|
||||||
|
rank={calculateRank(data.items, page, index)}
|
||||||
|
item={item}
|
||||||
|
type={type}
|
||||||
|
key={type + item.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -33,31 +69,55 @@ export default function TopItemList<T extends Item>({ data, separators, type, cl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
|
function ItemCard({
|
||||||
|
item,
|
||||||
const itemClasses = `flex items-center gap-2`
|
type,
|
||||||
|
rank,
|
||||||
|
ranked,
|
||||||
|
}: {
|
||||||
|
item: Item;
|
||||||
|
type: "album" | "track" | "artist";
|
||||||
|
rank: number;
|
||||||
|
ranked?: boolean;
|
||||||
|
}) {
|
||||||
|
const itemClasses = `flex items-center gap-2`;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "album": {
|
case "album": {
|
||||||
const album = item as Album;
|
const album = item as Album;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{fontSize: 12}} className={itemClasses}>
|
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||||
|
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||||
<Link to={`/album/${album.id}`}>
|
<Link to={`/album/${album.id}`}>
|
||||||
<img loading="lazy" src={imageUrl(album.image, "small")} alt={album.title} className="min-w-[48px]" />
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={imageUrl(album.image, "small")}
|
||||||
|
alt={album.title}
|
||||||
|
className="min-w-[48px]"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
|
<Link
|
||||||
<span style={{fontSize: 14}}>{album.title}</span>
|
to={`/album/${album.id}`}
|
||||||
|
className="hover:text-(--color-fg-secondary)"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>{album.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<br />
|
<br />
|
||||||
{album.is_various_artists ?
|
{album.is_various_artists ? (
|
||||||
<span className="color-fg-secondary">Various Artists</span>
|
<span className="color-fg-secondary">Various Artists</span>
|
||||||
:
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
|
<ArtistLinks
|
||||||
</div>
|
artists={
|
||||||
|
album.artists
|
||||||
|
? [album.artists[0]]
|
||||||
|
: [{ id: 0, name: "Unknown Artist" }]
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,17 +127,28 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
|
||||||
const track = item as Track;
|
const track = item as Track;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{fontSize: 12}} className={itemClasses}>
|
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||||
|
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||||
<Link to={`/track/${track.id}`}>
|
<Link to={`/track/${track.id}`}>
|
||||||
<img loading="lazy" src={imageUrl(track.image, "small")} alt={track.title} className="min-w-[48px]" />
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={imageUrl(track.image, "small")}
|
||||||
|
alt={track.title}
|
||||||
|
className="min-w-[48px]"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/track/${track.id}`} className="hover:text-(--color-fg-secondary)">
|
<Link
|
||||||
<span style={{fontSize: 14}}>{track.title}</span>
|
to={`/track/${track.id}`}
|
||||||
|
className="hover:text-(--color-fg-secondary)"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>{track.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<br />
|
<br />
|
||||||
<div>
|
<div>
|
||||||
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
<ArtistLinks
|
||||||
|
artists={track.artists || [{ id: 0, Name: "Unknown Artist" }]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,12 +158,25 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
|
||||||
case "artist": {
|
case "artist": {
|
||||||
const artist = item as Artist;
|
const artist = item as Artist;
|
||||||
return (
|
return (
|
||||||
<div style={{fontSize: 12}}>
|
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||||
<Link className={itemClasses+' mt-1 mb-[6px] hover:text-(--color-fg-secondary)'} to={`/artist/${artist.id}`}>
|
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||||
<img loading="lazy" src={imageUrl(artist.image, "small")} alt={artist.name} className="min-w-[48px]" />
|
<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>
|
<div>
|
||||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
<span style={{ fontSize: 14 }}>{artist.name}</span>
|
||||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
<div className="color-fg-secondary">
|
||||||
|
{artist.listen_count} plays
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api";
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
||||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumChart() {
|
export default function AlbumChart() {
|
||||||
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
|
const { top_albums: initialData } = useLoaderData<{
|
||||||
|
top_albums: PaginatedResponse<Album>;
|
||||||
|
}>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
|
|
@ -33,11 +35,16 @@ export default function AlbumChart() {
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<TopItemList
|
<TopItemList
|
||||||
|
ranked
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
className="w-[400px] sm:w-[600px]"
|
className="w-[400px] sm:w-[600px]"
|
||||||
|
|
@ -47,7 +54,11 @@ export default function AlbumChart() {
|
||||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api";
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
||||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Artist() {
|
export default function Artist() {
|
||||||
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
|
const { top_artists: initialData } = useLoaderData<{
|
||||||
|
top_artists: PaginatedResponse<Album>;
|
||||||
|
}>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
|
|
@ -33,11 +35,16 @@ export default function Artist() {
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<TopItemList
|
<TopItemList
|
||||||
|
ranked
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
className="w-[400px] sm:w-[600px]"
|
className="w-[400px] sm:w-[600px]"
|
||||||
|
|
@ -47,7 +54,11 @@ export default function Artist() {
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
import {
|
import { useFetcher, useLocation, useNavigate } from "react-router";
|
||||||
useFetcher,
|
import { useEffect, useState } from "react";
|
||||||
useLocation,
|
import { average } from "color.js";
|
||||||
useNavigate,
|
import { imageUrl, type PaginatedResponse } from "api/api";
|
||||||
} from "react-router"
|
import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { average } from "color.js"
|
|
||||||
import { imageUrl, type PaginatedResponse } from "api/api"
|
|
||||||
import PeriodSelector from "~/components/PeriodSelector"
|
|
||||||
|
|
||||||
interface ChartLayoutProps<T> {
|
interface ChartLayoutProps<T> {
|
||||||
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
|
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played";
|
||||||
initialData: PaginatedResponse<T>
|
initialData: PaginatedResponse<T>;
|
||||||
endpoint: string
|
endpoint: string;
|
||||||
render: (opts: {
|
render: (opts: {
|
||||||
data: PaginatedResponse<T>
|
data: PaginatedResponse<T>;
|
||||||
page: number
|
page: number;
|
||||||
onNext: () => void
|
onNext: () => void;
|
||||||
onPrev: () => void
|
onPrev: () => void;
|
||||||
}) => React.ReactNode
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChartLayout<T>({
|
export default function ChartLayout<T>({
|
||||||
|
|
@ -26,52 +22,52 @@ export default function ChartLayout<T>({
|
||||||
endpoint,
|
endpoint,
|
||||||
render,
|
render,
|
||||||
}: ChartLayoutProps<T>) {
|
}: ChartLayoutProps<T>) {
|
||||||
const pgTitle = `${title} - Koito`
|
const pgTitle = `${title} - Koito`;
|
||||||
|
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const currentParams = new URLSearchParams(location.search)
|
const currentParams = new URLSearchParams(location.search);
|
||||||
const currentPage = parseInt(currentParams.get("page") || "1", 10)
|
const currentPage = parseInt(currentParams.get("page") || "1", 10);
|
||||||
|
|
||||||
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
||||||
? fetcher.data[endpoint]
|
? fetcher.data[endpoint]
|
||||||
: initialData
|
: initialData;
|
||||||
|
|
||||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)")
|
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((data?.items?.length ?? 0) === 0) return
|
if ((data?.items?.length ?? 0) === 0) return;
|
||||||
|
|
||||||
const img = (data.items[0] as any)?.image
|
const img = (data.items[0] as any)?.image;
|
||||||
if (!img) return
|
if (!img) return;
|
||||||
|
|
||||||
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
||||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
|
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||||
})
|
});
|
||||||
}, [data])
|
}, [data]);
|
||||||
|
|
||||||
const period = currentParams.get("period") ?? "day"
|
const period = currentParams.get("period") ?? "day";
|
||||||
const year = currentParams.get("year")
|
const year = currentParams.get("year");
|
||||||
const month = currentParams.get("month")
|
const month = currentParams.get("month");
|
||||||
const week = currentParams.get("week")
|
const week = currentParams.get("week");
|
||||||
|
|
||||||
const updateParams = (params: Record<string, string | null>) => {
|
const updateParams = (params: Record<string, string | null>) => {
|
||||||
const nextParams = new URLSearchParams(location.search)
|
const nextParams = new URLSearchParams(location.search);
|
||||||
|
|
||||||
for (const key in params) {
|
for (const key in params) {
|
||||||
const val = params[key]
|
const val = params[key];
|
||||||
if (val !== null) {
|
if (val !== null) {
|
||||||
nextParams.set(key, val)
|
nextParams.set(key, val);
|
||||||
} else {
|
} else {
|
||||||
nextParams.delete(key)
|
nextParams.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `/${endpoint}?${nextParams.toString()}`
|
const url = `/${endpoint}?${nextParams.toString()}`;
|
||||||
navigate(url, { replace: false })
|
navigate(url, { replace: false });
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSetPeriod = (p: string) => {
|
const handleSetPeriod = (p: string) => {
|
||||||
updateParams({
|
updateParams({
|
||||||
|
|
@ -80,8 +76,8 @@ export default function ChartLayout<T>({
|
||||||
year: null,
|
year: null,
|
||||||
month: null,
|
month: null,
|
||||||
week: null,
|
week: null,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
const handleSetYear = (val: string) => {
|
const handleSetYear = (val: string) => {
|
||||||
if (val == "") {
|
if (val == "") {
|
||||||
updateParams({
|
updateParams({
|
||||||
|
|
@ -89,24 +85,24 @@ export default function ChartLayout<T>({
|
||||||
page: "1",
|
page: "1",
|
||||||
year: null,
|
year: null,
|
||||||
month: null,
|
month: null,
|
||||||
week: null
|
week: null,
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
updateParams({
|
updateParams({
|
||||||
period: null,
|
period: null,
|
||||||
page: "1",
|
page: "1",
|
||||||
year: val,
|
year: val,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
const handleSetMonth = (val: string) => {
|
const handleSetMonth = (val: string) => {
|
||||||
updateParams({
|
updateParams({
|
||||||
period: null,
|
period: null,
|
||||||
page: "1",
|
page: "1",
|
||||||
year: year ?? new Date().getFullYear().toString(),
|
year: year ?? new Date().getFullYear().toString(),
|
||||||
month: val,
|
month: val,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
const handleSetWeek = (val: string) => {
|
const handleSetWeek = (val: string) => {
|
||||||
updateParams({
|
updateParams({
|
||||||
period: null,
|
period: null,
|
||||||
|
|
@ -114,80 +110,83 @@ export default function ChartLayout<T>({
|
||||||
year: year ?? new Date().getFullYear().toString(),
|
year: year ?? new Date().getFullYear().toString(),
|
||||||
month: null,
|
month: null,
|
||||||
week: val,
|
week: val,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetcher.load(`/${endpoint}?${currentParams.toString()}`)
|
fetcher.load(`/${endpoint}?${currentParams.toString()}`);
|
||||||
}, [location.search])
|
}, [location.search]);
|
||||||
|
|
||||||
const setPage = (nextPage: number) => {
|
const setPage = (nextPage: number) => {
|
||||||
const nextParams = new URLSearchParams(location.search)
|
const nextParams = new URLSearchParams(location.search);
|
||||||
nextParams.set("page", String(nextPage))
|
nextParams.set("page", String(nextPage));
|
||||||
const url = `/${endpoint}?${nextParams.toString()}`
|
const url = `/${endpoint}?${nextParams.toString()}`;
|
||||||
fetcher.load(url)
|
fetcher.load(url);
|
||||||
navigate(url, { replace: false })
|
navigate(url, { replace: false });
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleNextPage = () => setPage(currentPage + 1)
|
const handleNextPage = () => setPage(currentPage + 1);
|
||||||
const handlePrevPage = () => setPage(currentPage - 1)
|
const handlePrevPage = () => setPage(currentPage - 1);
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`)
|
const yearOptions = Array.from(
|
||||||
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`)
|
{ length: 10 },
|
||||||
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`)
|
(_, i) => `${new Date().getFullYear() - i}`
|
||||||
|
);
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
|
||||||
|
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`);
|
||||||
|
|
||||||
const getDateRange = (): string => {
|
const getDateRange = (): string => {
|
||||||
let from: Date
|
let from: Date;
|
||||||
let to: Date
|
let to: Date;
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const currentYear = now.getFullYear()
|
const currentYear = now.getFullYear();
|
||||||
const currentMonth = now.getMonth() // 0-indexed
|
const currentMonth = now.getMonth(); // 0-indexed
|
||||||
const currentDate = now.getDate()
|
const currentDate = now.getDate();
|
||||||
|
|
||||||
if (year && month) {
|
if (year && month) {
|
||||||
from = new Date(parseInt(year), parseInt(month) - 1, 1)
|
from = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||||
to = new Date(from)
|
to = new Date(from);
|
||||||
to.setMonth(from.getMonth() + 1)
|
to.setMonth(from.getMonth() + 1);
|
||||||
to.setDate(0)
|
to.setDate(0);
|
||||||
} else if (year && week) {
|
} else if (year && week) {
|
||||||
const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
|
const base = new Date(parseInt(year), 0, 1); // Jan 1 of the year
|
||||||
const weekNumber = parseInt(week)
|
const weekNumber = parseInt(week);
|
||||||
from = new Date(base)
|
from = new Date(base);
|
||||||
from.setDate(base.getDate() + (weekNumber - 1) * 7)
|
from.setDate(base.getDate() + (weekNumber - 1) * 7);
|
||||||
to = new Date(from)
|
to = new Date(from);
|
||||||
to.setDate(from.getDate() + 6)
|
to.setDate(from.getDate() + 6);
|
||||||
} else if (year) {
|
} else if (year) {
|
||||||
from = new Date(parseInt(year), 0, 1)
|
from = new Date(parseInt(year), 0, 1);
|
||||||
to = new Date(parseInt(year), 11, 31)
|
to = new Date(parseInt(year), 11, 31);
|
||||||
} else {
|
} else {
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case "day":
|
case "day":
|
||||||
from = new Date(now)
|
from = new Date(now);
|
||||||
to = new Date(now)
|
to = new Date(now);
|
||||||
break
|
break;
|
||||||
case "week":
|
case "week":
|
||||||
to = new Date(now)
|
to = new Date(now);
|
||||||
from = new Date(now)
|
from = new Date(now);
|
||||||
from.setDate(to.getDate() - 6)
|
from.setDate(to.getDate() - 6);
|
||||||
break
|
break;
|
||||||
case "month":
|
case "month":
|
||||||
to = new Date(now)
|
to = new Date(now);
|
||||||
from = new Date(now)
|
from = new Date(now);
|
||||||
if (currentMonth === 0) {
|
if (currentMonth === 0) {
|
||||||
from = new Date(currentYear - 1, 11, currentDate)
|
from = new Date(currentYear - 1, 11, currentDate);
|
||||||
} else {
|
} else {
|
||||||
from = new Date(currentYear, currentMonth - 1, currentDate)
|
from = new Date(currentYear, currentMonth - 1, currentDate);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
case "year":
|
case "year":
|
||||||
to = new Date(now)
|
to = new Date(now);
|
||||||
from = new Date(currentYear - 1, currentMonth, currentDate)
|
from = new Date(currentYear - 1, currentMonth, currentDate);
|
||||||
break
|
break;
|
||||||
case "all_time":
|
case "all_time":
|
||||||
return "All Time"
|
return "All Time";
|
||||||
default:
|
default:
|
||||||
return ""
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,11 +194,10 @@ export default function ChartLayout<T>({
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
});
|
||||||
|
|
||||||
return `${formatter.format(from)} - ${formatter.format(to)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return `${formatter.format(from)} - ${formatter.format(to)}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -215,7 +213,11 @@ export default function ChartLayout<T>({
|
||||||
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
||||||
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
|
<PeriodSelector
|
||||||
|
current={period}
|
||||||
|
setter={handleSetPeriod}
|
||||||
|
disableCache
|
||||||
|
/>
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<select
|
<select
|
||||||
value={year ?? ""}
|
value={year ?? ""}
|
||||||
|
|
@ -224,7 +226,9 @@ export default function ChartLayout<T>({
|
||||||
>
|
>
|
||||||
<option value="">Year</option>
|
<option value="">Year</option>
|
||||||
{yearOptions.map((y) => (
|
{yearOptions.map((y) => (
|
||||||
<option key={y} value={y}>{y}</option>
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
|
|
@ -234,7 +238,9 @@ export default function ChartLayout<T>({
|
||||||
>
|
>
|
||||||
<option value="">Month</option>
|
<option value="">Month</option>
|
||||||
{monthOptions.map((m) => (
|
{monthOptions.map((m) => (
|
||||||
<option key={m} value={m}>{m}</option>
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
|
|
@ -244,7 +250,9 @@ export default function ChartLayout<T>({
|
||||||
>
|
>
|
||||||
<option value="">Week</option>
|
<option value="">Week</option>
|
||||||
{weekOptions.map((w) => (
|
{weekOptions.map((w) => (
|
||||||
<option key={w} value={w}>{w}</option>
|
<option key={w} value={w}>
|
||||||
|
{w}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -260,5 +268,5 @@ export default function ChartLayout<T>({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api";
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
||||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TrackChart() {
|
export default function TrackChart() {
|
||||||
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
|
const { top_tracks: initialData } = useLoaderData<{
|
||||||
|
top_tracks: PaginatedResponse<Album>;
|
||||||
|
}>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
|
|
@ -33,11 +35,16 @@ export default function TrackChart() {
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<TopItemList
|
<TopItemList
|
||||||
|
ranked
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
className="w-[400px] sm:w-[600px]"
|
className="w-[400px] sm:w-[600px]"
|
||||||
|
|
@ -47,7 +54,11 @@ export default function TrackChart() {
|
||||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue