feat: v0.0.3

pull/20/head v0.0.3
Gabe Farrell 6 months ago
parent 7ff317756f
commit 3250a4ec3f

@ -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.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](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
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
yarn create astro@latest -- --template starlight
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
[![Deploy with Vercel](https://vercel.com/button)](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 [Starlights 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…
Cancel
Save