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

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
) );
} }

View file

@ -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>