feat: add ranks to top items charts (#122)

This commit is contained in:
Gabe Farrell 2026-01-11 00:15:46 -05:00 committed by GitHub
parent d3faa9728e
commit f51771bc34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 491 additions and 366 deletions

View file

@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page)
url.searchParams.set("page", page);
const res = await fetch(
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
}
export default function AlbumChart() {
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
const { top_albums: initialData } = useLoaderData<{
top_albums: PaginatedResponse<Album>;
}>();
return (
<ChartLayout
@ -29,15 +31,20 @@ export default function AlbumChart() {
endpoint="chart/top-albums"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList
ranked
separators
data={data}
className="w-[400px] sm:w-[600px]"
@ -47,7 +54,11 @@ export default function AlbumChart() {
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>

View file

@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page)
url.searchParams.set("page", page);
const res = await fetch(
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
}
export default function Artist() {
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
const { top_artists: initialData } = useLoaderData<{
top_artists: PaginatedResponse<Album>;
}>();
return (
<ChartLayout
@ -29,15 +31,20 @@ export default function Artist() {
endpoint="chart/top-artists"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList
ranked
separators
data={data}
className="w-[400px] sm:w-[600px]"
@ -47,7 +54,11 @@ export default function Artist() {
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>

View file

@ -1,264 +1,272 @@
import {
useFetcher,
useLocation,
useNavigate,
} from "react-router"
import { useEffect, useState } from "react"
import { average } from "color.js"
import { imageUrl, type PaginatedResponse } from "api/api"
import PeriodSelector from "~/components/PeriodSelector"
import { useFetcher, useLocation, useNavigate } from "react-router";
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> {
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
initialData: PaginatedResponse<T>
endpoint: string
render: (opts: {
data: PaginatedResponse<T>
page: number
onNext: () => void
onPrev: () => void
}) => React.ReactNode
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played";
initialData: PaginatedResponse<T>;
endpoint: string;
render: (opts: {
data: PaginatedResponse<T>;
page: number;
onNext: () => void;
onPrev: () => void;
}) => React.ReactNode;
}
export default function ChartLayout<T>({
title,
initialData,
endpoint,
render,
title,
initialData,
endpoint,
render,
}: ChartLayoutProps<T>) {
const pgTitle = `${title} - Koito`
const pgTitle = `${title} - Koito`;
const fetcher = useFetcher()
const location = useLocation()
const navigate = useNavigate()
const fetcher = useFetcher();
const location = useLocation();
const navigate = useNavigate();
const currentParams = new URLSearchParams(location.search)
const currentPage = parseInt(currentParams.get("page") || "1", 10)
const currentParams = new URLSearchParams(location.search);
const currentPage = parseInt(currentParams.get("page") || "1", 10);
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
? fetcher.data[endpoint]
: initialData
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
? fetcher.data[endpoint]
: initialData;
const [bgColor, setBgColor] = useState<string>("(--color-bg)")
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
useEffect(() => {
if ((data?.items?.length ?? 0) === 0) return
useEffect(() => {
if ((data?.items?.length ?? 0) === 0) return;
const img = (data.items[0] as any)?.image
if (!img) return
const img = (data.items[0] as any)?.image;
if (!img) return;
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
})
}, [data])
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
});
}, [data]);
const period = currentParams.get("period") ?? "day"
const year = currentParams.get("year")
const month = currentParams.get("month")
const week = currentParams.get("week")
const period = currentParams.get("period") ?? "day";
const year = currentParams.get("year");
const month = currentParams.get("month");
const week = currentParams.get("week");
const updateParams = (params: Record<string, string | null>) => {
const nextParams = new URLSearchParams(location.search)
for (const key in params) {
const val = params[key]
if (val !== null) {
nextParams.set(key, val)
} else {
nextParams.delete(key)
}
}
const url = `/${endpoint}?${nextParams.toString()}`
navigate(url, { replace: false })
const updateParams = (params: Record<string, string | null>) => {
const nextParams = new URLSearchParams(location.search);
for (const key in params) {
const val = params[key];
if (val !== null) {
nextParams.set(key, val);
} else {
nextParams.delete(key);
}
}
const handleSetPeriod = (p: string) => {
updateParams({
period: p,
page: "1",
year: null,
month: null,
week: null,
})
}
const handleSetYear = (val: string) => {
if (val == "") {
updateParams({
period: period,
page: "1",
year: null,
month: null,
week: null
})
return
}
updateParams({
period: null,
page: "1",
year: val,
})
}
const handleSetMonth = (val: string) => {
updateParams({
period: null,
page: "1",
year: year ?? new Date().getFullYear().toString(),
month: val,
})
}
const handleSetWeek = (val: string) => {
updateParams({
period: null,
page: "1",
year: year ?? new Date().getFullYear().toString(),
month: null,
week: val,
})
}
useEffect(() => {
fetcher.load(`/${endpoint}?${currentParams.toString()}`)
}, [location.search])
const url = `/${endpoint}?${nextParams.toString()}`;
navigate(url, { replace: false });
};
const setPage = (nextPage: number) => {
const nextParams = new URLSearchParams(location.search)
nextParams.set("page", String(nextPage))
const url = `/${endpoint}?${nextParams.toString()}`
fetcher.load(url)
navigate(url, { replace: false })
}
const handleNextPage = () => setPage(currentPage + 1)
const handlePrevPage = () => setPage(currentPage - 1)
const yearOptions = Array.from({ length: 10 }, (_, 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 => {
let from: Date
let to: Date
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-indexed
const currentDate = now.getDate()
if (year && month) {
from = new Date(parseInt(year), parseInt(month) - 1, 1)
to = new Date(from)
to.setMonth(from.getMonth() + 1)
to.setDate(0)
} else if (year && week) {
const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
const weekNumber = parseInt(week)
from = new Date(base)
from.setDate(base.getDate() + (weekNumber - 1) * 7)
to = new Date(from)
to.setDate(from.getDate() + 6)
} else if (year) {
from = new Date(parseInt(year), 0, 1)
to = new Date(parseInt(year), 11, 31)
} else {
switch (period) {
case "day":
from = new Date(now)
to = new Date(now)
break
case "week":
to = new Date(now)
from = new Date(now)
from.setDate(to.getDate() - 6)
break
case "month":
to = new Date(now)
from = new Date(now)
if (currentMonth === 0) {
from = new Date(currentYear - 1, 11, currentDate)
} else {
from = new Date(currentYear, currentMonth - 1, currentDate)
}
break
case "year":
to = new Date(now)
from = new Date(currentYear - 1, currentMonth, currentDate)
break
case "all_time":
return "All Time"
default:
return ""
}
}
const formatter = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})
return `${formatter.format(from)} - ${formatter.format(to)}`
const handleSetPeriod = (p: string) => {
updateParams({
period: p,
page: "1",
year: null,
month: null,
week: null,
});
};
const handleSetYear = (val: string) => {
if (val == "") {
updateParams({
period: period,
page: "1",
year: null,
month: null,
week: null,
});
return;
}
updateParams({
period: null,
page: "1",
year: val,
});
};
const handleSetMonth = (val: string) => {
updateParams({
period: null,
page: "1",
year: year ?? new Date().getFullYear().toString(),
month: val,
});
};
const handleSetWeek = (val: string) => {
updateParams({
period: null,
page: "1",
year: year ?? new Date().getFullYear().toString(),
month: null,
week: val,
});
};
return (
<div
className="w-full min-h-screen"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
transition: "1000",
}}
>
<title>{pgTitle}</title>
<meta property="og:title" content={pgTitle} />
<meta name="description" content={pgTitle} />
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
<h1>{title}</h1>
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
<div className="flex gap-5">
<select
value={year ?? ""}
onChange={(e) => handleSetYear(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Year</option>
{yearOptions.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<select
value={month ?? ""}
onChange={(e) => handleSetMonth(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Month</option>
{monthOptions.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<select
value={week ?? ""}
onChange={(e) => handleSetWeek(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Week</option>
{weekOptions.map((w) => (
<option key={w} value={w}>{w}</option>
))}
</select>
</div>
</div>
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
{render({
data,
page: currentPage,
onNext: handleNextPage,
onPrev: handlePrevPage,
})}
</div>
</div>
</div>
)
useEffect(() => {
fetcher.load(`/${endpoint}?${currentParams.toString()}`);
}, [location.search]);
const setPage = (nextPage: number) => {
const nextParams = new URLSearchParams(location.search);
nextParams.set("page", String(nextPage));
const url = `/${endpoint}?${nextParams.toString()}`;
fetcher.load(url);
navigate(url, { replace: false });
};
const handleNextPage = () => setPage(currentPage + 1);
const handlePrevPage = () => setPage(currentPage - 1);
const yearOptions = Array.from(
{ length: 10 },
(_, 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 => {
let from: Date;
let to: Date;
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth(); // 0-indexed
const currentDate = now.getDate();
if (year && month) {
from = new Date(parseInt(year), parseInt(month) - 1, 1);
to = new Date(from);
to.setMonth(from.getMonth() + 1);
to.setDate(0);
} else if (year && week) {
const base = new Date(parseInt(year), 0, 1); // Jan 1 of the year
const weekNumber = parseInt(week);
from = new Date(base);
from.setDate(base.getDate() + (weekNumber - 1) * 7);
to = new Date(from);
to.setDate(from.getDate() + 6);
} else if (year) {
from = new Date(parseInt(year), 0, 1);
to = new Date(parseInt(year), 11, 31);
} else {
switch (period) {
case "day":
from = new Date(now);
to = new Date(now);
break;
case "week":
to = new Date(now);
from = new Date(now);
from.setDate(to.getDate() - 6);
break;
case "month":
to = new Date(now);
from = new Date(now);
if (currentMonth === 0) {
from = new Date(currentYear - 1, 11, currentDate);
} else {
from = new Date(currentYear, currentMonth - 1, currentDate);
}
break;
case "year":
to = new Date(now);
from = new Date(currentYear - 1, currentMonth, currentDate);
break;
case "all_time":
return "All Time";
default:
return "";
}
}
const formatter = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
return `${formatter.format(from)} - ${formatter.format(to)}`;
};
return (
<div
className="w-full min-h-screen"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
transition: "1000",
}}
>
<title>{pgTitle}</title>
<meta property="og:title" content={pgTitle} />
<meta name="description" content={pgTitle} />
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
<h1>{title}</h1>
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
<PeriodSelector
current={period}
setter={handleSetPeriod}
disableCache
/>
<div className="flex gap-5">
<select
value={year ?? ""}
onChange={(e) => handleSetYear(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Year</option>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<select
value={month ?? ""}
onChange={(e) => handleSetMonth(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Month</option>
{monthOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<select
value={week ?? ""}
onChange={(e) => handleSetWeek(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Week</option>
{weekOptions.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</div>
</div>
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
{render({
data,
page: currentPage,
onNext: handleNextPage,
onPrev: handlePrevPage,
})}
</div>
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page)
url.searchParams.set("page", page);
const res = await fetch(
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
}
export default function TrackChart() {
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
const { top_tracks: initialData } = useLoaderData<{
top_tracks: PaginatedResponse<Album>;
}>();
return (
<ChartLayout
@ -29,15 +31,20 @@ export default function TrackChart() {
endpoint="chart/top-tracks"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList
ranked
separators
data={data}
className="w-[400px] sm:w-[600px]"
@ -47,7 +54,11 @@ export default function TrackChart() {
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>