mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-16 02:45:54 -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
client/app/routes/Home.tsx
Normal file
41
client/app/routes/Home.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { Route } from "./+types/Home";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
import TopAlbums from "~/components/TopAlbums";
|
||||
import TopArtists from "~/components/TopArtists";
|
||||
import AllTimeStats from "~/components/AllTimeStats";
|
||||
import { useState } from "react";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Koito" },
|
||||
{ name: "description", content: "Koito" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
const { homeItems } = useAppContext();
|
||||
|
||||
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">
|
||||
<AllTimeStats />
|
||||
<ActivityGrid />
|
||||
</div>
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
|
||||
<TopArtists period={period} limit={homeItems} />
|
||||
<TopAlbums period={period} limit={homeItems} />
|
||||
<TopTracks period={period} limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
57
client/app/routes/MediaItems/Album.tsx
Normal file
57
client/app/routes/MediaItems/Album.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useState } from "react";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import { mergeAlbums, type Album } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album", { status: 500 });
|
||||
}
|
||||
const album: Album = await res.json();
|
||||
return album;
|
||||
}
|
||||
|
||||
export default function Album() {
|
||||
const album = useLoaderData() as Album;
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
console.log(album)
|
||||
|
||||
return (
|
||||
<MediaLayout type="Album"
|
||||
title={album.title}
|
||||
img={album.image}
|
||||
id={album.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={album.id}
|
||||
mergeFunc={mergeAlbums}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.artists = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.albums.length; i ++) {
|
||||
if (r.albums[i].id === id) {
|
||||
delete r.albums[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex gap-20 mt-10">
|
||||
<LastPlays limit={30} albumId={album.id} />
|
||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||
<ActivityGrid autoAdjust configurable albumId={album.id} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
66
client/app/routes/MediaItems/Artist.tsx
Normal file
66
client/app/routes/MediaItems/Artist.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState } from "react";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import { mergeArtists, type Artist } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load artist", { status: 500 });
|
||||
}
|
||||
const artist: Artist = await res.json();
|
||||
return artist;
|
||||
}
|
||||
|
||||
export default function Artist() {
|
||||
const artist = useLoaderData() as Artist;
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
// remove canonical name from alias list
|
||||
console.log(artist.aliases)
|
||||
let index = artist.aliases.indexOf(artist.name);
|
||||
if (index !== -1) {
|
||||
artist.aliases.splice(index, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaLayout type="Artist"
|
||||
title={artist.name}
|
||||
img={artist.image}
|
||||
id={artist.id}
|
||||
musicbrainzId={artist.musicbrainz_id}
|
||||
imgItemId={artist.id}
|
||||
mergeFunc={mergeArtists}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.artists.length; i ++) {
|
||||
if (r.artists[i].id === id) {
|
||||
delete r.artists[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-20">
|
||||
<div className="flex gap-15 mt-10 flex-wrap">
|
||||
<LastPlays limit={20} artistId={artist.id} />
|
||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||
<ActivityGrid configurable autoAdjust artistId={artist.id} />
|
||||
</div>
|
||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
88
client/app/routes/MediaItems/MediaLayout.tsx
Normal file
88
client/app/routes/MediaItems/MediaLayout.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { average } from "color.js";
|
||||
import { imageUrl, type SearchResponse } from "api/api";
|
||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import MergeModal from "~/components/modals/MergeModal";
|
||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||
import DeleteModal from "~/components/modals/DeleteModal";
|
||||
import RenameModal from "~/components/modals/RenameModal";
|
||||
|
||||
export type MergeFunc = (from: number, to: number) => Promise<Response>
|
||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||
|
||||
interface Props {
|
||||
type: "Track" | "Album" | "Artist"
|
||||
title: string
|
||||
img: string
|
||||
id: number
|
||||
musicbrainzId: string
|
||||
imgItemId: number
|
||||
mergeFunc: MergeFunc
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
||||
children: React.ReactNode
|
||||
subContent: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MediaLayout(props: Props) {
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||
});
|
||||
}, [props.img]);
|
||||
|
||||
const replaceImageCallback = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const title = `${props.title} - Koito`
|
||||
|
||||
return (
|
||||
<main
|
||||
className="w-full flex flex-col flex-grow"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
|
||||
transition: '1000',
|
||||
}}
|
||||
>
|
||||
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta
|
||||
name="description"
|
||||
content={title}
|
||||
/>
|
||||
<div className="w-19/20 mx-auto pt-12">
|
||||
<div className="flex gap-8 relative">
|
||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" />
|
||||
<div className="flex flex-col items-start">
|
||||
<h3>{props.type}</h3>
|
||||
<h1>{props.title}</h1>
|
||||
{props.subContent}
|
||||
</div>
|
||||
{ user &&
|
||||
<div className="absolute right-1 flex gap-3 items-center">
|
||||
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={30} /></button>
|
||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={30} /></button>
|
||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={30} /></button>
|
||||
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={30} /></button>
|
||||
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
|
||||
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
|
||||
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
|
||||
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
59
client/app/routes/MediaItems/Track.tsx
Normal file
59
client/app/routes/MediaItems/Track.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { mergeTracks, type Album, type Track } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load track", { status: res.status });
|
||||
}
|
||||
const track: Track = await res.json();
|
||||
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album for track", { status: res.status })
|
||||
}
|
||||
const album: Album = await res.json()
|
||||
return {track: track, album: album};
|
||||
}
|
||||
|
||||
export default function Track() {
|
||||
const { track, album } = useLoaderData();
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
return (
|
||||
<MediaLayout type="Track"
|
||||
title={track.title}
|
||||
img={track.image}
|
||||
id={track.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={track.album_id}
|
||||
mergeFunc={mergeTracks}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.artists = []
|
||||
for (let i = 0; i < r.tracks.length; i ++) {
|
||||
if (r.tracks[i].id === id) {
|
||||
delete r.tracks[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<div className="flex flex-col gap-4 items-start">
|
||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex gap-20 mt-10">
|
||||
<LastPlays limit={20} trackId={track.id}/>
|
||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
)
|
||||
}
|
||||
43
client/app/routes/Root.tsx
Normal file
43
client/app/routes/Root.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
||||
67
client/app/routes/ThemeHelper.tsx
Normal file
67
client/app/routes/ThemeHelper.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useState } from "react"
|
||||
import { useAppContext } from "~/providers/AppProvider"
|
||||
import { AsyncButton } from "../components/AsyncButton"
|
||||
import AllTimeStats from "~/components/AllTimeStats"
|
||||
import ActivityGrid from "~/components/ActivityGrid"
|
||||
import LastPlays from "~/components/LastPlays"
|
||||
import TopAlbums from "~/components/TopAlbums"
|
||||
import TopArtists from "~/components/TopArtists"
|
||||
import TopTracks from "~/components/TopTracks"
|
||||
|
||||
export default function ThemeHelper() {
|
||||
|
||||
const homeItems = 3
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-10 items-center">
|
||||
<div className="flex gap-5">
|
||||
<AllTimeStats />
|
||||
<ActivityGrid />
|
||||
</div>
|
||||
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
|
||||
<TopArtists period="all_time" limit={homeItems} />
|
||||
<TopAlbums period="all_time" limit={homeItems} />
|
||||
<TopTracks period="all_time" limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You're logged in as <strong>Example User</strong></p>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
|
||||
<label htmlFor="reverse-merge-order">Example checkbox</label>
|
||||
</div>
|
||||
<p className="success">successfully displayed example text</p>
|
||||
<p className="error">this is an example of error text</p>
|
||||
<p className="info">here is an informational example</p>
|
||||
<p className="warning">heed this warning, traveller</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue