mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-17 19:26:36 -07:00
chore: initial public commit
This commit is contained in:
commit
fc9054b78c
250 changed files with 32809 additions and 0 deletions
58
client/app/routes/Charts/AlbumChart.tsx
Normal file
58
client/app/routes/Charts/AlbumChart.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
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)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top albums", { status: 500 });
|
||||
}
|
||||
|
||||
const top_albums: PaginatedResponse<Album> = await res.json();
|
||||
return { top_albums };
|
||||
}
|
||||
|
||||
export default function AlbumChart() {
|
||||
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Top Albums"
|
||||
initialData={initialData}
|
||||
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>
|
||||
<TopItemList
|
||||
separators
|
||||
data={data}
|
||||
width={600}
|
||||
type="album"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
client/app/routes/Charts/ArtistChart.tsx
Normal file
58
client/app/routes/Charts/ArtistChart.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
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)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top artists", { status: 500 });
|
||||
}
|
||||
|
||||
const top_artists: PaginatedResponse<Album> = await res.json();
|
||||
return { top_artists };
|
||||
}
|
||||
|
||||
export default function Artist() {
|
||||
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Top Artists"
|
||||
initialData={initialData}
|
||||
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>
|
||||
<TopItemList
|
||||
separators
|
||||
data={data}
|
||||
width={600}
|
||||
type="artist"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
262
client/app/routes/Charts/ChartLayout.tsx
Normal file
262
client/app/routes/Charts/ChartLayout.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
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
|
||||
}
|
||||
|
||||
export default function ChartLayout<T>({
|
||||
title,
|
||||
initialData,
|
||||
endpoint,
|
||||
render,
|
||||
}: ChartLayoutProps<T>) {
|
||||
const pgTitle = `${title} - Koito`
|
||||
|
||||
const fetcher = useFetcher()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
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 [bgColor, setBgColor] = useState<string>("(--color-bg)")
|
||||
|
||||
useEffect(() => {
|
||||
if ((data?.items?.length ?? 0) === 0) 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])
|
||||
|
||||
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 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 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-17/20 mx-auto pt-12">
|
||||
<h1>{title}</h1>
|
||||
<div className="flex 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>
|
||||
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
||||
<div className="mt-20 flex mx-auto justify-between">
|
||||
{render({
|
||||
data,
|
||||
page: currentPage,
|
||||
onNext: handleNextPage,
|
||||
onPrev: handlePrevPage,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
client/app/routes/Charts/Listens.tsx
Normal file
66
client/app/routes/Charts/Listens.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import ChartLayout from "./ChartLayout";
|
||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type Listen, type PaginatedResponse } from "api/api";
|
||||
import { timeSince } from "~/utils/utils";
|
||||
import ArtistLinks from "~/components/ArtistLinks";
|
||||
|
||||
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 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 };
|
||||
}
|
||||
|
||||
export default function Listens() {
|
||||
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
||||
|
||||
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
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
client/app/routes/Charts/TrackChart.tsx
Normal file
58
client/app/routes/Charts/TrackChart.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
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)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top tracks", { status: 500 });
|
||||
}
|
||||
|
||||
const top_tracks: PaginatedResponse<Album> = await res.json();
|
||||
return { top_tracks };
|
||||
}
|
||||
|
||||
export default function TrackChart() {
|
||||
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Top Tracks"
|
||||
initialData={initialData}
|
||||
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>
|
||||
<TopItemList
|
||||
separators
|
||||
data={data}
|
||||
width={600}
|
||||
type="track"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue