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

View file

@ -24,12 +24,12 @@ export default function Home() {
return (
<main className="flex flex-grow justify-center pb-4">
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
<div className="flex gap-20">
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
<AllTimeStats />
<ActivityGrid />
</div>
<PeriodSelector setter={setPeriod} current={period} />
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-between mx-5 gap-5">
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
<TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} />

View file

@ -1,43 +0,0 @@
import { isRouteErrorResponse, Outlet } from "react-router";
import Footer from "~/components/Footer";
import type { Route } from "../+types/root";
export default function Root() {
return (
<div className="flex flex-col items-center mx-auto w-full">
<Outlet />
<Footer />
</div>
)
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto scroll-smooth">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}