chore: initial public commit

This commit is contained in:
Gabe Farrell 2025-06-11 19:45:39 -04:00
commit fc9054b78c
250 changed files with 32809 additions and 0 deletions

View file

@ -0,0 +1,185 @@
import { useQuery } from "@tanstack/react-query"
import { getActivity, type getActivityArgs } from "api/api"
import Popup from "./Popup"
import { useEffect, useState } from "react"
import { useTheme } from "~/hooks/useTheme"
import ActivityOptsSelector from "./ActivityOptsSelector"
function getPrimaryColor(): string {
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary')
.trim();
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return (
'#' +
[r, g, b]
.map((n) => n.toString(16).padStart(2, '0'))
.join('')
);
}
return value;
}
interface Props {
step?: string
range?: number
month?: number
year?: number
artistId?: number
albumId?: number
trackId?: number
configurable?: boolean
autoAdjust?: boolean
}
export default function ActivityGrid({
step = 'day',
range = 182,
month = 0,
year = 0,
artistId = 0,
albumId = 0,
trackId = 0,
configurable = false,
autoAdjust = false,
}: Props) {
const [color, setColor] = useState(getPrimaryColor())
const [stepState, setStep] = useState(step)
const [rangeState, setRange] = useState(range)
const { isPending, isError, data, error } = useQuery({
queryKey: [
'listen-activity',
{
step: stepState,
range: rangeState,
month: month,
year: year,
artist_id: artistId,
album_id: albumId,
track_id: trackId
},
],
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
});
const { theme } = useTheme();
useEffect(() => {
const raf = requestAnimationFrame(() => {
const color = getPrimaryColor()
setColor(color);
});
return () => cancelAnimationFrame(raf);
}, [theme]);
if (isPending) {
return (
<div className="w-[500px]">
<h2>Activity</h2>
<p>Loading...</p>
</div>
)
}
if (isError) return <p className="error">Error:{error.message}</p>
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
function LightenDarkenColor(hex: string, lum: number) {
// validate hex string
hex = String(hex).replace(/[^0-9a-f]/gi, '');
if (hex.length < 6) {
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
}
lum = lum || 0;
// convert to decimal and change luminosity
var rgb = "#", c, i;
for (i = 0; i < 3; i++) {
c = parseInt(hex.substring(i*2,(i*2)+2), 16);
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
rgb += ("00"+c).substring(c.length);
}
return rgb;
}
const getDarkenAmount = (v: number, t: number): number => {
if (autoAdjust) {
// automatically adjust the target value based on step
// the smartest way to do this would be to have the api return the
// highest value in the range. too bad im not smart
switch (stepState) {
case 'day':
t = 10
break;
case 'week':
t = 20
break;
case 'month':
t = 50
break;
case 'year':
t = 100
break;
}
}
v = Math.min(v, t)
if (theme === "pearl") {
// special case for the only light theme lol
// could be generalized by pragmatically comparing the
// lightness of the bg vs the primary but eh
return ((t-v) / t)
} else {
return ((v-t) / t) * .8
}
}
const dotSize = 12;
return (
<div className="flex flex-col items-start">
<h2>Activity</h2>
{configurable ?
<ActivityOptsSelector rangeSetter={setRange} currentRange={rangeState} stepSetter={setStep} currentStep={stepState} />
:
''
}
<div className="grid grid-flow-col grid-rows-7 gap-[5px]">
{data.map((item) => (
<div
key={new Date(item.start_time).toString()}
style={{ width: dotSize, height: dotSize }}
>
<Popup
position="top"
space={dotSize}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
>
<div
style={{
width: dotSize,
height: dotSize,
display: 'inline-block',
background:
item.listens > 0
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
: 'var(--color-bg-secondary)',
}}
className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
></div>
</Popup>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { useEffect } from "react";
interface Props {
stepSetter: (value: string) => void;
currentStep: string;
rangeSetter: (value: number) => void;
currentRange: number;
disableCache?: boolean;
}
export default function ActivityOptsSelector({
stepSetter,
currentStep,
rangeSetter,
currentRange,
disableCache = false,
}: Props) {
const stepPeriods = ['day', 'week', 'month', 'year'];
const rangePeriods = [105, 182, 365];
const stepDisplay = (str: string): string => {
return str.split('_').map(w =>
w.split('').map((char, index) =>
index === 0 ? char.toUpperCase() : char).join('')
).join(' ');
};
const rangeDisplay = (r: number): string => {
return `${r}`
}
const setStep = (val: string) => {
stepSetter(val);
if (!disableCache) {
localStorage.setItem('activity_step_' + window.location.pathname.split('/')[1], val);
}
};
const setRange = (val: number) => {
rangeSetter(val);
if (!disableCache) {
localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val));
}
};
useEffect(() => {
if (!disableCache) {
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
if (cachedRange) {
rangeSetter(cachedRange);
}
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
if (cachedStep) {
stepSetter(cachedStep);
}
}
}, []);
return (
<div className="flex flex-col">
<div className="flex gap-2 items-center">
<p>Step:</p>
{stepPeriods.map((p, i) => (
<div key={`step_selector_${p}`}>
<button
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
onClick={() => setStep(p)}
disabled={p === currentStep}
>
{stepDisplay(p)}
</button>
<span className="color-fg-secondary">
{i !== stepPeriods.length - 1 ? '|' : ''}
</span>
</div>
))}
</div>
<div className="flex gap-2 items-center">
<p>Range:</p>
{rangePeriods.map((r, i) => (
<div key={`range_selector_${r}`}>
<button
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
onClick={() => setRange(r)}
disabled={r === currentRange}
>
{rangeDisplay(r)}
</button>
<span className="color-fg-secondary">
{i !== rangePeriods.length - 1 ? '|' : ''}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { imageUrl, type Album } from "api/api";
import { Link } from "react-router";
interface Props {
album: Album
size: number
}
export default function AlbumDisplay({ album, size }: Props) {
return (
<div className="flex gap-3" key={album.id}>
<div>
<Link to={`/album/${album.id}`}>
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/>
</Link>
</div>
<div className="flex flex-col items-start" style={{width: size}}>
<Link 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

@ -0,0 +1,45 @@
import { useQuery } from "@tanstack/react-query"
import { getStats } from "api/api"
export default function AllTimeStats() {
const { isPending, isError, data, error } = useQuery({
queryKey: ['stats', 'all_time'],
queryFn: ({ queryKey }) => getStats(queryKey[1]),
})
if (isPending) {
return (
<div className="w-[200px]">
<h2>All Time Stats</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
const numberClasses = 'header-font font-bold text-xl'
return (
<div>
<h2>All Time Stats</h2>
<div>
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
</div>
<div>
<span className={numberClasses}>{data.listen_count}</span> Plays
</div>
<div>
<span className={numberClasses}>{data.artist_count}</span> Artists
</div>
<div>
<span className={numberClasses}>{data.album_count}</span> Albums
</div>
<div>
<span className={numberClasses}>{data.track_count}</span> Tracks
</div>
</div>
)
}

View file

@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query"
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
interface Props {
artistId: number
name: string
period: string
}
export default function ArtistAlbums({artistId, name, period}: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {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>
)
}
return (
<div>
<h2>Albums featuring {name}</h2>
<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

@ -0,0 +1,26 @@
import React from 'react';
import { Link } from 'react-router';
type Artist = {
id: number;
name: string;
};
type ArtistLinksProps = {
artists: Artist[];
};
const ArtistLinks: React.FC<ArtistLinksProps> = ({ artists }) => {
return (
<>
{artists.map((artist, index) => (
<span key={artist.id} className='color-fg-secondary'>
<Link className="hover:text-(--color-fg-tertiary)" to={`/artist/${artist.id}`}>{artist.name}</Link>
{index < artists.length - 1 ? ', ' : ''}
</span>
))}
</>
);
};
export default ArtistLinks;

View file

@ -0,0 +1,43 @@
import React, { useState } from "react"
type Props = {
children: React.ReactNode
onClick: () => void
loading?: boolean
disabled?: boolean
confirm?: boolean
}
export function AsyncButton(props: Props) {
const [awaitingConfirm, setAwaitingConfirm] = useState(false)
const handleClick = () => {
if (props.confirm) {
if (!awaitingConfirm) {
setAwaitingConfirm(true)
setTimeout(() => setAwaitingConfirm(false), 3000)
return
}
setAwaitingConfirm(false)
}
props.onClick()
}
return (
<button
onClick={handleClick}
disabled={props.loading || props.disabled}
className={`relative px-5 py-2 rounded-md large-button flex disabled:opacity-50 items-center`}
>
<span className={props.loading ? 'invisible' : 'visible'}>
{awaitingConfirm ? 'Are you sure?' : props.children}
</span>
{props.loading && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></span>
</span>
)}
</button>
)
}

View file

@ -0,0 +1,13 @@
import { ExternalLinkIcon } from 'lucide-react'
import pkg from '../../package.json'
export default function Footer() {
return (
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
<ul className="flex flex-col items-center w-sm justify-around">
<li>Koito {pkg.version}</li>
<li><a href="https://github.com/gabehf/koito" target="_blank" className="link-underline">View the source on GitHub <ExternalLinkIcon className='inline mb-1' size={14}/></a></li>
</ul>
</div>
)
}

View file

@ -0,0 +1,36 @@
// import { css } from '@emotion/css';
// import { themes } from '../providers/ThemeProvider';
// export default function GlobalThemes() {
// return (
// <div
// styles={css`
// ${themes
// .map(
// (theme) => `
// [data-theme=${theme.name}] {
// --color-bg: ${theme.bg};
// --color-bg-secondary: ${theme.bgSecondary};
// --color-bg-tertiary:${theme.bgTertiary};
// --color-fg: ${theme.fg};
// --color-fg-secondary: ${theme.fgSecondary};
// --color-fg-tertiary: ${theme.fgTertiary};
// --color-primary: ${theme.primary};
// --color-primary-dim: ${theme.primaryDim};
// --color-secondary: ${theme.secondary};
// --color-secondary-dim: ${theme.secondaryDim};
// --color-error: ${theme.error};
// --color-success: ${theme.success};
// --color-warning: ${theme.warning};
// --color-info: ${theme.info};
// --color-border: var(--color-bg-tertiary);
// --color-shadow: rgba(0, 0, 0, 0.5);
// --color-link: var(--color-primary);
// --color-link-hover: var(--color-primary-dim);
// }
// `).join('\n')
// }
// `}
// />
// )
// }

View file

@ -0,0 +1,53 @@
import { replaceImage } from 'api/api';
import { useEffect } from 'react';
interface Props {
itemType: string,
id: number,
onComplete: Function
}
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
useEffect(() => {
const handleDragOver = (e: DragEvent) => {
console.log('dragover!!')
e.preventDefault();
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.files.length) return;
const imageFile = Array.from(e.dataTransfer.files).find(file =>
file.type.startsWith('image/')
);
if (!imageFile) return;
const formData = new FormData();
formData.append('image', imageFile);
formData.append(itemType.toLowerCase()+'_id', String(id))
replaceImage(formData).then((r) => {
if (r.status >= 200 && r.status < 300) {
onComplete()
console.log("Replacement image uploaded successfully")
} else {
r.json().then((body) => {
console.log(`Upload failed: ${r.statusText} - ${body}`)
})
}
}).catch((err) => {
console.log(`Upload failed: ${err}`)
})
};
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, []);
return null;
}

View file

@ -0,0 +1,57 @@
import { useQuery } from "@tanstack/react-query"
import { timeSince } from "~/utils/utils"
import ArtistLinks from "./ArtistLinks"
import { getLastListens, type getItemsArgs } from "api/api"
import { Link } from "react-router"
interface Props {
limit: number
artistId?: Number
albumId?: Number
trackId?: number
hideArtists?: boolean
}
export default function LastPlays(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['last-listens', {limit: props.limit, period: 'all_time', artist_id: props.artistId, album_id: props.albumId, track_id: props.trackId}],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div className="w-[500px]">
<h2>Last Played</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
let params = ''
params += props.artistId ? `&artist_id=${props.artistId}` : ''
params += props.albumId ? `&album_id=${props.albumId}` : ''
params += props.trackId ? `&track_id=${props.trackId}` : ''
return (
<div>
<h2 className="hover:underline"><Link to={`/listens?period=all_time${params}`}>Last Played</Link></h2>
<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 max-w-[600px]">
{props.hideArtists ? <></> : <><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>
)
}

View file

@ -0,0 +1,52 @@
import { useEffect } from "react"
interface Props {
setter: Function
current: string
disableCache?: boolean
}
export default function PeriodSelector({ setter, current, disableCache = false }: Props) {
const periods = ['day', 'week', 'month', 'year', 'all_time']
const periodDisplay = (str: string) => {
return str.split('_').map(w => w.split('').map((char, index) =>
index === 0 ? char.toUpperCase() : char).join('')).join(' ')
}
const setPeriod = (val: string) => {
setter(val)
if (!disableCache) {
localStorage.setItem('period_selection_'+window.location.pathname.split('/')[1], val)
}
}
useEffect(() => {
if (!disableCache) {
const cached = localStorage.getItem('period_selection_' + window.location.pathname.split('/')[1]);
if (cached) {
setter(cached);
}
}
}, []);
return (
<div className="flex gap-2">
<p>Showing stats for:</p>
{periods.map((p, i) => (
<div key={`period_setter_${p}`}>
<button
className={`period-selector ${p === current ? 'color-fg' : 'color-fg-secondary'} ${i !== periods.length - 1 ? 'pr-2' : ''}`}
onClick={() => setPeriod(p)}
disabled={p === current}
>
{periodDisplay(p)}
</button>
<span className="color-fg-secondary">
{i !== periods.length - 1 ? '|' : ''}
</span>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,48 @@
import React, { type PropsWithChildren, useState } from 'react';
interface Props {
inner: React.ReactNode
position: string
space: number
extraClasses?: string
hint?: string
}
export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren<Props>) {
const [isVisible, setIsVisible] = useState(false);
let positionClasses
let spaceCSS = {}
if (position == "top") {
positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`
} else if (position == "right") {
positionClasses = `bottom-1 -translate-x-1/2`
spaceCSS = {left: 70 + space}
}
return (
<div
className="relative"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
<div
className={`
absolute
${positionClasses}
${extraClasses ? extraClasses : ''}
bg-(--color-bg) color-fg border-1 border-(--color-bg-tertiary)
px-3 py-2 rounded-lg
transition-opacity duration-100
${isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
z-50 text-center
flex
`}
style={spaceCSS}
>
{inner}
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { Link } from "react-router"
interface Props {
to: string
onClick: React.MouseEventHandler<HTMLAnchorElement>
img: string
text: string
subtext?: string
}
export default function SearchResultItem(props: Props) {
return (
<Link to={props.to} className="px-3 py-2 flex gap-3 items-center hover:text-(--color-fg-secondary)" onClick={props.onClick}>
<img src={props.img} alt={props.text} />
<div>
{props.text}
{props.subtext ? <><br/>
<span className="color-fg-secondary">{props.subtext}</span>
</> : ''}
</div>
</Link>
)
}

View file

@ -0,0 +1,31 @@
import { Check } from "lucide-react"
import CheckCircleIcon from "./icons/CheckCircleIcon"
interface Props {
id: number
onClick: React.MouseEventHandler<HTMLButtonElement>
img: string
text: string
subtext?: string
active: boolean
}
export default function SearchResultSelectorItem(props: Props) {
return (
<button className="px-3 py-2 flex gap-3 items-center hover:text-(--color-fg-secondary) hover:cursor-pointer w-full" style={{ border: props.active ? "1px solid var(--color-fg-tertiary" : ''}} onClick={props.onClick}>
<img src={props.img} alt={props.text} />
<div className="flex justify-between items-center w-full">
<div className="flex flex-col items-start text-start">
{props.text}
{props.subtext ? <><br/>
<span className="color-fg-secondary">{props.subtext}</span>
</> : ''}
</div>
{
props.active ?
<div className="px-2"><Check size={24} /></div> : ''
}
</div>
</button>
)
}

View file

@ -0,0 +1,107 @@
import { imageUrl, type SearchResponse } from "api/api"
import { useState } from "react"
import SearchResultItem from "./SearchResultItem"
import SearchResultSelectorItem from "./SearchResultSelectorItem"
interface Props {
data?: SearchResponse
onSelect: Function
selectorMode?: boolean
}
export default function SearchResults({ data, onSelect, selectorMode }: Props) {
const [selected, setSelected] = useState(0)
const classes = "flex flex-col items-start bg rounded w-full"
const hClasses = "pt-4 pb-2"
const selectItem = (title: string, id: number) => {
if (selected === id) {
setSelected(0)
onSelect({id: id, title: title})
} else {
setSelected(id)
onSelect({id: id, title: title})
}
}
if (data === undefined) {
return <></>
}
return (
<div className="w-full">
{ data.artists.length > 0 &&
<>
<h3 className={hClasses}>Artists</h3>
<div className={classes}>
{data.artists.map((artist) => (
selectorMode ?
<SearchResultSelectorItem
id={artist.id}
onClick={() => selectItem(artist.name, artist.id)}
text={artist.name}
img={imageUrl(artist.image, "small")}
active={selected === artist.id}
/> :
<SearchResultItem
to={`/artist/${artist.id}`}
onClick={() => onSelect(artist.id)}
text={artist.name}
img={imageUrl(artist.image, "small")}
/>
))}
</div>
</>
}
{ data.albums.length > 0 &&
<>
<h3 className={hClasses}>Albums</h3>
<div className={classes}>
{data.albums.map((album) => (
selectorMode ?
<SearchResultSelectorItem
id={album.id}
onClick={() => selectItem(album.title, album.id)}
text={album.title}
subtext={album.is_various_artists ? "Various Artists" : album.artists[0].name}
img={imageUrl(album.image, "small")}
active={selected === album.id}
/> :
<SearchResultItem
to={`/album/${album.id}`}
onClick={() => onSelect(album.id)}
text={album.title}
subtext={album.is_various_artists ? "Various Artists" : album.artists[0].name}
img={imageUrl(album.image, "small")}
/>
))}
</div>
</>
}
{ data.tracks.length > 0 &&
<>
<h3 className={hClasses}>Tracks</h3>
<div className={classes}>
{data.tracks.map((track) => (
selectorMode ?
<SearchResultSelectorItem
id={track.id}
onClick={() => selectItem(track.title, track.id)}
text={track.title}
subtext={track.artists.map((a) => a.name).join(', ')}
img={imageUrl(track.image, "small")}
active={selected === track.id}
/> :
<SearchResultItem
to={`/track/${track.id}`}
onClick={() => onSelect(track.id)}
text={track.title}
subtext={track.artists.map((a) => a.name).join(', ')}
img={imageUrl(track.image, "small")}
/>
))}
</div>
</>
}
</div>
)
}

View file

@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query"
import ArtistLinks from "./ArtistLinks"
import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import TopListSkeleton from "./skeletons/TopListSkeleton"
import TopItemList from "./TopItemList"
interface Props {
limit: number,
period: string,
artistId?: Number
}
export default function TopAlbums (props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Albums</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
return (
<div>
<h2 className="hover:underline"><Link to={`/chart/top-albums?period=${props.period}${props.artistId ? `&artist_id=${props.artistId}` : ''}`}>Top Albums</Link></h2>
<div className="max-w-[300px]">
<TopItemList type="album" data={data} />
{data.items.length < 1 ? 'Nothing to show' : ''}
</div>
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query"
import ArtistLinks from "./ArtistLinks"
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import TopListSkeleton from "./skeletons/TopListSkeleton"
import TopItemList from "./TopItemList"
interface Props {
limit: number,
period: string,
artistId?: Number
albumId?: Number
}
export default function TopArtists (props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Artists</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
return (
<div>
<h2 className="hover:underline"><Link to={`/chart/top-artists?period=${props.period}`}>Top Artists</Link></h2>
<div className="max-w-[300px]">
<TopItemList type="artist" data={data} />
{data.items.length < 1 ? 'Nothing to show' : ''}
</div>
</div>
)
}

View file

@ -0,0 +1,142 @@
import { Link, useNavigate } from "react-router";
import ArtistLinks from "./ArtistLinks";
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
type Item = Album | Track | Artist;
interface Props<T extends Item> {
data: PaginatedResponse<T>
separators?: ConstrainBoolean
width?: number
type: "album" | "track" | "artist";
}
export default function TopItemList<T extends Item>({ data, separators, type, width }: Props<T>) {
return (
<div className="flex flex-col gap-1" style={{width: width ?? 300}}>
{data.items.map((item, index) => {
const key = `${type}-${item.id}`;
return (
<div
key={key}
style={{ fontSize: 12 }}
className={`${
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
}`}
>
<ItemCard item={item} type={type} key={type+item.id} />
</div>
);
})}
</div>
);
}
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
const navigate = useNavigate();
const handleItemClick = (type: string, id: number) => {
navigate(`/${type.toLowerCase()}/${id}`);
};
const handleArtistClick = (event: React.MouseEvent) => {
// Stop the click from navigating to the album page
event.stopPropagation();
};
// Also stop keyboard events on the inner links from bubbling up
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
event.stopPropagation();
}
switch (type) {
case "album": {
const album = item as Album;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("album", album.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("album", album.id)}
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View album: ${album.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(album.image, "small")} alt={album.title} />
<div>
<span style={{fontSize: 14}}>{album.title}</span>
<br />
{album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span>
:
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div>
}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</div>
</div>
</div>
);
}
case "track": {
const track = item as Track;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("track", track.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("track", track.id)}
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View track: ${track.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(track.image, "small")} alt={track.title} />
<div>
<span style={{fontSize: 14}}>{track.title}</span>
<br />
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div>
<div className="color-fg-secondary">{track.listen_count} plays</div>
</div>
</div>
</div>
);
}
case "artist": {
const artist = item as Artist;
return (
<div style={{fontSize: 12}}>
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
<div>
<span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div>
</div>
</Link>
</div>
);
}
}
}

View file

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query"
import { getTopAlbums, type getItemsArgs } from "api/api"
import AlbumDisplay from "./AlbumDisplay"
interface Props {
period: string
artistId?: Number
vert?: boolean
hideTitle?: boolean
}
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),
})
if (isPending) {
return <p>Loading...</p>
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
console.log(data)
return (
<div>
{!props.hideTitle && <h2>Top Three Albums</h2>}
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
{data.items.map((item, index) => (
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,50 @@
import { useQuery } from "@tanstack/react-query"
import ArtistLinks from "./ArtistLinks"
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import TopListSkeleton from "./skeletons/TopListSkeleton"
import { useEffect } from "react"
import TopItemList from "./TopItemList"
interface Props {
limit: number,
period: string,
artistId?: Number
albumId?: Number
}
const TopTracks = (props: Props) => {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Tracks</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
let params = ''
params += props.artistId ? `&artist_id=${props.artistId}` : ''
params += props.albumId ? `&album_id=${props.albumId}` : ''
return (
<div>
<h2 className="hover:underline"><Link to={`/chart/top-tracks?period=${props.period}${params}`}>Top Tracks</Link></h2>
<div className="max-w-[300px]">
<TopItemList type="track" data={data}/>
{data.items.length < 1 ? 'Nothing to show' : ''}
</div>
</div>
)
}
export default TopTracks

View file

@ -0,0 +1,16 @@
interface Props {
size: number,
hover?: boolean,
}
export default function ChartIcon({size, hover}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-stroke"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C7.58172 0 4 3.58172 4 8C4 10.0289 4.75527 11.8814 6 13.2916V23C6 23.3565 6.18976 23.686 6.49807 23.8649C6.80639 24.0438 7.18664 24.0451 7.49614 23.8682L12 21.2946L16.5039 23.8682C16.8134 24.0451 17.1936 24.0438 17.5019 23.8649C17.8102 23.686 18 23.3565 18 23V13.2916C19.2447 11.8814 20 10.0289 20 8C20 3.58172 16.4183 0 12 0ZM6 8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8C18 11.3137 15.3137 14 12 14C8.68629 14 6 11.3137 6 8ZM16 14.9297C14.8233 15.6104 13.4571 16 12 16C10.5429 16 9.17669 15.6104 8 14.9297V21.2768L11.5039 19.2746C11.8113 19.0989 12.1887 19.0989 12.4961 19.2746L16 21.2768V14.9297Z" fill="var(--color-fg)"/> </svg>
</div>
)
}

View file

@ -0,0 +1,16 @@
interface Props {
size: number,
hover?: boolean,
color?: string
}
export default function CheckCircleIcon({size, hover, color}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-fill"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill={color !== undefined ? `var(--${color})` : 'var(--color-fg)'} fill-rule="evenodd" d="M3 10a7 7 0 019.307-6.611 1 1 0 00.658-1.889 9 9 0 105.98 7.501 1 1 0 00-1.988.22A7 7 0 113 10zm14.75-5.338a1 1 0 00-1.5-1.324l-6.435 7.28-3.183-2.593a1 1 0 00-1.264 1.55l3.929 3.2a1 1 0 001.38-.113l7.072-8z"/> </svg></div>
)
}

View file

@ -0,0 +1,17 @@
interface Props {
size: number,
hover?: boolean,
}
export default function GraphIcon({size, hover}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-stroke"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V3M15 10V17M7 13V17M19 5V17M11 7V17" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
)
}

View file

@ -0,0 +1,16 @@
interface Props {
size: number,
hover?: boolean,
}
export default function HomeIcon({size, hover}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-fill"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3103 1.77586C11.6966 1.40805 12.3034 1.40805 12.6897 1.77586L20.6897 9.39491L23.1897 11.7759C23.5896 12.1567 23.605 12.7897 23.2241 13.1897C22.8433 13.5896 22.2103 13.605 21.8103 13.2241L21 12.4524V20C21 21.1046 20.1046 22 19 22H14H10H5C3.89543 22 3 21.1046 3 20V12.4524L2.18966 13.2241C1.78972 13.605 1.15675 13.5896 0.775862 13.1897C0.394976 12.7897 0.410414 12.1567 0.810345 11.7759L3.31034 9.39491L11.3103 1.77586ZM5 10.5476V20H9V15C9 13.3431 10.3431 12 12 12C13.6569 12 15 13.3431 15 15V20H19V10.5476L12 3.88095L5 10.5476ZM13 20V15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15V20H13Z" fill="var(--color-fg)"/>
</svg></div>
)
}

View file

@ -0,0 +1,16 @@
interface Props {
size: number,
hover?: boolean,
}
export default function ImageIcon({size, hover}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-stroke"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.2639 15.9375L12.5958 14.2834C11.7909 13.4851 11.3884 13.086 10.9266 12.9401C10.5204 12.8118 10.0838 12.8165 9.68048 12.9536C9.22188 13.1095 8.82814 13.5172 8.04068 14.3326L4.04409 18.2801M14.2639 15.9375L14.6053 15.599C15.4112 14.7998 15.8141 14.4002 16.2765 14.2543C16.6831 14.126 17.12 14.1311 17.5236 14.2687C17.9824 14.4251 18.3761 14.8339 19.1634 15.6514L20 16.4934M14.2639 15.9375L18.275 19.9565M18.275 19.9565C17.9176 20 17.4543 20 16.8 20H7.2C6.07989 20 5.51984 20 5.09202 19.782C4.71569 19.5903 4.40973 19.2843 4.21799 18.908C4.12796 18.7313 4.07512 18.5321 4.04409 18.2801M18.275 19.9565C18.5293 19.9256 18.7301 19.8727 18.908 19.782C19.2843 19.5903 19.5903 19.2843 19.782 18.908C20 18.4802 20 17.9201 20 16.8V16.4934M4.04409 18.2801C4 17.9221 4 17.4575 4 16.8V7.2C4 6.0799 4 5.51984 4.21799 5.09202C4.40973 4.71569 4.71569 4.40973 5.09202 4.21799C5.51984 4 6.07989 4 7.2 4H16.8C17.9201 4 18.4802 4 18.908 4.21799C19.2843 4.40973 19.5903 4.71569 19.782 5.09202C20 5.51984 20 6.0799 20 7.2V16.4934M17 8.99989C17 10.1045 16.1046 10.9999 15 10.9999C13.8954 10.9999 13 10.1045 13 8.99989C13 7.89532 13.8954 6.99989 15 6.99989C16.1046 6.99989 17 7.89532 17 8.99989Z" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg></div>
)
}

View file

@ -0,0 +1,15 @@
interface Props {
size: number,
hover?: boolean,
}
export default function MergeIcon({size, hover}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-fill"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--color-fg)" fill-rule="evenodd" d="M10,0 L10,2.60002 C12.2108812,3.04881281 13.8920863,4.95644867 13.9950026,7.27443311 L14,7.5 L14,11.2676 C14.5978,11.6134 15,12.2597 15,13 C15,14.1046 14.1046,15 13,15 C11.8954,15 11,14.1046 11,13 C11,12.3166462 11.342703,11.713387 11.8656124,11.3526403 L12,11.2676 L12,7.5 C12,6.259091 11.246593,5.19415145 10.1722389,4.73766702 L10,4.67071 L10,7 L6,3.5 L10,0 Z M3,1 C4.10457,1 5,1.89543 5,3 C5,3.68333538 4.65729704,4.28663574 4.13438762,4.6473967 L4,4.73244 L4,11.2676 C4.5978,11.6134 5,12.2597 5,13 C5,14.1046 4.10457,15 3,15 C1.89543,15 1,14.1046 1,13 C1,12.3166462 1.34270296,11.713387 1.86561238,11.3526403 L2,11.2676 L2,4.73244 C1.4022,4.38663 1,3.74028 1,3 C1,1.89543 1.89543,1 3,1 Z"/> </svg></div>
)
}

View file

@ -0,0 +1,16 @@
interface Props {
size: number,
hover?: boolean,
}
export default function SearchIcon({size, hover}: Props) {
let classNames = ""
if (hover) {
classNames += "icon-hover-stroke"
}
return (
<div className={classNames}>
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 6C13.7614 6 16 8.23858 16 11M16.6588 16.6549L21 21M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg></div>
)
}

View file

@ -0,0 +1,97 @@
import { logout, updateUser } from "api/api"
import { useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { useAppContext } from "~/providers/AppProvider"
export default function Account() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { user, setUsername: setCtxUsername } = useAppContext()
const logoutHandler = () => {
setLoading(true)
logout()
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
if (password != "" && confirmPw === "") {
setError("confirm your password before submitting")
return
}
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 (
<>
<h2>Account</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div>
<h2>Update User</h2>
<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="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 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>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>
</>
)
}

View file

@ -0,0 +1,17 @@
import { useAppContext } from "~/providers/AppProvider"
import LoginForm from "./LoginForm"
import Account from "./Account"
export default function AuthForm() {
const { user } = useAppContext()
return (
<>
{ user ?
<Account />
:
<LoginForm />
}
</>
)
}

View file

@ -0,0 +1,129 @@
import { useQuery } from "@tanstack/react-query";
import { createApiKey, deleteApiKey, getApiKeys, type ApiKey } from "api/api";
import { AsyncButton } from "../AsyncButton";
import { useEffect, useState } from "react";
import { Copy, Trash } from "lucide-react";
type CopiedState = {
x: number;
y: number;
visible: boolean;
};
export default function ApiKeysModal() {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<ApiKey[]>([])
const [copied, setCopied] = useState<CopiedState | null>(null);
const { isPending, isError, data, error } = useQuery({
queryKey: [
'api-keys'
],
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) => {
navigator.clipboard.writeText(text);
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2, // center of button
y: buttonRect.top - parentRect.top - 8, // above the button
visible: true,
});
setTimeout(() => setCopied(null), 1500);
};
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 className="bg p-3 rounded-md flex-grow" 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>
{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

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

View file

@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { replaceImage, search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
import { AsyncButton } from "../AsyncButton";
interface Props {
type: string
id: number
musicbrainzId?: string
open: boolean
setOpen: Function
}
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false)
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
const doImageReplace = (url: string) => {
setLoading(true)
const formData = new FormData
formData.set(`${type.toLowerCase()}_id`, id.toString())
formData.set("image_url", url)
replaceImage(formData)
.then((r) => {
if (r.ok) {
window.location.reload()
} else {
console.log(r)
setLoading(false)
}
})
.catch((err) => console.log(err))
}
const closeModal = () => {
setOpen(false)
setQuery('')
}
return (
<Modal isOpen={open} onClose={closeModal}>
<h2>Replace Image</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Image URL`}
className="w-full mx-auto fg bg rounded p-2"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{ query != "" ?
<div className="flex gap-2 mt-4">
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
</div> :
''}
{ type === "Album" && musicbrainzId ?
<>
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
<button
className="mt-4"
disabled={loading}
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
>
<div className={`relative`}>
{suggestedImgLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
style={{ width: 20, height: 20 }}
/>
</div>
)}
<img
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
onLoad={() => setSuggestedImgLoading(false)}
onError={() => setSuggestedImgLoading(false)}
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
</div>
</button>
</>
: ''
}
</div>
</Modal>
)
}

View file

@ -0,0 +1,59 @@
import { login } from "api/api"
import { useEffect, useState } from "react"
import { AsyncButton } from "../AsyncButton"
export default function LoginForm() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const loginHandler = () => {
if (username && password) {
setLoading(true)
login(username, password, remember)
.then(r => {
if (r.status >= 200 && r.status < 300) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} else if (username || password) {
setError("username and password are required")
}
}
return (
<>
<h2>Log In</h2>
<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>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<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

@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router";
interface Props {
open: boolean
setOpen: Function
type: string
currentId: number
currentTitle: string
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
}
export default function MergeModal(props: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const navigate = useNavigate()
const closeMergeModal = () => {
props.setOpen(false)
setQuery('')
setData(undefined)
setMergeOrderReversed(false)
setMergeTarget({title: '', id: 0})
}
const toggleSelect = ({title, id}: {title: string, id: number}) => {
if (mergeTarget.id === 0) {
setMergeTarget({title: title, id: id})
} else {
setMergeTarget({title:"", id: 0})
}
}
useEffect(() => {
console.log(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.mergeFunc(from.id, to.id)
.then(r => {
if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget}`)
closeMergeModal()
} else {
window.location.reload()
}
} else {
// TODO: handle error
console.log(r)
}
})
.catch((err) => console.log(err))
}
useEffect(() => {
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}>
<h2>Merge {props.type}s</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// 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()}`}
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
{ mergeTarget.id !== 0 ?
<>
{mergeOrderReversed ?
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <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">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</div>
</> :
''}
</div>
</Modal>
)
}

View file

@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
export function Modal({
isOpen,
onClose,
children,
maxW,
h
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
maxW?: number;
h?: number;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const [shouldRender, setShouldRender] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
// Show/hide logic
useEffect(() => {
if (isOpen) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timeout = setTimeout(() => {
setShouldRender(false);
}, 100); // Match fade-out duration
return () => clearTimeout(timeout);
}
}, [isOpen, shouldRender]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (isOpen) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen, onClose]);
if (!shouldRender) return null;
return ReactDOM.createPortal(
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-100 ${
isClosing ? 'animate-fade-out' : 'animate-fade-in'
}`}
>
<div
ref={modalRef}
className={`bg-secondary rounded-lg shadow-md p-6 w-full relative max-h-3/4 overflow-y-auto transition-all duration-100 ${
isClosing ? 'animate-fade-out-scale' : 'animate-fade-in-scale'
}`}
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
>
<button
onClick={onClose}
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
>
🞪
</button>
{children}
</div>
</div>,
document.body
);
}

View file

@ -0,0 +1,124 @@
import { useQuery } from "@tanstack/react-query";
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
import { Modal } from "./Modal";
import { AsyncButton } from "../AsyncButton";
import { useEffect, useState } from "react";
import { Trash } from "lucide-react";
interface Props {
type: string
id: number
open: boolean
setOpen: Function
}
export default function RenameModal({ open, setOpen, type, id }: Props) {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<Alias[]>([])
const { isPending, isError, data, error } = useQuery({
queryKey: [
'aliases',
{
type: type,
id: id
},
],
queryFn: ({ queryKey }) => {
const params = queryKey[1] as { type: string; id: number };
return getAliases(params.type, params.id);
},
});
useEffect(() => {
if (data) {
setDisplayData(data)
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
const handleSetPrimary = (alias: string) => {
setError(undefined)
setLoading(true)
setPrimaryAlias(type, id, alias)
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
const handleNewAlias = () => {
setError(undefined)
if (input === "") {
setError("alias must be provided")
return
}
setLoading(true)
createAlias(type, id, input)
.then(r => {
if (r.ok) {
setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}])
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
const handleDeleteAlias = (alias: string) => {
setError(undefined)
setLoading(true)
deleteAlias(type, id, alias)
.then(r => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.alias != alias))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
return (
<Modal maxW={1000} isOpen={open} onClose={() => setOpen(false)}>
<h2>Alias Manager</h2>
<div className="flex flex-col gap-4">
{displayData.map((v) => (
<div className="flex gap-2">
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div>
<AsyncButton loading={loading} onClick={() => handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary</AsyncButton>
<AsyncButton loading={loading} onClick={() => handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}><Trash size={16} /></AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a new alias"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleNewAlias}>Submit</AsyncButton>
</div>
{err && <p className="error">{err}</p>}
</div>
</Modal>
)
}

View file

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
interface Props {
open: boolean
setOpen: Function
}
export default function SearchModal({ open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const closeSearchModal = () => {
setOpen(false)
setQuery('')
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]);
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,41 @@
import { Modal } from "./Modal"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import AccountPage from "./AccountPage";
import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
import ThemeHelper from "../../routes/ThemeHelper";
import { useAppContext } from "~/providers/AppProvider";
import ApiKeysModal from "./ApiKeysModal";
interface Props {
open: boolean
setOpen: Function
}
export default function SettingsModal({ open, setOpen } : Props) {
const { user } = useAppContext()
const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]"
const contentClasses = "w-full px-10 overflow-y-auto"
return (
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full">
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0">
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
{ user && <TabsTrigger className={triggerClasses} value="API Keys">API Keys</TabsTrigger>}
</TabsList>
<TabsContent value="Account" className={contentClasses}>
<AccountPage />
</TabsContent>
<TabsContent value="Appearance" className={contentClasses}>
<ThemeSwitcher />
</TabsContent>
<TabsContent value="API Keys" className={contentClasses}>
<ApiKeysModal />
</TabsContent>
</Tabs>
</Modal>
)
}

View file

@ -0,0 +1,22 @@
import { ExternalLink, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings";
export default function Sidebar() {
const iconSize = 20;
return (
<div className="z-50 flex flex-col justify-between h-screen border-r-1 border-(--color-bg-tertiary) p-1 py-10 sticky left-0 top-0 bg-(--color-bg)">
<div className="flex flex-col gap-4">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}><Home size={iconSize} /></SidebarItem>
<SidebarSearch size={iconSize} />
</div>
<div className="flex flex-col gap-4">
<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

@ -0,0 +1,48 @@
import React, { useState } from "react";
import Popup from "../Popup";
import { Link } from "react-router";
interface Props {
name: string;
to?: string;
onClick: Function;
children: React.ReactNode;
modal: React.ReactNode;
keyHint?: React.ReactNode;
space?: number
externalLink?: boolean
/* true if the keyhint is an icon and not text */
icon?: boolean
}
export default function SidebarItem({ externalLink, space, keyHint, name, to, children, modal, onClick, icon }: Props) {
const classes = "hover:cursor-pointer hover:bg-(--color-bg-tertiary) transition duration-100 rounded-md p-2 inline-block";
const popupInner = keyHint ? (
<div className="flex items-center gap-2">
<span>{name}</span>
{icon ?
<div>
{keyHint}
</div>
:
<kbd className="px-1 text-sm rounded bg-(--color-bg-tertiary) text-(--color-fg) border border-[var(--color-fg)]">
{keyHint}
</kbd>
}
</div>
) : name;
return (
<>
<Popup position="right" space={space ?? 20} inner={popupInner}>
{to ? (
<Link target={externalLink ? "_blank" : ""} className={classes} to={to}>{children}</Link>
) : (
<a className={classes} onClick={() => onClick()}>{children}</a>
)}
</Popup>
{modal}
</>
);
}

View file

@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import SidebarItem from "./SidebarItem";
import { Search } from "lucide-react";
import SearchModal from "../modals/SearchModal";
interface Props {
size: number
}
export default function SidebarSearch({ size } : Props) {
const [open, setModalOpen] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '/' && !open) {
e.preventDefault();
setModalOpen(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open]);
return (
<SidebarItem
space={26}
onClick={() => setModalOpen(true)}
name="Search"
keyHint="/"
children={<Search size={size}/>} modal={<SearchModal open={open} setOpen={setModalOpen} />}
/>
)
}

View file

@ -0,0 +1,29 @@
import { Settings2 } from "lucide-react";
import SettingsModal from "../modals/SettingsModal";
import SidebarItem from "./SidebarItem";
import { useEffect, useState } from "react";
interface Props {
size: number
}
export default function SidebarSettings({ size }: Props) {
const [open, setOpen] = useState(false);
useEffect(() => {
const handleKeyDown= (e: KeyboardEvent) => {
if (e.key === '\\' && !open) {
e.preventDefault();
setOpen(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open]);
return (
<SidebarItem space={30} keyHint="\" name="Settings" onClick={() => setOpen(true)} modal={<SettingsModal open={open} setOpen={setOpen} />}>
<Settings2 size={size} />
</SidebarItem>
)
}

View file

@ -0,0 +1,20 @@
interface Props {
numItems: number
}
export default function TopListSkeleton({ numItems }: Props) {
return (
<div className="w-[300px]">
{[...Array(numItems)].map(() => (
<div className="flex items-center gap-2 mb-[4px]">
<div className="w-[40px] h-[40px] bg-(--color-bg-tertiary) rounded"></div>
<div>
<div className="h-[14px] w-[150px] bg-(--color-bg-tertiary) rounded"></div>
<div className="h-[12px] w-[60px] bg-(--color-bg-tertiary) rounded"></div>
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,22 @@
import type { Theme } from "~/providers/ThemeProvider";
interface Props {
theme: Theme
setTheme: Function
}
export default function ThemeOption({ theme, setTheme }: Props) {
const capitalizeFirstLetter = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1);
}
return (
<div onClick={() => setTheme(theme.name)} className="rounded-md p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
{capitalizeFirstLetter(theme.name)}
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
</div>
)
}

View file

@ -0,0 +1,36 @@
// ThemeSwitcher.tsx
import { useEffect } from 'react';
import { useTheme } from '../../hooks/useTheme';
import { themes } from '~/providers/ThemeProvider';
import ThemeOption from './ThemeOption';
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved && saved !== theme) {
setTheme(saved);
} else if (!saved) {
localStorage.setItem('theme', theme)
}
}, []);
useEffect(() => {
if (theme) {
localStorage.setItem('theme', theme)
}
}, [theme]);
return (
<>
<h2>Select Theme</h2>
<div className="grid grid-cols-2 items-center gap-2">
{themes.map((t) => (
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
))}
</div>
</>
);
}