feat: v0.0.3

This commit is contained in:
Gabe Farrell 2025-06-15 00:12:21 -04:00
parent 7ff317756f
commit 3250a4ec3f
21 changed files with 322 additions and 374 deletions

View file

@ -142,14 +142,6 @@ export default function ActivityGrid({
}
}
const mobileDotSize = 10
const normalDotSize = 12
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
let dotSize = vw > 768 ? normalDotSize : mobileDotSize
return (<div className="flex flex-col items-start">
<h2>Activity</h2>
{configurable ? (
@ -162,29 +154,27 @@ export default function ActivityGrid({
) : (
''
)}
<div className="flex flex-row flex-wrap w-[94px] md:w-auto md:grid md:grid-flow-col md:grid-cols-7 md:grid-rows-7 gap-[4px] md:gap-[5px]">
<div className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px]">
{data.map((item) => (
<div
key={new Date(item.start_time).toString()}
style={{ width: dotSize, height: dotSize }}
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
>
<Popup
position="top"
space={dotSize}
space={12}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
>
<div
style={{
display: 'inline-block',
width: dotSize,
height: dotSize,
background:
item.listens > 0
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
: 'var(--color-bg-secondary)',
}}
className={`rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
></div>
</Popup>
</div>

View file

@ -1,7 +1,8 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { timeSince } from "~/utils/utils"
import ArtistLinks from "./ArtistLinks"
import { getLastListens, type getItemsArgs } from "api/api"
import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api"
import { Link } from "react-router"
interface Props {
@ -11,47 +12,95 @@ interface Props {
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}],
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),
})
const [items, setItems] = useState<Listen[] | null>(null)
const handleDelete = async (listen: Listen) => {
if (!data) return
try {
const res = await deleteListen(listen)
if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
} else {
console.error("Failed to delete listen:", res.status)
}
} catch (err) {
console.error("Error deleting listen:", err)
}
}
if (isPending) {
return (
<div className="w-[400px] sm:w-[500px]">
<div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
return <p className="error">Error: {error.message}</p>
}
const listens = items ?? data.items
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>
<div className="text-sm sm:text-[16px]">
<h2 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2>
<table className="-ml-4">
<tbody>
{data.items.map((item) => (
<tr key={`last_listen_${item.time}`}>
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{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>
))}
{listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[1px] pr-2 align-middle">
<button
onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete"
>
×
</button>
</td>
<td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()}
>
{timeSince(new Date(item.time))}
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : (
<>
<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

@ -31,7 +31,7 @@ export default function PeriodSelector({ setter, current, disableCache = false }
}, []);
return (
<div className="flex gap-2">
<div className="flex gap-2 grow-0 text-sm sm:text-[16px]">
<p>Showing stats for:</p>
{periods.map((p, i) => (
<div key={`period_setter_${p}`}>

View file

@ -1,48 +1,64 @@
import React, { type PropsWithChildren, useState } from 'react';
import React, { type PropsWithChildren, useEffect, useState } from 'react';
interface Props {
inner: React.ReactNode
position: string
space: number
extraClasses?: string
hint?: string
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);
const [showPopup, setShowPopup] = useState(true);
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}
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 640px)');
const handleChange = (e: MediaQueryListEvent) => {
setShowPopup(e.matches);
};
setShowPopup(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
let positionClasses = '';
let spaceCSS: React.CSSProperties = {};
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)}
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>
{children}
{showPopup && (
<div
className={`
absolute
${positionClasses}
${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

@ -7,14 +7,14 @@ type Item = Album | Track | Artist;
interface Props<T extends Item> {
data: PaginatedResponse<T>
separators?: ConstrainBoolean
width?: number
type: "album" | "track" | "artist";
className?: string,
}
export default function TopItemList<T extends Item>({ data, separators, type, width }: Props<T>) {
export default function TopItemList<T extends Item>({ data, separators, type, className }: Props<T>) {
return (
<div className="flex flex-col gap-1" style={{width: width ?? 300}}>
<div className={`flex flex-col gap-1 ${className} min-w-[300px]`}>
{data.items.map((item, index) => {
const key = `${type}-${item.id}`;
return (

View file

@ -7,29 +7,48 @@ export default function Sidebar() {
const iconSize = 20;
return (
<div className="overflow-x-hidden w-full sm:w-auto">
<div className="z-50 flex sm:flex-col justify-between sm:h-screen h-auto sm:w-auto w-full border-b sm:border-b-0 sm:border-r border-(--color-bg-tertiary) pt-2 sm:py-10 sm:px-1 px-4 sticky top-0 sm:left-0 bg-(--color-bg)">
<div className="flex gap-4 sm:flex-col">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
</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 className="
z-50
flex
sm:flex-col
justify-between
sm:fixed
sm:top-0
sm:left-0
sm:h-screen
h-auto
sm:w-auto
w-full
border-b
sm:border-b-0
sm:border-r
border-(--color-bg-tertiary)
pt-2
sm:py-10
sm:px-1
px-4
bg-(--color-bg)
">
<div className="flex gap-4 sm:flex-col">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
</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>
);