feat: v0.0.3

This commit is contained in:
Gabe Farrell 2025-06-15 00:12:21 -04:00
parent 7ff317756f
commit 3250a4ec3f
21 changed files with 322 additions and 374 deletions

View file

@ -40,7 +40,7 @@ export default function AlbumChart() {
<TopItemList
separators
data={data}
width={600}
className="w-[400px] sm:w-[600px]"
type="album"
/>
<div className="flex gap-15 mx-auto">

View file

@ -40,7 +40,7 @@ export default function Artist() {
<TopItemList
separators
data={data}
width={600}
className="w-[400px] sm:w-[600px]"
type="artist"
/>
<div className="flex gap-15 mx-auto">

View file

@ -212,43 +212,45 @@ export default function ChartLayout<T>({
<title>{pgTitle}</title>
<meta property="og:title" content={pgTitle} />
<meta name="description" content={pgTitle} />
<div className="w-17/20 mx-auto pt-12">
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
<h1>{title}</h1>
<div className="flex 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 />
<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 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-20 flex mx-auto justify-between">
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
{render({
data,
page: currentPage,

View file

@ -1,66 +1,104 @@
import ChartLayout from "./ChartLayout";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type Listen, type PaginatedResponse } from "api/api";
import { deleteListen, type Listen, type PaginatedResponse } from "api/api";
import { timeSince } from "~/utils/utils";
import ArtistLinks from "~/components/ArtistLinks";
import { useState } from "react";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page)
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page)
const res = await fetch(
`/apis/web/v1/listens?${url.searchParams.toString()}`
);
if (!res.ok) {
throw new Response("Failed to load top tracks", { status: 500 });
}
const res = await fetch(
`/apis/web/v1/listens?${url.searchParams.toString()}`
);
if (!res.ok) {
throw new Response("Failed to load top tracks", { status: 500 });
}
const listens: PaginatedResponse<Album> = await res.json();
return { listens };
const listens: PaginatedResponse<Listen> = await res.json();
return { listens };
}
export default function Listens() {
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
const [items, setItems] = useState<Listen[] | null>(null)
const handleDelete = async (listen: Listen) => {
if (!initialData) return
try {
const res = await deleteListen(listen)
if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? initialData.items).filter((i) => i.time !== listen.time))
} else {
console.error("Failed to delete listen:", res.status)
}
} catch (err) {
console.error("Error deleting listen:", err)
}
}
const listens = items ?? initialData.items
return (
<ChartLayout
title="Last Played"
initialData={initialData}
endpoint="listens"
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>
<table>
<tbody>
{data.items.map((item) => (
<tr key={`last_listen_${item.time}`}>
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
<td className="text-ellipsis overflow-hidden w-[700px]">
<ArtistLinks artists={item.track.artists} />{' - '}
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
<ChartLayout
title="Last Played"
initialData={initialData}
endpoint="listens"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5 text-sm md:text-[16px]">
<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
Next
</button>
</div>
</div>
)}
/>
);
</div>
<table className="-ml-4">
<tbody>
{listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[1px] pr-2 align-middle">
<button
onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete"
>
×
</button>
</td>
<td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()}
>
{timeSince(new Date(item.time))}
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
<ArtistLinks artists={item.track.artists} /> {' '}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${item.track.id}`}
>
{item.track.title}
</Link>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
</div>
)}
/>
);
}

View file

@ -40,7 +40,7 @@ export default function TrackChart() {
<TopItemList
separators
data={data}
width={600}
className="w-[400px] sm:w-[600px]"
type="track"
/>
<div className="flex gap-15 mx-auto">