This commit is contained in:
Gabe Farrell 2025-12-31 00:46:32 -05:00
parent 6b73f83484
commit b5e8d88451
29 changed files with 1271 additions and 963 deletions

View file

@ -15,6 +15,14 @@ interface getActivityArgs {
album_id: number; album_id: number;
track_id: number; track_id: number;
} }
interface timeframe {
week?: number;
month?: number;
year?: number;
from?: number;
to?: number;
period?: string;
}
async function handleJson<T>(r: Response): Promise<T> { async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) { if (!r.ok) {
@ -281,6 +289,13 @@ function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json()); return fetch("/apis/web/v1/now-playing").then((r) => r.json());
} }
async function getRewindStats(args: timeframe): Promise<RewindStats> {
const r = await fetch(
`/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}`
);
return handleJson<RewindStats>(r);
}
export { export {
getLastListens, getLastListens,
getTopTracks, getTopTracks,
@ -312,6 +327,7 @@ export {
getExport, getExport,
submitListen, submitListen,
getNowPlaying, getNowPlaying,
getRewindStats,
}; };
type Track = { type Track = {
id: number; id: number;
@ -404,6 +420,22 @@ type NowPlaying = {
currently_playing: boolean; currently_playing: boolean;
track: Track; track: Track;
}; };
type RewindStats = {
title: string;
top_artists: Artist[];
top_albums: Album[];
top_tracks: Track[];
minutes_listened: number;
avg_minutes_listened_per_day: number;
plays: number;
avg_plays_per_day: number;
unique_tracks: number;
unique_albums: number;
unique_artists: number;
new_tracks: number;
new_albums: number;
new_artists: number;
};
export type { export type {
getItemsArgs, getItemsArgs,
@ -422,4 +454,5 @@ export type {
Config, Config,
NowPlaying, NowPlaying,
Stats, Stats,
RewindStats,
}; };

View file

@ -1,59 +1,56 @@
@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap'); @import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap");
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif, --font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards; --animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards; --animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale { @keyframes fade-in-scale {
0% { 0% {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
100% {
@keyframes fade-out-scale { opacity: 1;
0% { transform: scale(1);
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.95);
}
} }
}
--animate-fade-in: fade-in 0.1s ease forwards; @keyframes fade-out-scale {
--animate-fade-out: fade-out 0.1s ease forwards; 0% {
opacity: 1;
@keyframes fade-in { transform: scale(1);
0% {
opacity: 0;
}
100% {
opacity: 1;
}
} }
100% {
@keyframes fade-out { opacity: 0;
0% { transform: scale(0.95);
opacity: 1;
}
100% {
opacity: 0;
}
} }
}
--animate-fade-in: fade-in 0.1s ease forwards;
--animate-fade-out: fade-out 0.1s ease forwards;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
} }
:root { :root {
--header-xl: 36px; --header-xl: 36px;
--header-lg: 28px; --header-lg: 28px;
@ -66,7 +63,7 @@
@media (min-width: 60rem) { @media (min-width: 60rem) {
:root { :root {
--header-xl: 78px; --header-xl: 78px;
--header-lg: 28px; --header-lg: 44px;
--header-md: 22px; --header-md: 22px;
--header-sm: 16px; --header-sm: 16px;
--header-xl-weight: 600; --header-xl-weight: 600;
@ -74,7 +71,6 @@
} }
} }
html, html,
body { body {
background-color: var(--color-bg); background-color: var(--color-bg);
@ -106,16 +102,18 @@ h1 {
h2 { h2 {
font-family: "League Spartan"; font-family: "League Spartan";
font-weight: var(--header-weight); font-weight: var(--header-weight);
font-size: var(--header-md); font-size: var(--header-lg);
margin-bottom: 0.5em;
} }
h3 { h3 {
font-family: "League Spartan"; font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight); font-weight: var(--header-weight);
font-size: var(--header-md);
margin-bottom: 0.5em;
} }
h4 { h4 {
font-size: var(--header-md); font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight);
} }
.header-font { .header-font {
font-family: "League Spartan"; font-family: "League Spartan";

View file

@ -69,14 +69,14 @@ export default function ActivityGrid({
if (isPending) { if (isPending) {
return ( return (
<div className="w-[500px]"> <div className="w-[500px]">
<h2>Activity</h2> <h3>Activity</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { } else if (isError) {
return ( return (
<div className="w-[500px]"> <div className="w-[500px]">
<h2>Activity</h2> <h3>Activity</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
); );
@ -148,7 +148,7 @@ export default function ActivityGrid({
return ( return (
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<h2>Activity</h2> <h3>Activity</h3>
{configurable ? ( {configurable ? (
<ActivityOptsSelector <ActivityOptsSelector
rangeSetter={setRange} rangeSetter={setRange}

View file

@ -2,24 +2,31 @@ import { imageUrl, type Album } from "api/api";
import { Link } from "react-router"; import { Link } from "react-router";
interface Props { interface Props {
album: Album album: Album;
size: number size: number;
} }
export default function AlbumDisplay({ album, size }: Props) { export default function AlbumDisplay({ album, size }: Props) {
return ( return (
<div className="flex gap-3" key={album.id}> <div className="flex gap-3" key={album.id}>
<div> <div>
<Link to={`/album/${album.id}`}> <Link to={`/album/${album.id}`}>
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/> <img
</Link> src={imageUrl(album.image, "large")}
</div> alt={album.title}
<div className="flex flex-col items-start" style={{width: size}}> style={{ width: size }}
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)"> />
<h4>{album.title}</h4> </Link>
</Link> </div>
<p className="color-fg-secondary">{album.listen_count} plays</p> <div className="flex flex-col items-start" style={{ width: size }}>
</div> <Link
</div> to={`/album/${album.id}`}
) className="hover:text-(--color-fg-secondary)"
>
<h4>{album.title}</h4>
</Link>
<p className="color-fg-secondary">{album.listen_count} plays</p>
</div>
</div>
);
} }

View file

@ -10,7 +10,7 @@ export default function AllTimeStats() {
if (isPending) { if (isPending) {
return ( return (
<div className="w-[200px]"> <div className="w-[200px]">
<h2>All Time Stats</h2> <h3>All Time Stats</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
@ -18,7 +18,7 @@ export default function AllTimeStats() {
return ( return (
<> <>
<div> <div>
<h2>All Time Stats</h2> <h3>All Time Stats</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
</> </>
@ -29,7 +29,7 @@ export default function AllTimeStats() {
return ( return (
<div> <div>
<h2>All Time Stats</h2> <h3>All Time Stats</h3>
<div> <div>
<span <span
className={numberClasses} className={numberClasses}

View file

@ -1,51 +1,59 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api" import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
import { Link } from "react-router" import { Link } from "react-router";
interface Props { interface Props {
artistId: number artistId: number;
name: string name: string;
period: string period: string;
} }
export default function ArtistAlbums({artistId, name, period}: Props) { export default function ArtistAlbums({ artistId, name, period }: Props) {
const { isPending, isError, data, error } = useQuery({
const { isPending, isError, data, error } = useQuery({ queryKey: [
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}], "top-albums",
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), { limit: 99, period: "all_time", artist_id: artistId, page: 0 },
}) ],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
if (isPending) { });
return (
<div>
<h2>Albums From This Artist</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return (
<div>
<h2>Albums From This Artist</h2>
<p className="error">Error:{error.message}</p>
</div>
)
}
if (isPending) {
return ( return (
<div> <div>
<h2>Albums featuring {name}</h2> <h3>Albums From This Artist</h3>
<div className="flex flex-wrap gap-8"> <p>Loading...</p>
{data.items.map((item) => ( </div>
<Link to={`/album/${item.id}`}className="flex gap-2 items-start"> );
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} /> }
<div className="w-[180px] flex flex-col items-start gap-1"> if (isError) {
<p>{item.title}</p> return (
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p> <div>
</div> <h3>Albums From This Artist</h3>
</Link> <p className="error">Error:{error.message}</p>
))} </div>
</div> );
</div> }
)
return (
<div>
<h3>Albums featuring {name}</h3>
<div className="flex flex-wrap gap-8">
{data.items.map((item) => (
<Link to={`/album/${item.id}`} className="flex gap-2 items-start">
<img
src={imageUrl(item.image, "medium")}
alt={item.title}
style={{ width: 130 }}
/>
<div className="w-[180px] flex flex-col items-start gap-1">
<p>{item.title}</p>
<p className="text-sm color-fg-secondary">
{item.listen_count} play{item.listen_count > 1 ? "s" : ""}
</p>
</div>
</Link>
))}
</div>
</div>
);
} }

View file

@ -63,14 +63,14 @@ export default function LastPlays(props: Props) {
if (isPending) { if (isPending) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h3>Last Played</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { } else if (isError) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h3>Last Played</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
); );
@ -85,9 +85,9 @@ export default function LastPlays(props: Props) {
return ( return (
<div className="text-sm sm:text-[16px]"> <div className="text-sm sm:text-[16px]">
<h2 className="hover:underline"> <h3 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link> <Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2> </h3>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && npData && npData.currently_playing && ( {props.showNowPlaying && npData && npData.currently_playing && (

View file

@ -33,14 +33,14 @@ export default function TopAlbums(props: Props) {
if (isPending) { if (isPending) {
return ( return (
<div className="w-[300px]"> <div className="w-[300px]">
<h2>Top Albums</h2> <h3>Top Albums</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { } else if (isError) {
return ( return (
<div className="w-[300px]"> <div className="w-[300px]">
<h2>Top Albums</h2> <h3>Top Albums</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
); );
@ -48,7 +48,7 @@ export default function TopAlbums(props: Props) {
return ( return (
<div> <div>
<h2 className="hover:underline"> <h3 className="hover:underline">
<Link <Link
to={`/chart/top-albums?period=${props.period}${ to={`/chart/top-albums?period=${props.period}${
props.artistId ? `&artist_id=${props.artistId}` : "" props.artistId ? `&artist_id=${props.artistId}` : ""
@ -56,7 +56,7 @@ export default function TopAlbums(props: Props) {
> >
Top Albums Top Albums
</Link> </Link>
</h2> </h3>
<div className="max-w-[300px]"> <div className="max-w-[300px]">
<TopItemList type="album" data={data} /> <TopItemList type="album" data={data} />
{data.items.length < 1 ? "Nothing to show" : ""} {data.items.length < 1 ? "Nothing to show" : ""}

View file

@ -24,14 +24,14 @@ export default function TopArtists(props: Props) {
if (isPending) { if (isPending) {
return ( return (
<div className="w-[300px]"> <div className="w-[300px]">
<h2>Top Artists</h2> <h3>Top Artists</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { } else if (isError) {
return ( return (
<div className="w-[300px]"> <div className="w-[300px]">
<h2>Top Artists</h2> <h3>Top Artists</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
); );
@ -39,11 +39,11 @@ export default function TopArtists(props: Props) {
return ( return (
<div> <div>
<h2 className="hover:underline"> <h3 className="hover:underline">
<Link to={`/chart/top-artists?period=${props.period}`}> <Link to={`/chart/top-artists?period=${props.period}`}>
Top Artists Top Artists
</Link> </Link>
</h2> </h3>
<div className="max-w-[300px]"> <div className="max-w-[300px]">
<TopItemList type="artist" data={data} /> <TopItemList type="artist" data={data} />
{data.items.length < 1 ? "Nothing to show" : ""} {data.items.length < 1 ? "Nothing to show" : ""}

View file

@ -1,38 +1,43 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, type getItemsArgs } from "api/api" import { getTopAlbums, type getItemsArgs } from "api/api";
import AlbumDisplay from "./AlbumDisplay" import AlbumDisplay from "./AlbumDisplay";
interface Props { interface Props {
period: string period: string;
artistId?: Number artistId?: Number;
vert?: boolean vert?: boolean;
hideTitle?: boolean hideTitle?: boolean;
} }
export default function TopThreeAlbums(props: Props) { export default function TopThreeAlbums(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"top-albums",
{ limit: 3, period: props.period, artist_id: props.artistId, page: 0 },
],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
});
const { isPending, isError, data, error } = useQuery({ if (isPending) {
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}], return <p>Loading...</p>;
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), }
}) if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (isPending) { console.log(data);
return <p>Loading...</p>
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
console.log(data) return (
<div>
return ( {!props.hideTitle && <h3>Top Three Albums</h3>}
<div> <div
{!props.hideTitle && <h2>Top Three Albums</h2>} className={`flex ${props.vert ? "flex-col" : ""}`}
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}> style={{ gap: 15 }}
{data.items.map((item, index) => ( >
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} /> {data.items.map((item, index) => (
))} <AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
</div> ))}
</div> </div>
) </div>
);
} }

View file

@ -31,14 +31,14 @@ const TopTracks = (props: Props) => {
if (isPending) { if (isPending) {
return ( return (
<div className="w-[300px]"> <div className="w-[300px]">
<h2>Top Tracks</h2> <h3>Top Tracks</h3>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
} else if (isError) { } else if (isError) {
return ( return (
<div className="w-[300px]"> <div className="w-[300px]">
<h2>Top Tracks</h2> <h3>Top Tracks</h3>
<p className="error">Error: {error.message}</p> <p className="error">Error: {error.message}</p>
</div> </div>
); );
@ -51,11 +51,11 @@ const TopTracks = (props: Props) => {
return ( return (
<div> <div>
<h2 className="hover:underline"> <h3 className="hover:underline">
<Link to={`/chart/top-tracks?period=${props.period}${params}`}> <Link to={`/chart/top-tracks?period=${props.period}${params}`}>
Top Tracks Top Tracks
</Link> </Link>
</h2> </h3>
<div className="max-w-[300px]"> <div className="max-w-[300px]">
<TopItemList type="track" data={data} /> <TopItemList type="track" data={data} />
{data.items.length < 1 ? "Nothing to show" : ""} {data.items.length < 1 ? "Nothing to show" : ""}

View file

@ -1,106 +1,124 @@
import { logout, updateUser } from "api/api" import { logout, updateUser } from "api/api";
import { useState } from "react" import { useState } from "react";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
import { useAppContext } from "~/providers/AppProvider" import { useAppContext } from "~/providers/AppProvider";
export default function Account() { export default function Account() {
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [confirmPw, setConfirmPw] = useState('') const [confirmPw, setConfirmPw] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [success, setSuccess] = useState('') const [success, setSuccess] = useState("");
const { user, setUsername: setCtxUsername } = useAppContext() const { user, setUsername: setCtxUsername } = useAppContext();
const logoutHandler = () => { const logoutHandler = () => {
setLoading(true) setLoading(true);
logout() logout()
.then(r => { .then((r) => {
if (r.ok) { if (r.ok) {
window.location.reload() window.location.reload();
} else { } else {
r.json().then(r => setError(r.error)) r.json().then((r) => setError(r.error));
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
setError('')
setSuccess('')
if (password != "" && confirmPw === "") {
setError("confirm your new password before submitting")
return
} }
setError('') })
setSuccess('') .catch((err) => setError(err));
setLoading(true) setLoading(false);
updateUser(username, password) };
.then(r => { const updateHandler = () => {
if (r.ok) { setError("");
setSuccess("sucessfully updated user") setSuccess("");
if (username != "") { if (password != "" && confirmPw === "") {
setCtxUsername(username) setError("confirm your new password before submitting");
} return;
setUsername('')
setPassword('')
setConfirmPw('')
} else {
r.json().then((r) => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} }
setError("");
setSuccess("");
setLoading(true);
updateUser(username, password)
.then((r) => {
if (r.ok) {
setSuccess("sucessfully updated user");
if (username != "") {
setCtxUsername(username);
}
setUsername("");
setPassword("");
setConfirmPw("");
} else {
r.json().then((r) => setError(r.error));
}
})
.catch((err) => setError(err));
setLoading(false);
};
return ( return (
<> <>
<h2>Account</h2> <h3>Account</h3>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center"> <div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p> <p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton> You're logged in as <strong>{user?.username}</strong>
</div> </p>
<h2>Update User</h2> <AsyncButton loading={loading} onClick={logoutHandler}>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4"> Logout
<div className="flex flex gap-4"> </AsyncButton>
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<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"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm new password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div> </div>
</> <h3>Update User</h3>
) <form
action="#"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<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"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>
Submit
</AsyncButton>
</div>
</form>
<form
action="#"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<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"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm new password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>
Submit
</AsyncButton>
</div>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>
</>
);
} }

View file

@ -5,53 +5,56 @@ import { submitListen } from "api/api";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
trackid: number trackid: number;
} }
export default function AddListenModal({ open, setOpen, trackid }: Props) { export default function AddListenModal({ open, setOpen, trackid }: Props) {
const [ts, setTS] = useState<Date>(new Date); const [ts, setTS] = useState<Date>(new Date());
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const navigate = useNavigate() const navigate = useNavigate();
const close = () => { const close = () => {
setOpen(false) setOpen(false);
} };
const submit = () => { const submit = () => {
setLoading(true) setLoading(true);
submitListen(trackid.toString(), ts) submitListen(trackid.toString(), ts).then((r) => {
.then(r => { if (r.ok) {
if(r.ok) { setLoading(false);
setLoading(false) navigate(0);
navigate(0) } else {
} else { r.json().then((r) => setError(r.error));
r.json().then(r => setError(r.error)) setLoading(false);
setLoading(false) }
} });
}) };
}
const formatForDatetimeLocal = (d: Date) => { const formatForDatetimeLocal = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0"); const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
}; d.getDate()
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
return ( return (
<Modal isOpen={open} onClose={close}> <Modal isOpen={open} onClose={close}>
<h2>Add Listen</h2> <h3>Add Listen</h3>
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<input <input
type="datetime-local" type="datetime-local"
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
value={formatForDatetimeLocal(ts)} value={formatForDatetimeLocal(ts)}
onChange={(e) => setTS(new Date(e.target.value))} onChange={(e) => setTS(new Date(e.target.value))}
/> />
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton> <AsyncButton loading={loading} onClick={submit}>
<p className="error">{error}</p> Submit
</div> </AsyncButton>
</Modal> <p className="error">{error}</p>
) </div>
</Modal>
);
} }

View file

@ -5,172 +5,183 @@ import { useEffect, useRef, useState } from "react";
import { Copy, Trash } from "lucide-react"; import { Copy, Trash } from "lucide-react";
type CopiedState = { type CopiedState = {
x: number; x: number;
y: number; y: number;
visible: boolean; visible: boolean;
}; };
export default function ApiKeysModal() { export default function ApiKeysModal() {
const [input, setInput] = useState('') const [input, setInput] = useState("");
const [loading, setLoading ] = useState(false) const [loading, setLoading] = useState(false);
const [err, setError ] = useState<string>() const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<ApiKey[]>([]) const [displayData, setDisplayData] = useState<ApiKey[]>([]);
const [copied, setCopied] = useState<CopiedState | null>(null); const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({}); const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => { const handleRevealAndSelect = (key: string) => {
setExpandedKey(key); setExpandedKey(key);
setTimeout(() => { setTimeout(() => {
const el = textRefs.current[key]; const el = textRefs.current[key];
if (el) { if (el) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
const sel = window.getSelection(); const sel = window.getSelection();
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
} }
}, 0); }, 0);
}; };
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: ["api-keys"],
'api-keys' queryFn: () => {
], return getApiKeys();
queryFn: () => { },
return getApiKeys(); });
},
useEffect(() => {
if (data) {
setDisplayData(data);
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
const parentRect = (
e.currentTarget.closest(".relative") as HTMLElement
).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
y: buttonRect.top - parentRect.top - 8,
visible: true,
}); });
useEffect(() => { setTimeout(() => setCopied(null), 1500);
if (data) { };
setDisplayData(data)
}
}, [data])
if (isError) { const fallbackCopy = (text: string) => {
return ( const textarea = document.createElement("textarea");
<p className="error">Error: {error.message}</p> textarea.value = text;
) textarea.style.position = "fixed"; // prevent scroll to bottom
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} catch (err) {
console.error("Fallback: Copy failed", err);
} }
if (isPending) { document.body.removeChild(textarea);
return ( };
<p>Loading...</p>
) const handleCreateApiKey = () => {
setError(undefined);
if (input === "") {
setError("a label must be provided");
return;
} }
setLoading(true);
createApiKey(input)
.then((r) => {
setDisplayData([r, ...displayData]);
setInput("");
})
.catch((err) => setError(err.message));
setLoading(false);
};
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => { const handleDeleteApiKey = (id: number) => {
if (navigator.clipboard && navigator.clipboard.writeText) { setError(undefined);
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); setLoading(true);
} else { deleteApiKey(id).then((r) => {
fallbackCopy(text); if (r.ok) {
} setDisplayData(displayData.filter((v) => v.id != id));
} else {
r.json().then((r) => setError(r.error));
}
});
setLoading(false);
};
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect(); return (
const buttonRect = e.currentTarget.getBoundingClientRect(); <div className="">
<h3>API Keys</h3>
setCopied({ <div className="flex flex-col gap-4 relative">
x: buttonRect.left - parentRect.left + buttonRect.width / 2, {displayData.map((v) => (
y: buttonRect.top - parentRect.top - 8, <div className="flex gap-2">
visible: true, <div
}); key={v.key}
ref={(el) => {
setTimeout(() => setCopied(null), 1500); textRefs.current[v.key] = el;
}; }}
onClick={() => handleRevealAndSelect(v.key)}
const fallbackCopy = (text: string) => { className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
const textarea = document.createElement("textarea"); expandedKey === v.key ? "" : "truncate"
textarea.value = text; }`}
textarea.style.position = "fixed"; // prevent scroll to bottom style={{ whiteSpace: "nowrap" }}
document.body.appendChild(textarea); title={v.key} // optional tooltip
textarea.focus(); >
textarea.select(); {expandedKey === v.key
try { ? v.key
document.execCommand("copy"); : `${v.key.slice(0, 8)}... ${v.label}`}
} catch (err) {
console.error("Fallback: Copy failed", err);
}
document.body.removeChild(textarea);
};
const handleCreateApiKey = () => {
setError(undefined)
if (input === "") {
setError("a label must be provided")
return
}
setLoading(true)
createApiKey(input)
.then(r => {
setDisplayData([r, ...displayData])
setInput('')
}).catch((err) => setError(err.message))
setLoading(false)
}
const handleDeleteApiKey = (id: number) => {
setError(undefined)
setLoading(true)
deleteApiKey(id)
.then(r => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.id != id))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
return (
<div className="">
<h2>API Keys</h2>
<div className="flex flex-col gap-4 relative">
{displayData.map((v) => (
<div className="flex gap-2"><div
key={v.key}
ref={el => {
textRefs.current[v.key] = el;
}}
onClick={() => handleRevealAndSelect(v.key)}
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
expandedKey === v.key ? '' : 'truncate'
}`}
style={{ whiteSpace: 'nowrap' }}
title={v.key} // optional tooltip
>
{expandedKey === v.key ? v.key : `${v.key.slice(0, 8)}... ${v.label}`}
</div>
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a label for a new API key"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
</div> </div>
{err && <p className="error">{err}</p>} <button
{copied?.visible && ( onClick={(e) => handleCopy(e, v.key)}
<div className="large-button px-5 rounded-md"
style={{ >
position: "absolute", <Copy size={16} />
top: copied.y, </button>
left: copied.x, <AsyncButton
transform: "translate(-50%, -100%)", loading={loading}
}} onClick={() => handleDeleteApiKey(v.id)}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade" confirm
> >
Copied! <Trash size={16} />
</div> </AsyncButton>
)} </div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a label for a new API key"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleCreateApiKey}>
Create
</AsyncButton>
</div> </div>
</div> {err && <p className="error">{err}</p>}
) {copied?.visible && (
<div
style={{
position: "absolute",
top: copied.y,
left: copied.x,
transform: "translate(-50%, -100%)",
}}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
>
Copied!
</div>
)}
</div>
</div>
);
} }

View file

@ -1,40 +1,41 @@
import { deleteItem } from "api/api" import { deleteItem } from "api/api";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
import { Modal } from "./Modal" import { Modal } from "./Modal";
import { useNavigate } from "react-router" import { useNavigate } from "react-router";
import { useState } from "react" import { useState } from "react";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
title: string, title: string;
id: number, id: number;
type: string type: string;
} }
export default function DeleteModal({ open, setOpen, title, id, type }: Props) { export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const navigate = useNavigate() const navigate = useNavigate();
const doDelete = () => { const doDelete = () => {
setLoading(true) setLoading(true);
deleteItem(type.toLowerCase(), id) deleteItem(type.toLowerCase(), id).then((r) => {
.then(r => { if (r.ok) {
if (r.ok) { navigate("/");
navigate('/') } else {
} else { console.log(r);
console.log(r) }
} });
}) };
}
return ( return (
<Modal isOpen={open} onClose={() => setOpen(false)}> <Modal isOpen={open} onClose={() => setOpen(false)}>
<h2>Delete "{title}"?</h2> <h3>Delete "{title}"?</h3>
<p>This action is irreversible!</p> <p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center"> <div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton> <AsyncButton loading={loading} onClick={doDelete}>
</div> Yes, Delete It
</Modal> </AsyncButton>
) </div>
</Modal>
);
} }

View file

@ -108,7 +108,7 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
<Modal maxW={1000} isOpen={open} onClose={handleClose}> <Modal maxW={1000} isOpen={open} onClose={handleClose}>
<div className="flex flex-col items-start gap-6 w-full"> <div className="flex flex-col items-start gap-6 w-full">
<div className="w-full"> <div className="w-full">
<h2>Alias Manager</h2> <h3>Alias Manager</h3>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{displayData.map((v) => ( {displayData.map((v) => (
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -1,99 +1,99 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getAlbum, type Artist } from "api/api"; import { getAlbum, type Artist } from "api/api";
import { useEffect, useState } from "react" import { useEffect, useState } from "react";
interface Props { interface Props {
id: number id: number;
type: string type: string;
} }
export default function SetPrimaryArtist({ id, type }: Props) { export default function SetPrimaryArtist({ id, type }: Props) {
const [err, setErr] = useState('') const [err, setErr] = useState("");
const [primary, setPrimary] = useState<Artist>() const [primary, setPrimary] = useState<Artist>();
const [success, setSuccess] = useState('') const [success, setSuccess] = useState("");
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: [
'get-artists-'+type.toLowerCase(), "get-artists-" + type.toLowerCase(),
{ {
id: id id: id,
}, },
], ],
queryFn: () => { queryFn: () => {
return fetch('/apis/web/v1/artists?'+type.toLowerCase()+'_id='+id).then(r => r.json()) as Promise<Artist[]>; return fetch(
}, "/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
}); ).then((r) => r.json()) as Promise<Artist[]>;
},
});
useEffect(() => { useEffect(() => {
if (data) { if (data) {
for (let a of data) { for (let a of data) {
if (a.is_primary) { if (a.is_primary) {
setPrimary(a) setPrimary(a);
break break;
}
}
} }
}, [data]) }
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
} }
}, [data]);
const updatePrimary = (artist: number, val: boolean) => { if (isError) {
setErr(''); return <p className="error">Error: {error.message}</p>;
setSuccess(''); }
fetch(`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`, { if (isPending) {
method: 'POST', return <p>Loading...</p>;
headers: { }
"Content-Type": "application/x-www-form-urlencoded"
const updatePrimary = (artist: number, val: boolean) => {
setErr("");
setSuccess("");
fetch(
`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
).then((r) => {
if (r.ok) {
setSuccess("successfully updated primary artists");
} else {
r.json().then((r) => setErr(r.error));
}
});
};
return (
<div className="w-full">
<h3>Set Primary Artist</h3>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-60 px-3 py-2 rounded-md"
value={primary?.name || ""}
onChange={(e) => {
for (let a of data) {
if (a.name === e.target.value) {
setPrimary(a);
updatePrimary(a.id, true);
}
} }
}) }}
.then(r => { >
if (r.ok) { <option value="" disabled>
setSuccess('successfully updated primary artists'); Select an artist
} else { </option>
r.json().then(r => setErr(r.error)); {data.map((a) => (
} <option key={a.id} value={a.name}>
}); {a.name}
} </option>
))}
return ( </select>
<div className="w-full"> {err && <p className="error">{err}</p>}
<h2>Set Primary Artist</h2> {success && <p className="success">{success}</p>}
<div className="flex flex-col gap-4"> </div>
<select </div>
name="mark-various-artists" );
id="mark-various-artists"
className="w-60 px-3 py-2 rounded-md"
value={primary?.name || ""}
onChange={(e) => {
for (let a of data) {
if (a.name === e.target.value) {
setPrimary(a);
updatePrimary(a.id, true);
}
}
}}
>
<option value="" disabled>
Select an artist
</option>
{data.map((a) => (
<option key={a.id} value={a.name}>
{a.name}
</option>
))}
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
} }

View file

@ -1,80 +1,77 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getAlbum } from "api/api"; import { getAlbum } from "api/api";
import { useEffect, useState } from "react" import { useEffect, useState } from "react";
interface Props { interface Props {
id: number id: number;
} }
export default function SetVariousArtists({ id }: Props) { export default function SetVariousArtists({ id }: Props) {
const [err, setErr] = useState('') const [err, setErr] = useState("");
const [va, setVA] = useState(false) const [va, setVA] = useState(false);
const [success, setSuccess] = useState('') const [success, setSuccess] = useState("");
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: [
'get-album', "get-album",
{ {
id: id id: id,
}, },
], ],
queryFn: ({ queryKey }) => { queryFn: ({ queryKey }) => {
const params = queryKey[1] as { id: number }; const params = queryKey[1] as { id: number };
return getAlbum(params.id); return getAlbum(params.id);
}, },
});
useEffect(() => {
if (data) {
setVA(data.is_various_artists);
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const updateVA = (val: boolean) => {
setErr("");
setSuccess("");
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, {
method: "PATCH",
}).then((r) => {
if (r.ok) {
setSuccess("Successfully updated album");
} else {
r.json().then((r) => setErr(r.error));
}
}); });
};
useEffect(() => { return (
if (data) { <div className="w-full">
setVA(data.is_various_artists) <h3>Mark as Various Artists</h3>
} <div className="flex flex-col gap-4">
}, [data]) <select
name="mark-various-artists"
if (isError) { id="mark-various-artists"
return ( className="w-30 px-3 py-2 rounded-md"
<p className="error">Error: {error.message}</p> value={va.toString()}
) onChange={(e) => {
} const val = e.target.value === "true";
if (isPending) { setVA(val);
return ( updateVA(val);
<p>Loading...</p> }}
) >
} <option value="true">True</option>
<option value="false">False</option>
const updateVA = (val: boolean) => { </select>
setErr(''); {err && <p className="error">{err}</p>}
setSuccess(''); {success && <p className="success">{success}</p>}
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, { method: 'PATCH' }) </div>
.then(r => { </div>
if (r.ok) { );
setSuccess('Successfully updated album');
} else {
r.json().then(r => setErr(r.error));
}
});
}
return (
<div className="w-full">
<h2>Mark as Various Artists</h2>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-30 px-3 py-2 rounded-md"
value={va.toString()}
onChange={(e) => {
const val = e.target.value === 'true';
setVA(val);
updateVA(val);
}}
>
<option value="true">True</option>
<option value="false">False</option>
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
)
} }

View file

@ -3,43 +3,45 @@ import { AsyncButton } from "../AsyncButton";
import { getExport } from "api/api"; import { getExport } from "api/api";
export default function ExportModal() { export default function ExportModal() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const handleExport = () => { const handleExport = () => {
setLoading(true) setLoading(true);
fetch(`/apis/web/v1/export`, { fetch(`/apis/web/v1/export`, {
method: "GET" method: "GET",
}) })
.then(res => { .then((res) => {
if (res.ok) { if (res.ok) {
res.blob() res.blob().then((blob) => {
.then(blob => { const url = window.URL.createObjectURL(blob);
const url = window.URL.createObjectURL(blob) const a = document.createElement("a");
const a = document.createElement("a") a.href = url;
a.href = url a.download = "koito_export.json";
a.download = "koito_export.json" document.body.appendChild(a);
document.body.appendChild(a) a.click();
a.click() a.remove();
a.remove() window.URL.revokeObjectURL(url);
window.URL.revokeObjectURL(url) setLoading(false);
setLoading(false) });
}) } else {
} else { res.json().then((r) => setError(r.error));
res.json().then(r => setError(r.error)) setLoading(false);
setLoading(false) }
} })
}).catch(err => { .catch((err) => {
setError(err) setError(err);
setLoading(false) setLoading(false);
}) });
} };
return ( return (
<div> <div>
<h2>Export</h2> <h3>Export</h3>
<AsyncButton loading={loading} onClick={handleExport}>Export Data</AsyncButton> <AsyncButton loading={loading} onClick={handleExport}>
{error && <p className="error">{error}</p>} Export Data
</div> </AsyncButton>
) {error && <p className="error">{error}</p>}
</div>
);
} }

View file

@ -50,7 +50,7 @@ export default function ImageReplaceModal({
return ( return (
<Modal isOpen={open} onClose={closeModal}> <Modal isOpen={open} onClose={closeModal}>
<h2>Replace Image</h2> <h3>Replace Image</h3>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<input <input
type="text" type="text"

View file

@ -1,59 +1,74 @@
import { login } from "api/api" import { login } from "api/api";
import { useEffect, useState } from "react" import { useEffect, useState } from "react";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
export default function LoginForm() { export default function LoginForm() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false) const [remember, setRemember] = useState(false);
const loginHandler = () => { const loginHandler = () => {
if (username && password) { if (username && password) {
setLoading(true) setLoading(true);
login(username, password, remember) login(username, password, remember)
.then(r => { .then((r) => {
if (r.status >= 200 && r.status < 300) { if (r.status >= 200 && r.status < 300) {
window.location.reload() window.location.reload();
} else { } else {
r.json().then(r => setError(r.error)) r.json().then((r) => setError(r.error));
} }
}).catch(err => setError(err)) })
setLoading(false) .catch((err) => setError(err));
} else if (username || password) { setLoading(false);
setError("username and password are required") } else if (username || password) {
} setError("username and password are required");
} }
};
return ( return (
<> <>
<h2>Log In</h2> <h3>Log In</h3>
<div className="flex flex-col items-center gap-4 w-full"> <div className="flex flex-col items-center gap-4 w-full">
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p> <p>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}> Logging in gives you access to <strong>admin tools</strong>, such as
<input updating images, merging items, deleting items, and more.
name="koito-username" </p>
type="text" <form
placeholder="Username" action="#"
className="w-full mx-auto fg bg rounded p-2" className="flex flex-col items-center gap-4 w-3/4"
onChange={(e) => setUsername(e.target.value)} onSubmit={(e) => e.preventDefault()}
/> >
<input <input
name="koito-password" name="koito-username"
type="password" type="text"
placeholder="Password" placeholder="Username"
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<div className="flex gap-2"> <input
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} /> name="koito-password"
<label htmlFor="kotio-remember">Remember me</label> type="password"
</div> placeholder="Password"
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton> className="w-full mx-auto fg bg rounded p-2"
</form> onChange={(e) => setPassword(e.target.value)}
<p className="error">{error}</p> />
</div> <div className="flex gap-2">
</> <input
) type="checkbox"
name="koito-remember"
id="koito-remember"
onChange={() => setRemember(!remember)}
/>
<label htmlFor="kotio-remember">Remember me</label>
</div>
<AsyncButton loading={loading} onClick={loginHandler}>
Login
</AsyncButton>
</form>
<p className="error">{error}</p>
</div>
</>
);
} }

View file

@ -2,128 +2,158 @@ import { useEffect, useState } from "react";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api"; import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults"; import SearchResults from "../SearchResults";
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout"; import type {
MergeFunc,
MergeSearchCleanerFunc,
} from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
type: string type: string;
currentId: number currentId: number;
currentTitle: string currentTitle: string;
mergeFunc: MergeFunc mergeFunc: MergeFunc;
mergeCleanerFunc: MergeSearchCleanerFunc mergeCleanerFunc: MergeSearchCleanerFunc;
} }
export default function MergeModal(props: Props) { export default function MergeModal(props: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState("");
const [data, setData] = useState<SearchResponse>(); const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query); const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0}) const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
const [mergeOrderReversed, setMergeOrderReversed] = useState(false) { title: "", id: 0 }
const [replaceImage, setReplaceImage] = useState(false) );
const navigate = useNavigate() const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
const [replaceImage, setReplaceImage] = useState(false);
const navigate = useNavigate();
const closeMergeModal = () => {
props.setOpen(false);
setQuery("");
setData(undefined);
setMergeOrderReversed(false);
setMergeTarget({ title: "", id: 0 });
};
const closeMergeModal = () => { const toggleSelect = ({ title, id }: { title: string; id: number }) => {
props.setOpen(false) setMergeTarget({ title: title, id: id });
setQuery('') };
setData(undefined)
setMergeOrderReversed(false) useEffect(() => {
setMergeTarget({title: '', id: 0}) console.log("mergeTarget", mergeTarget);
}, [mergeTarget]);
const doMerge = () => {
let from, to;
if (!mergeOrderReversed) {
from = mergeTarget;
to = { id: props.currentId, title: props.currentTitle };
} else {
from = { id: props.currentId, title: props.currentTitle };
to = mergeTarget;
} }
props
const toggleSelect = ({title, id}: {title: string, id: number}) => { .mergeFunc(from.id, to.id, replaceImage)
setMergeTarget({title: title, id: id}) .then((r) => {
} if (r.ok) {
if (mergeOrderReversed) {
useEffect(() => { navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
console.log("mergeTarget",mergeTarget) closeMergeModal();
}, [mergeTarget]) } else {
window.location.reload();
const doMerge = () => { }
let from, to
if (!mergeOrderReversed) {
from = mergeTarget
to = {id: props.currentId, title: props.currentTitle}
} else { } else {
from = {id: props.currentId, title: props.currentTitle} // TODO: handle error
to = mergeTarget console.log(r);
} }
props.mergeFunc(from.id, to.id, replaceImage) })
.then(r => { .catch((err) => console.log(err));
if (r.ok) { };
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`) useEffect(() => {
closeMergeModal() const handler = setTimeout(() => {
} else { setDebouncedQuery(query);
window.location.reload() if (query === "") {
} setData(undefined);
} else { }
// TODO: handle error }, 300);
console.log(r)
} return () => {
}) clearTimeout(handler);
.catch((err) => console.log(err)) };
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId);
setData(r);
});
} }
}, [debouncedQuery]);
useEffect(() => { return (
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId)
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={props.open} onClose={closeMergeModal}> <Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2> <h3>Merge {props.type}s</h3>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<input <input
type="text" type="text"
autoFocus autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal // i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`} placeholder={`Search for a${
className="w-full mx-auto fg bg rounded p-2" props.type.toLowerCase()[0] === "a" ? "n" : ""
onChange={(e) => setQuery(e.target.value)} } ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
/> className="w-full mx-auto fg bg rounded p-2"
<SearchResults selectorMode data={data} onSelect={toggleSelect}/> onChange={(e) => setQuery(e.target.value)}
{ mergeTarget.id !== 0 ? />
<> <SearchResults selectorMode data={data} onSelect={toggleSelect} />
{mergeOrderReversed ? {mergeTarget.id !== 0 ? (
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p> <>
: {mergeOrderReversed ? (
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p> <p className="mt-5">
} <strong>{props.currentTitle}</strong> will be merged into{" "}
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button> <strong>{mergeTarget.title}</strong>
</p>
) : (
<p className="mt-5">
<strong>{mergeTarget.title}</strong> will be merged into{" "}
<strong>{props.currentTitle}</strong>
</p>
)}
<button
className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)"
onClick={doMerge}
>
Merge Items
</button>
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} /> <input
<label htmlFor="reverse-merge-order">Reverse merge order</label> type="checkbox"
name="reverse-merge-order"
checked={mergeOrderReversed}
onChange={() => setMergeOrderReversed(!mergeOrderReversed)}
/>
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</div> </div>
{ {(props.type.toLowerCase() === "album" ||
(props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") && props.type.toLowerCase() === "artist") && (
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<input type="checkbox" name="replace-image" checked={replaceImage} onChange={() => setReplaceImage(!replaceImage)} /> <input
type="checkbox"
name="replace-image"
checked={replaceImage}
onChange={() => setReplaceImage(!replaceImage)}
/>
<label htmlFor="replace-image">Replace image</label> <label htmlFor="replace-image">Replace image</label>
</div> </div>
} )}
</> : </>
''} ) : (
</div> ""
)}
</div>
</Modal> </Modal>
) );
} }

View file

@ -4,57 +4,57 @@ import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults"; import SearchResults from "../SearchResults";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
} }
export default function SearchModal({ open, setOpen }: Props) { export default function SearchModal({ open, setOpen }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState("");
const [data, setData] = useState<SearchResponse>(); const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query); const [debouncedQuery, setDebouncedQuery] = useState(query);
const closeSearchModal = () => { const closeSearchModal = () => {
setOpen(false) setOpen(false);
setQuery('') setQuery("");
setData(undefined) setData(undefined);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === "") {
setData(undefined);
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
} }
}, [debouncedQuery]);
useEffect(() => { return (
const handler = setTimeout(() => { <Modal isOpen={open} onClose={closeSearchModal}>
setDebouncedQuery(query); <h3>Search</h3>
if (query === '') { <div className="flex flex-col items-center">
setData(undefined) <input
} type="text"
}, 300); autoFocus
placeholder="Search for an artist, album, or track"
return () => { className="w-full mx-auto fg bg rounded p-2"
clearTimeout(handler); onChange={(e) => setQuery(e.target.value)}
}; />
}, [query]); <div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal} />
useEffect(() => { </div>
if (debouncedQuery) { </div>
search(debouncedQuery).then((r) => { </Modal>
setData(r); );
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h2>Search</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
placeholder="Search for an artist, album, or track"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal}/>
</div>
</div>
</Modal>
)
} }

View file

@ -0,0 +1,114 @@
import { imageUrl, type RewindStats } from "api/api";
import RewindTopItem from "./RewindTopItem";
interface Props {
stats: RewindStats;
includeTime: boolean;
}
export default function Rewind(props: Props) {
const artistimg = props.stats.top_artists[0].image;
const albumimg = props.stats.top_albums[0].image;
const trackimg = props.stats.top_tracks[0].image;
return (
<div className="flex flex-col gap-10">
<h1>{props.stats.title}</h1>
<div className="flex gap-5">
<div className="rewind-top-item-image">
<img className="w-58 h-58" src={imageUrl(artistimg, "medium")} />
</div>
<div className="flex flex-col gap-1">
<h4>Top Artist</h4>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start mb-3">
<h2>{props.stats.top_artists[0].name}</h2>
<span className="text-(--color-fg-tertiary) -mt-3">
{`${props.stats.top_artists[0].listen_count} plays`}
{props.includeTime
? ` (${Math.floor(
props.stats.top_artists[0].time_listened / 60
)} minutes)`
: ``}
</span>
</div>
</div>
{props.stats.top_artists.slice(1).map((e, i) => (
<div className="" key={e.id}>
{e.name}
<span className="text-(--color-fg-tertiary)">
{` - ${e.listen_count} plays`}
{props.includeTime
? ` (${Math.floor(e.time_listened / 60)} minutes)`
: ``}
</span>
</div>
))}
</div>
</div>
<div className="flex gap-5">
<div className="rewind-top-item-image">
<img className="w-58 h-58" src={imageUrl(albumimg, "medium")} />
</div>
<div className="flex flex-col gap-1">
<h4>Top Album</h4>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start mb-3">
<h2>{props.stats.top_albums[0].title}</h2>
<span className="text-(--color-fg-tertiary) -mt-3">
{`${props.stats.top_albums[0].listen_count} plays`}
{props.includeTime
? ` (${Math.floor(
props.stats.top_albums[0].time_listened / 60
)} minutes)`
: ``}
</span>
</div>
</div>
{props.stats.top_albums.slice(1).map((e, i) => (
<div className="" key={e.id}>
{e.title}
<span className="text-(--color-fg-tertiary)">
{` - ${e.listen_count} plays`}
{props.includeTime
? ` (${Math.floor(e.time_listened / 60)} minutes)`
: ``}
</span>
</div>
))}
</div>
</div>
<div className="flex gap-5">
<div className="rewind-top-item-image">
<img className="w-58 h-58" src={imageUrl(trackimg, "medium")} />
</div>
<div className="flex flex-col gap-1">
<h4>Top Track</h4>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start mb-3">
<h2>{props.stats.top_tracks[0].title}</h2>
<span className="text-(--color-fg-tertiary) -mt-3">
{`${props.stats.top_tracks[0].listen_count} plays`}
{props.includeTime
? ` (${Math.floor(
props.stats.top_tracks[0].time_listened / 60
)} minutes)`
: ``}
</span>
</div>
</div>
{props.stats.top_tracks.slice(1).map((e, i) => (
<div className="" key={e.id}>
{e.title}
<span className="text-(--color-fg-tertiary)">
{` - ${e.listen_count} plays`}
{props.includeTime
? ` (${Math.floor(e.time_listened / 60)} minutes)`
: ``}
</span>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import { imageUrl, type Artist } from "api/api";
interface args {
title?: string;
name?: string;
image: string;
minutes_listened: number;
time_listened: number;
artists?: Artist;
}
export default function RewindTopItem(args: args[]) {
console.log(args);
if (args === undefined || args.length < 1) {
return <></>;
}
const img = imageUrl(args[0].image, "medium");
return (
<div className="flex gap-2">
<div className="rewind-top-item-image">
<img src={img} />
</div>
<div className="flex flex-col gap-1">
<h3>{args[0].title || args[0].name}</h3>
{args.map((e) => (
<div className="">{e.title || e.name}</div>
))}
</div>
</div>
);
}

View file

@ -1,13 +1,14 @@
import { ExternalLink, Home, Info } from "lucide-react"; import { ExternalLink, History, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch"; import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem"; import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings"; import SidebarSettings from "./SidebarSettings";
export default function Sidebar() { export default function Sidebar() {
const iconSize = 20; const iconSize = 20;
return ( return (
<div className=" <div
className="
z-50 z-50
flex flex
sm:flex-col sm:flex-col
@ -28,28 +29,44 @@ export default function Sidebar() {
sm:px-1 sm:px-1
px-4 px-4
bg-(--color-bg) bg-(--color-bg)
"> "
<div className="flex gap-4 sm:flex-col"> >
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}> <div className="flex gap-4 sm:flex-col">
<Home size={iconSize} /> <SidebarItem
</SidebarItem> space={10}
<SidebarSearch size={iconSize} /> to="/"
</div> name="Home"
<div className="flex gap-4 sm:flex-col"> onClick={() => {}}
<SidebarItem modal={<></>}
icon >
keyHint={<ExternalLink size={14} />} <Home size={iconSize} />
space={22} </SidebarItem>
externalLink <SidebarSearch size={iconSize} />
to="https://koito.io" <SidebarItem
name="About" space={10}
onClick={() => {}} to="/rewind"
modal={<></>} name="Rewind"
> onClick={() => {}}
<Info size={iconSize} /> modal={<></>}
</SidebarItem> >
<SidebarSettings size={iconSize} /> <History size={iconSize} />
</div> </SidebarItem>
</div> </div>
); <div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
);
} }

View file

@ -44,7 +44,7 @@ export function ThemeSwitcher() {
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2>Select Theme</h2> <h3>Select Theme</h3>
<div className="mb-3"> <div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton> <AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div> </div>
@ -61,7 +61,7 @@ export function ThemeSwitcher() {
</div> </div>
</div> </div>
<div> <div>
<h2>Use Custom Theme</h2> <h3>Use Custom Theme</h3>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg"> <div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea <textarea
name="custom-theme" name="custom-theme"

View file

@ -1,13 +1,14 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes"; import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ export default [
index("routes/Home.tsx"), index("routes/Home.tsx"),
route("/artist/:id", "routes/MediaItems/Artist.tsx"), route("/artist/:id", "routes/MediaItems/Artist.tsx"),
route("/album/:id", "routes/MediaItems/Album.tsx"), route("/album/:id", "routes/MediaItems/Album.tsx"),
route("/track/:id", "routes/MediaItems/Track.tsx"), route("/track/:id", "routes/MediaItems/Track.tsx"),
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"), route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"), route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"), route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
route("/listens", "routes/Charts/Listens.tsx"), route("/listens", "routes/Charts/Listens.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"), route("/rewind", "routes/RewindPage.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;

View file

@ -0,0 +1,16 @@
import Rewind from "~/components/rewind/Rewind";
import type { Route } from "./+types/Home";
import { getRewindStats, type RewindStats } from "api/api";
import { useEffect, useState } from "react";
export function meta({}: Route.MetaArgs) {
return [{ title: "Koito" }, { name: "description", content: "Koito" }];
}
export default function RewindPage() {
const [stats, setStats] = useState<RewindStats | undefined>(undefined);
useEffect(() => {
getRewindStats({ year: 2025 }).then((r) => setStats(r));
}, []);
return <>{stats !== undefined && <Rewind stats={stats} includeTime />}</>;
}