mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-14 18:05:55 -07:00
feat: v0.0.3
This commit is contained in:
parent
7ff317756f
commit
3250a4ec3f
21 changed files with 322 additions and 374 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue