mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
feat: v0.0.3
This commit is contained in:
parent
7ff317756f
commit
3250a4ec3f
21 changed files with 322 additions and 374 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -1,14 +1,14 @@
|
||||||
# v0.0.2
|
# v0.0.3
|
||||||
## Features
|
## Features
|
||||||
- Configurable CORS policy via KOITO_CORS_ALLOWED_ORIGINS
|
- Delete listens from the UI
|
||||||
- A baseline mobile UI
|
|
||||||
|
|
||||||
## Enhancements
|
## Enhancements
|
||||||
- The import source is now saved as the client for the imported listen.
|
- Better behaved mobile UI
|
||||||
|
- Search now returns 8 items per category instead of 5
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
- Account update form now works on enter key
|
- Many mobile UI fixes
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
- Non-sensitive query parameters are logged with requests
|
- Refuses a config that changes the MusicBrainz rate limit while using the official MusicBrainz URL
|
||||||
- Koito version number is embedded through tags
|
- Warns when enabling ListenBrainz relay with missing configuration
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
# Welcome to React Router!
|
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🚀 Server-side rendering
|
|
||||||
- ⚡️ Hot Module Replacement (HMR)
|
|
||||||
- 📦 Asset bundling and optimization
|
|
||||||
- 🔄 Data loading and mutations
|
|
||||||
- 🔒 TypeScript by default
|
|
||||||
- 🎉 TailwindCSS for styling
|
|
||||||
- 📖 [React Router docs](https://reactrouter.com/)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Install the dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Start the development server with HMR:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Your application will be available at `http://localhost:5173`.
|
|
||||||
|
|
||||||
## Building for Production
|
|
||||||
|
|
||||||
Create a production build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
To build and run using Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t my-app .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 3000:3000 my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
The containerized application can be deployed to any platform that supports Docker, including:
|
|
||||||
|
|
||||||
- AWS ECS
|
|
||||||
- Google Cloud Run
|
|
||||||
- Azure Container Apps
|
|
||||||
- Digital Ocean App Platform
|
|
||||||
- Fly.io
|
|
||||||
- Railway
|
|
||||||
|
|
||||||
### DIY Deployment
|
|
||||||
|
|
||||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
|
||||||
|
|
||||||
Make sure to deploy the output of `npm run build`
|
|
||||||
|
|
||||||
```
|
|
||||||
├── package.json
|
|
||||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
|
||||||
├── build/
|
|
||||||
│ ├── client/ # Static assets
|
|
||||||
│ └── server/ # Server-side code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built with ❤️ using React Router.
|
|
||||||
|
|
@ -157,6 +157,14 @@ function setPrimaryAlias(type: string, id: number, alias: string): Promise<Respo
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteListen(listen: Listen): Promise<Response> {
|
||||||
|
const ms = new Date(listen.time).getTime()
|
||||||
|
const unix= Math.floor(ms / 1000);
|
||||||
|
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getLastListens,
|
getLastListens,
|
||||||
getTopTracks,
|
getTopTracks,
|
||||||
|
|
@ -182,6 +190,7 @@ export {
|
||||||
createApiKey,
|
createApiKey,
|
||||||
deleteApiKey,
|
deleteApiKey,
|
||||||
updateApiKeyLabel,
|
updateApiKeyLabel,
|
||||||
|
deleteListen,
|
||||||
}
|
}
|
||||||
type Track = {
|
type Track = {
|
||||||
id: number
|
id: number
|
||||||
|
|
|
||||||
|
|
@ -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">
|
return (<div className="flex flex-col items-start">
|
||||||
<h2>Activity</h2>
|
<h2>Activity</h2>
|
||||||
{configurable ? (
|
{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) => (
|
{data.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={new Date(item.start_time).toString()}
|
key={new Date(item.start_time).toString()}
|
||||||
style={{ width: dotSize, height: dotSize }}
|
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||||
>
|
>
|
||||||
<Popup
|
<Popup
|
||||||
position="top"
|
position="top"
|
||||||
space={dotSize}
|
space={12}
|
||||||
extraClasses="left-2"
|
extraClasses="left-2"
|
||||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
width: dotSize,
|
|
||||||
height: dotSize,
|
|
||||||
background:
|
background:
|
||||||
item.listens > 0
|
item.listens > 0
|
||||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
||||||
: 'var(--color-bg-secondary)',
|
: '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>
|
></div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import { useState } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { timeSince } from "~/utils/utils"
|
import { timeSince } from "~/utils/utils"
|
||||||
import ArtistLinks from "./ArtistLinks"
|
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"
|
import { Link } from "react-router"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -11,47 +12,95 @@ interface Props {
|
||||||
trackId?: number
|
trackId?: number
|
||||||
hideArtists?: boolean
|
hideArtists?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LastPlays(props: Props) {
|
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
export default function LastPlays(props: Props) {
|
||||||
queryKey: ['last-listens', {limit: props.limit, period: 'all_time', artist_id: props.artistId, album_id: props.albumId, track_id: props.trackId}],
|
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),
|
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) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="w-[400px] sm:w-[500px]">
|
<div className="w-[300px] sm:w-[500px]">
|
||||||
<h2>Last Played</h2>
|
<h2>Last Played</h2>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isError) {
|
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 = ''
|
let params = ''
|
||||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||||
params += props.trackId ? `&track_id=${props.trackId}` : ''
|
params += props.trackId ? `&track_id=${props.trackId}` : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="text-sm sm:text-[16px]">
|
||||||
<h2 className="hover:underline"><Link to={`/listens?period=all_time${params}`}>Last Played</Link></h2>
|
<h2 className="hover:underline">
|
||||||
<table>
|
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
||||||
|
</h2>
|
||||||
|
<table className="-ml-4">
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((item) => (
|
{listens.map((item) => (
|
||||||
<tr key={`last_listen_${item.time}`}>
|
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
||||||
<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="w-[1px] pr-2 align-middle">
|
||||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
<button
|
||||||
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
|
onClick={() => handleDelete(item)}
|
||||||
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||||
</td>
|
aria-label="Delete"
|
||||||
</tr>
|
>
|
||||||
))}
|
×
|
||||||
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default function PeriodSelector({ setter, current, disableCache = false }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 grow-0 text-sm sm:text-[16px]">
|
||||||
<p>Showing stats for:</p>
|
<p>Showing stats for:</p>
|
||||||
{periods.map((p, i) => (
|
{periods.map((p, i) => (
|
||||||
<div key={`period_setter_${p}`}>
|
<div key={`period_setter_${p}`}>
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,64 @@
|
||||||
import React, { type PropsWithChildren, useState } from 'react';
|
import React, { type PropsWithChildren, useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
inner: React.ReactNode
|
inner: React.ReactNode
|
||||||
position: string
|
position: string
|
||||||
space: number
|
space: number
|
||||||
extraClasses?: string
|
extraClasses?: string
|
||||||
hint?: string
|
hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren<Props>) {
|
export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren<Props>) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [showPopup, setShowPopup] = useState(true);
|
||||||
|
|
||||||
let positionClasses
|
useEffect(() => {
|
||||||
let spaceCSS = {}
|
const mediaQuery = window.matchMedia('(min-width: 640px)');
|
||||||
if (position == "top") {
|
|
||||||
positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
} else if (position == "right") {
|
setShowPopup(e.matches);
|
||||||
positionClasses = `bottom-1 -translate-x-1/2`
|
};
|
||||||
spaceCSS = {left: 70 + space}
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
onMouseEnter={() => setIsVisible(true)}
|
onMouseEnter={() => setIsVisible(true)}
|
||||||
onMouseLeave={() => setIsVisible(false)}
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<div
|
{showPopup && (
|
||||||
className={`
|
<div
|
||||||
absolute
|
className={`
|
||||||
${positionClasses}
|
absolute
|
||||||
${extraClasses ? extraClasses : ''}
|
${positionClasses}
|
||||||
bg-(--color-bg) color-fg border-1 border-(--color-bg-tertiary)
|
${extraClasses ?? ''}
|
||||||
px-3 py-2 rounded-lg
|
bg-(--color-bg) color-fg border-1 border-(--color-bg-tertiary)
|
||||||
transition-opacity duration-100
|
px-3 py-2 rounded-lg
|
||||||
${isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
transition-opacity duration-100
|
||||||
z-50 text-center
|
${isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||||
flex
|
z-50 text-center
|
||||||
`}
|
flex
|
||||||
style={spaceCSS}
|
`}
|
||||||
>
|
style={spaceCSS}
|
||||||
{inner}
|
>
|
||||||
</div>
|
{inner}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ type Item = Album | Track | Artist;
|
||||||
interface Props<T extends Item> {
|
interface Props<T extends Item> {
|
||||||
data: PaginatedResponse<T>
|
data: PaginatedResponse<T>
|
||||||
separators?: ConstrainBoolean
|
separators?: ConstrainBoolean
|
||||||
width?: number
|
|
||||||
type: "album" | "track" | "artist";
|
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 (
|
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) => {
|
{data.items.map((item, index) => {
|
||||||
const key = `${type}-${item.id}`;
|
const key = `${type}-${item.id}`;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,29 +7,48 @@ export default function Sidebar() {
|
||||||
const iconSize = 20;
|
const iconSize = 20;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-hidden w-full sm:w-auto">
|
<div className="
|
||||||
<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)">
|
z-50
|
||||||
<div className="flex gap-4 sm:flex-col">
|
flex
|
||||||
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
|
sm:flex-col
|
||||||
<Home size={iconSize} />
|
justify-between
|
||||||
</SidebarItem>
|
sm:fixed
|
||||||
<SidebarSearch size={iconSize} />
|
sm:top-0
|
||||||
</div>
|
sm:left-0
|
||||||
<div className="flex gap-4 sm:flex-col">
|
sm:h-screen
|
||||||
<SidebarItem
|
h-auto
|
||||||
icon
|
sm:w-auto
|
||||||
keyHint={<ExternalLink size={14} />}
|
w-full
|
||||||
space={22}
|
border-b
|
||||||
externalLink
|
sm:border-b-0
|
||||||
to="https://koito.io"
|
sm:border-r
|
||||||
name="About"
|
border-(--color-bg-tertiary)
|
||||||
onClick={() => {}}
|
pt-2
|
||||||
modal={<></>}
|
sm:py-10
|
||||||
>
|
sm:px-1
|
||||||
<Info size={iconSize} />
|
px-4
|
||||||
</SidebarItem>
|
bg-(--color-bg)
|
||||||
<SidebarSettings size={iconSize} />
|
">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default function App() {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="flex-col flex sm:flex-row">
|
<div className="flex-col flex sm:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-col items-center mx-auto w-full">
|
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default function AlbumChart() {
|
||||||
<TopItemList
|
<TopItemList
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
width={600}
|
className="w-[400px] sm:w-[600px]"
|
||||||
type="album"
|
type="album"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default function Artist() {
|
||||||
<TopItemList
|
<TopItemList
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
width={600}
|
className="w-[400px] sm:w-[600px]"
|
||||||
type="artist"
|
type="artist"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -212,43 +212,45 @@ export default function ChartLayout<T>({
|
||||||
<title>{pgTitle}</title>
|
<title>{pgTitle}</title>
|
||||||
<meta property="og:title" content={pgTitle} />
|
<meta property="og:title" content={pgTitle} />
|
||||||
<meta name="description" content={pgTitle} />
|
<meta name="description" content={pgTitle} />
|
||||||
<div className="w-17/20 mx-auto pt-12">
|
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
||||||
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
|
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
|
||||||
<select
|
<div className="flex gap-5">
|
||||||
value={year ?? ""}
|
<select
|
||||||
onChange={(e) => handleSetYear(e.target.value)}
|
value={year ?? ""}
|
||||||
className="px-2 py-1 rounded border border-gray-400"
|
onChange={(e) => handleSetYear(e.target.value)}
|
||||||
>
|
className="px-2 py-1 rounded border border-gray-400"
|
||||||
<option value="">Year</option>
|
>
|
||||||
{yearOptions.map((y) => (
|
<option value="">Year</option>
|
||||||
<option key={y} value={y}>{y}</option>
|
{yearOptions.map((y) => (
|
||||||
))}
|
<option key={y} value={y}>{y}</option>
|
||||||
</select>
|
))}
|
||||||
<select
|
</select>
|
||||||
value={month ?? ""}
|
<select
|
||||||
onChange={(e) => handleSetMonth(e.target.value)}
|
value={month ?? ""}
|
||||||
className="px-2 py-1 rounded border border-gray-400"
|
onChange={(e) => handleSetMonth(e.target.value)}
|
||||||
>
|
className="px-2 py-1 rounded border border-gray-400"
|
||||||
<option value="">Month</option>
|
>
|
||||||
{monthOptions.map((m) => (
|
<option value="">Month</option>
|
||||||
<option key={m} value={m}>{m}</option>
|
{monthOptions.map((m) => (
|
||||||
))}
|
<option key={m} value={m}>{m}</option>
|
||||||
</select>
|
))}
|
||||||
<select
|
</select>
|
||||||
value={week ?? ""}
|
<select
|
||||||
onChange={(e) => handleSetWeek(e.target.value)}
|
value={week ?? ""}
|
||||||
className="px-2 py-1 rounded border border-gray-400"
|
onChange={(e) => handleSetWeek(e.target.value)}
|
||||||
>
|
className="px-2 py-1 rounded border border-gray-400"
|
||||||
<option value="">Week</option>
|
>
|
||||||
{weekOptions.map((w) => (
|
<option value="">Week</option>
|
||||||
<option key={w} value={w}>{w}</option>
|
{weekOptions.map((w) => (
|
||||||
))}
|
<option key={w} value={w}>{w}</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
||||||
<div className="mt-20 flex mx-auto justify-between">
|
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
|
||||||
{render({
|
{render({
|
||||||
data,
|
data,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,104 @@
|
||||||
import ChartLayout from "./ChartLayout";
|
import ChartLayout from "./ChartLayout";
|
||||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
import { type Album, type Listen, type PaginatedResponse } from "api/api";
|
import { deleteListen, type Listen, type PaginatedResponse } from "api/api";
|
||||||
import { timeSince } from "~/utils/utils";
|
import { timeSince } from "~/utils/utils";
|
||||||
import ArtistLinks from "~/components/ArtistLinks";
|
import ArtistLinks from "~/components/ArtistLinks";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set('page', page)
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/listens?${url.searchParams.toString()}`
|
`/apis/web/v1/listens?${url.searchParams.toString()}`
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Response("Failed to load top tracks", { status: 500 });
|
throw new Response("Failed to load top tracks", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const listens: PaginatedResponse<Album> = await res.json();
|
const listens: PaginatedResponse<Listen> = await res.json();
|
||||||
return { listens };
|
return { listens };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Listens() {
|
export default function Listens() {
|
||||||
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Listen[] | null>(null)
|
||||||
|
|
||||||
|
const handleDelete = async (listen: Listen) => {
|
||||||
|
if (!initialData) return
|
||||||
|
try {
|
||||||
|
const res = await deleteListen(listen)
|
||||||
|
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
||||||
|
setItems((prev) => (prev ?? initialData.items).filter((i) => i.time !== listen.time))
|
||||||
|
} else {
|
||||||
|
console.error("Failed to delete listen:", res.status)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting listen:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listens = items ?? initialData.items
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
title="Last Played"
|
title="Last Played"
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
endpoint="listens"
|
endpoint="listens"
|
||||||
render={({ data, page, onNext, onPrev }) => (
|
render={({ data, page, onNext, onPrev }) => (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5 text-sm md:text-[16px]">
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{data.items.map((item) => (
|
|
||||||
<tr key={`last_listen_${item.time}`}>
|
|
||||||
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
|
|
||||||
<td className="text-ellipsis overflow-hidden w-[700px]">
|
|
||||||
<ArtistLinks artists={item.track.artists} />{' - '}
|
|
||||||
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="flex gap-15 mx-auto">
|
|
||||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
|
||||||
Prev
|
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<table className="-ml-4">
|
||||||
)}
|
<tbody>
|
||||||
/>
|
{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]">
|
||||||
|
<ArtistLinks artists={item.track.artists} /> –{' '}
|
||||||
|
<Link
|
||||||
|
className="hover:text-[--color-fg-secondary]"
|
||||||
|
to={`/track/${item.track.id}`}
|
||||||
|
>
|
||||||
|
{item.track.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="flex gap-15 mx-auto">
|
||||||
|
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default function TrackChart() {
|
||||||
<TopItemList
|
<TopItemList
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
width={600}
|
className="w-[400px] sm:w-[600px]"
|
||||||
type="track"
|
type="track"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,12 @@ export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-grow justify-center pb-4">
|
<main className="flex flex-grow justify-center pb-4">
|
||||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
|
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
|
||||||
<div className="flex gap-20">
|
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
|
||||||
<AllTimeStats />
|
<AllTimeStats />
|
||||||
<ActivityGrid />
|
<ActivityGrid />
|
||||||
</div>
|
</div>
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-between mx-5 gap-5">
|
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
|
||||||
<TopArtists period={period} limit={homeItems} />
|
<TopArtists period={period} limit={homeItems} />
|
||||||
<TopAlbums period={period} limit={homeItems} />
|
<TopAlbums period={period} limit={homeItems} />
|
||||||
<TopTracks period={period} limit={homeItems} />
|
<TopTracks period={period} limit={homeItems} />
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { isRouteErrorResponse, Outlet } from "react-router";
|
|
||||||
import Footer from "~/components/Footer";
|
|
||||||
import type { Route } from "../+types/root";
|
|
||||||
|
|
||||||
export default function Root() {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center mx-auto w-full">
|
|
||||||
<Outlet />
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|
||||||
let message = "Oops!";
|
|
||||||
let details = "An unexpected error occurred.";
|
|
||||||
let stack: string | undefined;
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
|
||||||
message = error.status === 404 ? "404" : "Error";
|
|
||||||
details =
|
|
||||||
error.status === 404
|
|
||||||
? "The requested page could not be found."
|
|
||||||
: error.statusText || details;
|
|
||||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
|
||||||
details = error.message;
|
|
||||||
stack = error.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="pt-16 p-4 container mx-auto scroll-smooth">
|
|
||||||
<h1>{message}</h1>
|
|
||||||
<p>{details}</p>
|
|
||||||
{stack && (
|
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# Starlight Starter Kit: Basics
|
|
||||||
|
|
||||||
[](https://starlight.astro.build)
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn create astro@latest -- --template starlight
|
|
||||||
```
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
|
|
||||||
[](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
|
|
||||||
[](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)
|
|
||||||
|
|
||||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
|
||||||
|
|
||||||
## 🚀 Project Structure
|
|
||||||
|
|
||||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── public/
|
|
||||||
├── src/
|
|
||||||
│ ├── assets/
|
|
||||||
│ ├── content/
|
|
||||||
│ │ ├── docs/
|
|
||||||
│ └── content.config.ts
|
|
||||||
├── astro.config.mjs
|
|
||||||
├── package.json
|
|
||||||
└── tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
|
||||||
|
|
||||||
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
|
||||||
|
|
||||||
Static assets, like favicons, can be placed in the `public/` directory.
|
|
||||||
|
|
||||||
## 🧞 Commands
|
|
||||||
|
|
||||||
All commands are run from the root of the project, from a terminal:
|
|
||||||
|
|
||||||
| Command | Action |
|
|
||||||
| :------------------------ | :----------------------------------------------- |
|
|
||||||
| `yarn install` | Installs dependencies |
|
|
||||||
| `yarn dev` | Starts local dev server at `localhost:4321` |
|
|
||||||
| `yarn build` | Build your production site to `./dist/` |
|
|
||||||
| `yarn preview` | Preview your build locally, before deploying |
|
|
||||||
| `yarn astro ...` | Run CLI commands like `astro add`, `astro check` |
|
|
||||||
| `yarn astro -- --help` | Get help using the Astro CLI |
|
|
||||||
|
|
||||||
## 👀 Want to learn more?
|
|
||||||
|
|
||||||
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
|
||||||
|
|
@ -156,6 +156,10 @@ func Run(
|
||||||
l.Info().Msgf("Engine: CORS policy: Allowing origins: %v", cfg.AllowedOrigins())
|
l.Info().Msgf("Engine: CORS policy: Allowing origins: %v", cfg.AllowedOrigins())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.LbzRelayEnabled() && (cfg.LbzRelayUrl() == "" || cfg.LbzRelayToken() == "") {
|
||||||
|
l.Warn().Msg("You have enabled ListenBrainz relay, but either the URL or token is missing. Double check your configuration to make sure it is correct!")
|
||||||
|
}
|
||||||
|
|
||||||
l.Debug().Msg("Engine: Setting up HTTP server")
|
l.Debug().Msg("Engine: Setting up HTTP server")
|
||||||
var ready atomic.Bool
|
var ready atomic.Bool
|
||||||
mux := chi.NewRouter()
|
mux := chi.NewRouter()
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,11 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
||||||
if cfg.musicBrainzUrl == "" {
|
if cfg.musicBrainzUrl == "" {
|
||||||
cfg.musicBrainzUrl = defaultMusicBrainzUrl
|
cfg.musicBrainzUrl = defaultMusicBrainzUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.musicBrainzUrl == defaultMusicBrainzUrl && cfg.musicBrainzRateLimit != 1 {
|
||||||
|
return nil, fmt.Errorf("loadConfig: invalid configuration: %s cannot be altered when %s is default", MUSICBRAINZ_RATE_LIMIT_ENV, MUSICBRAINZ_URL_ENV)
|
||||||
|
}
|
||||||
|
|
||||||
if parseBool(getenv(ENABLE_LBZ_RELAY_ENV)) {
|
if parseBool(getenv(ENABLE_LBZ_RELAY_ENV)) {
|
||||||
cfg.lbzRelayEnabled = true
|
cfg.lbzRelayEnabled = true
|
||||||
cfg.lbzRelayToken = getenv(LBZ_RELAY_TOKEN_ENV)
|
cfg.lbzRelayToken = getenv(LBZ_RELAY_TOKEN_ENV)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const searchItemLimit = 5
|
const searchItemLimit = 8
|
||||||
const substringSearchLength = 6
|
const substringSearchLength = 6
|
||||||
|
|
||||||
func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) {
|
func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue