From 3250a4ec3f9e82bd673af7207ff628b38760a20a Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 15 Jun 2025 00:12:21 -0400 Subject: [PATCH] feat: v0.0.3 --- CHANGELOG.md | 14 +-- client/README.md | 87 -------------- client/api/api.ts | 9 ++ client/app/components/ActivityGrid.tsx | 18 +-- client/app/components/LastPlays.tsx | 89 ++++++++++---- client/app/components/PeriodSelector.tsx | 2 +- client/app/components/Popup.tsx | 82 +++++++------ client/app/components/TopItemList.tsx | 6 +- client/app/components/sidebar/Sidebar.tsx | 65 +++++++---- client/app/root.tsx | 2 +- client/app/routes/Charts/AlbumChart.tsx | 2 +- client/app/routes/Charts/ArtistChart.tsx | 2 +- client/app/routes/Charts/ChartLayout.tsx | 68 +++++------ client/app/routes/Charts/Listens.tsx | 136 ++++++++++++++-------- client/app/routes/Charts/TrackChart.tsx | 2 +- client/app/routes/Home.tsx | 4 +- client/app/routes/Root.tsx | 43 ------- docs/README.md | 54 --------- engine/engine.go | 4 + internal/cfg/cfg.go | 5 + internal/db/psql/search.go | 2 +- 21 files changed, 322 insertions(+), 374 deletions(-) delete mode 100644 client/README.md delete mode 100644 client/app/routes/Root.tsx delete mode 100644 docs/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eee375..1a3357e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,14 @@ -# v0.0.2 +# v0.0.3 ## Features -- Configurable CORS policy via KOITO_CORS_ALLOWED_ORIGINS -- A baseline mobile UI +- Delete listens from the UI ## 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 -- Account update form now works on enter key +- Many mobile UI fixes ## Updates -- Non-sensitive query parameters are logged with requests -- Koito version number is embedded through tags \ No newline at end of file +- Refuses a config that changes the MusicBrainz rate limit while using the official MusicBrainz URL +- Warns when enabling ListenBrainz relay with missing configuration \ No newline at end of file diff --git a/client/README.md b/client/README.md deleted file mode 100644 index 5c4780a..0000000 --- a/client/README.md +++ /dev/null @@ -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. diff --git a/client/api/api.ts b/client/api/api.ts index 40f4d6f..ec79aa0 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -157,6 +157,14 @@ function setPrimaryAlias(type: string, id: number, alias: string): Promise { + 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 { getLastListens, getTopTracks, @@ -182,6 +190,7 @@ export { createApiKey, deleteApiKey, updateApiKeyLabel, + deleteListen, } type Track = { id: number diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index a410c39..818d6e3 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -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 (

Activity

{configurable ? ( @@ -162,29 +154,27 @@ export default function ActivityGrid({ ) : ( '' )} -
+
{data.map((item) => (
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)'}`} >
diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index b1eda5e..2bc1cc3 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -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(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 ( -
+

Last Played

Loading...

) } if (isError) { - return

Error:{error.message}

+ return

Error: {error.message}

} + 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 ( -
-

Last Played

- +
+

+ Last Played +

+
- {data.items.map((item) => ( - - - - - ))} + {listens.map((item) => ( + + + + + + ))}
{timeSince(new Date(item.time))} - {props.hideArtists ? <> : <> - } - {item.track.title} -
+ + + {timeSince(new Date(item.time))} + + {props.hideArtists ? null : ( + <> + –{' '} + + )} + + {item.track.title} + +
) -} \ No newline at end of file +} diff --git a/client/app/components/PeriodSelector.tsx b/client/app/components/PeriodSelector.tsx index 91bad9a..3393dc7 100644 --- a/client/app/components/PeriodSelector.tsx +++ b/client/app/components/PeriodSelector.tsx @@ -31,7 +31,7 @@ export default function PeriodSelector({ setter, current, disableCache = false } }, []); return ( -
+

Showing stats for:

{periods.map((p, i) => (
diff --git a/client/app/components/Popup.tsx b/client/app/components/Popup.tsx index 3c73cb5..a032e4e 100644 --- a/client/app/components/Popup.tsx +++ b/client/app/components/Popup.tsx @@ -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) { 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 (
setIsVisible(true)} - onMouseLeave={() => setIsVisible(false)} + className="relative" + onMouseEnter={() => setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} > - {children} -
- {inner} -
+ {children} + {showPopup && ( +
+ {inner} +
+ )}
); } diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 7f68c9e..22d307c 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -7,14 +7,14 @@ type Item = Album | Track | Artist; interface Props { data: PaginatedResponse separators?: ConstrainBoolean - width?: number type: "album" | "track" | "artist"; + className?: string, } -export default function TopItemList({ data, separators, type, width }: Props) { +export default function TopItemList({ data, separators, type, className }: Props) { return ( -
+
{data.items.map((item, index) => { const key = `${type}-${item.id}`; return ( diff --git a/client/app/components/sidebar/Sidebar.tsx b/client/app/components/sidebar/Sidebar.tsx index 11ff824..1a42e67 100644 --- a/client/app/components/sidebar/Sidebar.tsx +++ b/client/app/components/sidebar/Sidebar.tsx @@ -7,29 +7,48 @@ export default function Sidebar() { const iconSize = 20; return ( -
-
-
- {}} modal={<>}> - - - -
-
- } - space={22} - externalLink - to="https://koito.io" - name="About" - onClick={() => {}} - modal={<>} - > - - - -
+
+
+ {}} modal={<>}> + + + +
+
+ } + space={22} + externalLink + to="https://koito.io" + name="About" + onClick={() => {}} + modal={<>} + > + + +
); diff --git a/client/app/root.tsx b/client/app/root.tsx index e7e2415..b210088 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -67,7 +67,7 @@ export default function App() {
-
+
diff --git a/client/app/routes/Charts/AlbumChart.tsx b/client/app/routes/Charts/AlbumChart.tsx index 8e68186..ba323bf 100644 --- a/client/app/routes/Charts/AlbumChart.tsx +++ b/client/app/routes/Charts/AlbumChart.tsx @@ -40,7 +40,7 @@ export default function AlbumChart() {
diff --git a/client/app/routes/Charts/ArtistChart.tsx b/client/app/routes/Charts/ArtistChart.tsx index bc3be16..ec3dfd8 100644 --- a/client/app/routes/Charts/ArtistChart.tsx +++ b/client/app/routes/Charts/ArtistChart.tsx @@ -40,7 +40,7 @@ export default function Artist() {
diff --git a/client/app/routes/Charts/ChartLayout.tsx b/client/app/routes/Charts/ChartLayout.tsx index 6690cd3..ee5ef59 100644 --- a/client/app/routes/Charts/ChartLayout.tsx +++ b/client/app/routes/Charts/ChartLayout.tsx @@ -212,43 +212,45 @@ export default function ChartLayout({ {pgTitle} -
+

{title}

-
+
- - - +
+ + + +

{getDateRange()}

-
+
{render({ data, page: currentPage, diff --git a/client/app/routes/Charts/Listens.tsx b/client/app/routes/Charts/Listens.tsx index 6f5efdb..979e1c1 100644 --- a/client/app/routes/Charts/Listens.tsx +++ b/client/app/routes/Charts/Listens.tsx @@ -1,66 +1,104 @@ import ChartLayout from "./ChartLayout"; 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 ArtistLinks from "~/components/ArtistLinks"; +import { useState } from "react"; export async function clientLoader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url); - const page = url.searchParams.get("page") || "0"; - url.searchParams.set('page', page) + const url = new URL(request.url); + const page = url.searchParams.get("page") || "0"; + url.searchParams.set('page', page) - const res = await fetch( - `/apis/web/v1/listens?${url.searchParams.toString()}` - ); - if (!res.ok) { - throw new Response("Failed to load top tracks", { status: 500 }); - } + const res = await fetch( + `/apis/web/v1/listens?${url.searchParams.toString()}` + ); + if (!res.ok) { + throw new Response("Failed to load top tracks", { status: 500 }); + } - const listens: PaginatedResponse = await res.json(); - return { listens }; + const listens: PaginatedResponse = await res.json(); + return { listens }; } export default function Listens() { - const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>(); + const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>(); + const [items, setItems] = useState(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 ( - ( -
-
- - -
- - - {data.items.map((item) => ( - - - - - ))} - -
{timeSince(new Date(item.time))} - {' - '} - {item.track.title} -
-
- -
-
- )} - /> - ); +
+ + + {listens.map((item) => ( + + + + + + ))} + +
+ + + {timeSince(new Date(item.time))} + + –{' '} + + {item.track.title} + +
+
+ + +
+
+ )} + /> + ); } diff --git a/client/app/routes/Charts/TrackChart.tsx b/client/app/routes/Charts/TrackChart.tsx index 23c1531..eeeb145 100644 --- a/client/app/routes/Charts/TrackChart.tsx +++ b/client/app/routes/Charts/TrackChart.tsx @@ -40,7 +40,7 @@ export default function TrackChart() {
diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx index 04359a2..8af882b 100644 --- a/client/app/routes/Home.tsx +++ b/client/app/routes/Home.tsx @@ -24,12 +24,12 @@ export default function Home() { return (
-
+
-
+
diff --git a/client/app/routes/Root.tsx b/client/app/routes/Root.tsx deleted file mode 100644 index 9672dd8..0000000 --- a/client/app/routes/Root.tsx +++ /dev/null @@ -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 ( -
- -
-
- ) -} - -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 ( -
-

{message}

-

{details}

- {stack && ( -
-            {stack}
-          
- )} -
- ); - } - \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 824799f..0000000 --- a/docs/README.md +++ /dev/null @@ -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 [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). diff --git a/engine/engine.go b/engine/engine.go index fdaab5c..2cbcb28 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -156,6 +156,10 @@ func Run( 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") var ready atomic.Bool mux := chi.NewRouter() diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 0d65f7a..ad15869 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -110,6 +110,11 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { if cfg.musicBrainzUrl == "" { 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)) { cfg.lbzRelayEnabled = true cfg.lbzRelayToken = getenv(LBZ_RELAY_TOKEN_ENV) diff --git a/internal/db/psql/search.go b/internal/db/psql/search.go index 69250fb..675134b 100644 --- a/internal/db/psql/search.go +++ b/internal/db/psql/search.go @@ -9,7 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const searchItemLimit = 5 +const searchItemLimit = 8 const substringSearchLength = 6 func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) {