chore: initial public commit

pull/20/head
Gabe Farrell 6 months ago
commit fc9054b78c

@ -0,0 +1,56 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Publish Docker image
on:
release:
types: [published]
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: gabehf/koito
- name: Build and push Docker image
id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: index.docker.io/gabehf/koito
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

@ -0,0 +1,14 @@
# Koito
## Dependencies
### libvips
```
sudo apt install libvips
```
## Tools
- goose
- sqlc
## Start dev env
```
make postgres.run
make api.debug
```

@ -0,0 +1,43 @@
FROM node AS frontend
WORKDIR /client
COPY ./client/package.json ./client/yarn.lock ./
RUN yarn install
COPY ./client .
ENV BUILD_TARGET=docker
RUN yarn run build
FROM golang:1.23 AS backend
WORKDIR /app
RUN apt-get update && \
apt-get install -y libvips-dev pkg-config && \
rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o app ./cmd/api
FROM debian:bookworm-slim AS final
WORKDIR /app
RUN apt-get update && \
apt-get install -y libvips42 && \
rm -rf /var/lib/apt/lists/*
COPY --from=backend /app/app ./app
COPY --from=frontend /client/build ./client/build
COPY ./client/public ./client/public
COPY ./assets ./assets
COPY ./db ./db
EXPOSE 4110
ENTRYPOINT ["./app"]

@ -0,0 +1,24 @@
# Must-haves
- scrobble with listenbrainz api
- import from maloja
- import from spotify
- natively host on subdirectory
- good mobile ui
- replace artist/album/track art from ui
- fetch artist/album/track art from lastfm and/or spotify
- edit artist/album/track name in ui (auto-merge colliding names)
- built with being exposed to the internet in mind
- track artist aliases
- hold a cache of musicbrainz responses, with a button to clear it out
# Want
- use musibrainz ids from scrobble to automatically merge plays
- use musicbrainz ids from scrobble to automatically add AKA fields (美波 aka Minami) and sort name
- export playlist m3u8 files based on charts
- track device/player listened from
- webhooks on certain events (every scrobble, listening milestones, etc.)
- Time of day, day of week, etc. graphs
- "pause" mode that temporarily disables recieving scrobbles until turned back on
# Stretch
- "Listening Digest" wrapped-esque digestable recap of the last week/month/year
# Could explore
- Federation/ActivityPub

@ -0,0 +1,48 @@
.PHONY: all test clean client
db.up:
GOOSE_MIGRATION_DIR=db/migrations GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://postgres:secret@localhost:5432 goose up
db.down:
GOOSE_MIGRATION_DIR=db/migrations GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://postgres:secret@localhost:5432 goose down
db.reset:
GOOSE_MIGRATION_DIR=db/migrations GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://postgres:secret@localhost:5432 goose down-to 0
db.schemadump:
docker run --rm --network=host --env PGPASSWORD=secret -v "./db:/tmp/dump" \
postgres pg_dump \
--schema-only \
--host=192.168.0.153 \
--port=5432 \
--username=postgres \
-v --dbname="koitodb" -f "/tmp/dump/schema.sql"
postgres.run:
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres
postgres.start:
docker start koito-db
postgres.stop:
docker stop koito-db
postgres.rm:
docker rm bamsort-db
api.debug:
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@192.168.0.153:5432/koitodb?sslmode=disable go run cmd/api/main.go
api.test:
go test ./... -timeout 60s
client.dev:
cd client && yarn run dev
docs.dev:
cd docs && yarn dev
client.build:
cd client && yarn run build
test: api.test

@ -0,0 +1 @@
# Koito

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

6
client/.gitignore vendored

@ -0,0 +1,6 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/

@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

@ -0,0 +1,87 @@
# 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.

@ -0,0 +1,278 @@
interface getItemsArgs {
limit: number,
period: string,
page: number,
artist_id?: number,
album_id?: number,
track_id?: number
}
interface getActivityArgs {
step: string
range: number
month: number
year: number
artist_id: number
album_id: number
track_id: number
}
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>)
}
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
if (args.artist_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
} else if (args.album_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
} else {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
}
}
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
} else {
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
}
}
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
}
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>)
}
function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
}
function search(q: string): Promise<SearchResponse> {
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
}
function imageUrl(id: string, size: string) {
if (!id) {
id = 'default'
}
return `/images/${size}/${id}`
}
function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, {
method: "POST",
body: form,
})
}
function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST",
})
}
function mergeAlbums(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, {
method: "POST",
})
}
function mergeArtists(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, {
method: "POST",
})
}
function login(username: string, password: string, remember: boolean): Promise<Response> {
return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, {
method: "POST",
})
}
function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, {
method: "POST",
})
}
function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
}
const createApiKey = async (label: string): Promise<ApiKey> => {
const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, {
method: "POST"
});
if (!r.ok) {
let errorMessage = `error: ${r.status}`;
try {
const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === 'string') {
errorMessage = errorData.error;
}
} catch (e) {
console.error("unexpected api error:", e);
}
throw new Error(errorMessage);
}
const data: ApiKey = await r.json();
return data;
};
function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE"
})
}
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, {
method: "PATCH"
})
}
function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE"
})
}
function updateUser(username: string, password: string) {
return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, {
method: "PATCH"
})
}
function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
}
function createAlias(type: string, id: number, alias: string): Promise<Response> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
method: 'POST'
})
}
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
method: "DELETE"
})
}
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, {
method: "POST"
})
}
export {
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
}
type Track = {
id: number
title: string
artists: SimpleArtists[]
listen_count: number
image: string
album_id: number
musicbrainz_id: string
}
type Artist = {
id: number
name: string
image: string,
aliases: string[]
listen_count: number
musicbrainz_id: string
}
type Album = {
id: number,
title: string
image: string
listen_count: number
is_various_artists: boolean
artists: SimpleArtists[]
musicbrainz_id: string
}
type Alias = {
id: number
alias: string
source: string
is_primary: boolean
}
type Listen = {
time: string,
track: Track,
}
type PaginatedResponse<T> = {
items: T[],
total_record_count: number,
has_next_page: boolean,
current_page: number,
items_per_page: number,
}
type ListenActivityItem = {
start_time: Date,
listens: number
}
type SimpleArtists = {
name: string
id: number
}
type Stats = {
listen_count: number
track_count: number
album_count: number
artist_count: number
hours_listened: number
}
type SearchResponse = {
albums: Album[]
artists: Artist[]
tracks: Track[]
}
type User = {
id: number
username: string
role: 'user' | 'admin'
}
type ApiKey = {
id: number
key: string
label: string
created_at: Date
}
type ApiError = {
error: string
}
export type {
getItemsArgs,
getActivityArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
ListenActivityItem,
User,
Alias,
ApiKey,
ApiError
}

@ -0,0 +1,181 @@
@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fade-out-scale {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.95);
}
}
--animate-fade-in: fade-in 0.1s ease forwards;
--animate-fade-out: fade-out 0.1s ease forwards;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}
:root {
--header-xl: 78px;
--header-lg: 28px;
--header-md: 22px;
--header-sm: 16px;
--header-xl-weight: 600;
--header-weight: 600;
}
html,
body {
background-color: var(--color-bg);
color: var(--color-fg);
transition: background-color 0.3s ease;
}
select option {
margin: 40px;
background: var(--color-bg);
color: var(--color-fg);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
}
select {
background: var(--color-bg);
}
/* a {
color: var(--color-fg);
}
a:hover {
color: var(--color-link-hover);
} */
h1 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-xl);
}
h2 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-md);
margin-bottom: 0.5em;
}
h3 {
font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight);
}
h4 {
font-size: var(--header-md);
}
.header-font {
font-family: "League Spartan";
}
.icon-hover-fill:hover > svg > path {
fill: var(--color-fg-secondary);
}
.icon-hover-stroke:hover > svg > path {
stroke: var(--color-fg-secondary);
}
.link-underline:hover {
text-decoration: underline;
}
input[type="text"] {
border: 1px solid var(--color-bg);
}
input[type="text"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
}
input[type="password"] {
border: 1px solid var(--color-bg);
}
input[type="password"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
}
input[type="checkbox"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
}
button:hover {
cursor: pointer;
}
button:disabled:hover,
button[disabled]:hover {
cursor: default;
}
button.large-button {
background: var(--color-bg);
}
button.large-button:hover {
background: var(--color-bg-tertiary);
}
button.large-button:disabled:hover,
button.large-button[disabled]:hover {
background: var(--color-bg);
}
button.period-selector {
color: var(--color-fg-secondary);
}
button.period-selector:disabled,
button.period-selector[disabled]:hover {
color: var(--color-fg);
}
button.period-selector:hover {
color: var(--color-fg);
}
button.default {
color: var(--color-fg);
}
button.default:disabled,
button.default[disabled]:hover {
color: var(--color-fg-secondary);
}
button.default:hover {
color: var(--color-fg-secondary);
}

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

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

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

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

@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query"
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
interface Props {
artistId: number
name: string
period: string
}
export default function ArtistAlbums({artistId, name, period}: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div>
<h2>Albums From This Artist</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return (
<div>
<h2>Albums From This Artist</h2>
<p className="error">Error:{error.message}</p>
</div>
)
}
return (
<div>
<h2>Albums featuring {name}</h2>
<div className="flex flex-wrap gap-8">
{data.items.map((item) => (
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
<div className="w-[180px] flex flex-col items-start gap-1">
<p>{item.title}</p>
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
</div>
</Link>
))}
</div>
</div>
)
}

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

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

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

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

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

@ -0,0 +1,57 @@
import { useQuery } from "@tanstack/react-query"
import { timeSince } from "~/utils/utils"
import ArtistLinks from "./ArtistLinks"
import { getLastListens, type getItemsArgs } from "api/api"
import { Link } from "react-router"
interface Props {
limit: number
artistId?: Number
albumId?: Number
trackId?: number
hideArtists?: boolean
}
export default function LastPlays(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['last-listens', {limit: props.limit, period: 'all_time', artist_id: props.artistId, album_id: props.albumId, track_id: props.trackId}],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div className="w-[500px]">
<h2>Last Played</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
let params = ''
params += props.artistId ? `&artist_id=${props.artistId}` : ''
params += props.albumId ? `&album_id=${props.albumId}` : ''
params += props.trackId ? `&track_id=${props.trackId}` : ''
return (
<div>
<h2 className="hover:underline"><Link to={`/listens?period=all_time${params}`}>Last Played</Link></h2>
<table>
<tbody>
{data.items.map((item) => (
<tr key={`last_listen_${item.time}`}>
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
<td className="text-ellipsis overflow-hidden max-w-[600px]">
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

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

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

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

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

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

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

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

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

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query"
import { getTopAlbums, type getItemsArgs } from "api/api"
import AlbumDisplay from "./AlbumDisplay"
interface Props {
period: string
artistId?: Number
vert?: boolean
hideTitle?: boolean
}
export default function TopThreeAlbums(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return <p>Loading...</p>
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
console.log(data)
return (
<div>
{!props.hideTitle && <h2>Top Three Albums</h2>}
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
{data.items.map((item, index) => (
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
))}
</div>
</div>
)
}

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

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

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

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

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

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

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

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

@ -0,0 +1,97 @@
import { logout, updateUser } from "api/api"
import { useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { useAppContext } from "~/providers/AppProvider"
export default function Account() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { user, setUsername: setCtxUsername } = useAppContext()
const logoutHandler = () => {
setLoading(true)
logout()
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
if (password != "" && confirmPw === "") {
setError("confirm your password before submitting")
return
}
setError('')
setSuccess('')
setLoading(true)
updateUser(username, password)
.then(r => {
if (r.ok) {
setSuccess("sucessfully updated user")
if (username != "") {
setCtxUsername(username)
}
setUsername('')
setPassword('')
setConfirmPw('')
} else {
r.json().then((r) => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
return (
<>
<h2>Account</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div>
<h2>Update User</h2>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>
</>
)
}

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

@ -0,0 +1,129 @@
import { useQuery } from "@tanstack/react-query";
import { createApiKey, deleteApiKey, getApiKeys, type ApiKey } from "api/api";
import { AsyncButton } from "../AsyncButton";
import { useEffect, useState } from "react";
import { Copy, Trash } from "lucide-react";
type CopiedState = {
x: number;
y: number;
visible: boolean;
};
export default function ApiKeysModal() {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<ApiKey[]>([])
const [copied, setCopied] = useState<CopiedState | null>(null);
const { isPending, isError, data, error } = useQuery({
queryKey: [
'api-keys'
],
queryFn: () => {
return getApiKeys();
},
});
useEffect(() => {
if (data) {
setDisplayData(data)
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
navigator.clipboard.writeText(text);
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2, // center of button
y: buttonRect.top - parentRect.top - 8, // above the button
visible: true,
});
setTimeout(() => setCopied(null), 1500);
};
const handleCreateApiKey = () => {
setError(undefined)
if (input === "") {
setError("a label must be provided")
return
}
setLoading(true)
createApiKey(input)
.then(r => {
setDisplayData([r, ...displayData])
setInput('')
}).catch((err) => setError(err.message))
setLoading(false)
}
const handleDeleteApiKey = (id: number) => {
setError(undefined)
setLoading(true)
deleteApiKey(id)
.then(r => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.id != id))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
return (
<div className="">
<h2>API Keys</h2>
<div className="flex flex-col gap-4 relative">
{displayData.map((v) => (
<div className="flex gap-2">
<div className="bg p-3 rounded-md flex-grow" key={v.key}>{v.key.slice(0, 8)+'...'} {v.label}</div>
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a label for a new API key"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
</div>
{err && <p className="error">{err}</p>}
{copied?.visible && (
<div
style={{
position: "absolute",
top: copied.y,
left: copied.x,
transform: "translate(-50%, -100%)",
}}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
>
Copied!
</div>
)}
</div>
</div>
)
}

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

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

@ -0,0 +1,59 @@
import { login } from "api/api"
import { useEffect, useState } from "react"
import { AsyncButton } from "../AsyncButton"
export default function LoginForm() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const loginHandler = () => {
if (username && password) {
setLoading(true)
login(username, password, remember)
.then(r => {
if (r.status >= 200 && r.status < 300) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} else if (username || password) {
setError("username and password are required")
}
}
return (
<>
<h2>Log In</h2>
<div className="flex flex-col items-center gap-4 w-full">
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="flex gap-2">
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
<label htmlFor="kotio-remember">Remember me</label>
</div>
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
</form>
<p className="error">{error}</p>
</div>
</>
)
}

@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router";
interface Props {
open: boolean
setOpen: Function
type: string
currentId: number
currentTitle: string
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
}
export default function MergeModal(props: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const navigate = useNavigate()
const closeMergeModal = () => {
props.setOpen(false)
setQuery('')
setData(undefined)
setMergeOrderReversed(false)
setMergeTarget({title: '', id: 0})
}
const toggleSelect = ({title, id}: {title: string, id: number}) => {
if (mergeTarget.id === 0) {
setMergeTarget({title: title, id: id})
} else {
setMergeTarget({title:"", id: 0})
}
}
useEffect(() => {
console.log(mergeTarget)
}, [mergeTarget])
const doMerge = () => {
let from, to
if (!mergeOrderReversed) {
from = mergeTarget
to = {id: props.currentId, title: props.currentTitle}
} else {
from = {id: props.currentId, title: props.currentTitle}
to = mergeTarget
}
props.mergeFunc(from.id, to.id)
.then(r => {
if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget}`)
closeMergeModal()
} else {
window.location.reload()
}
} else {
// TODO: handle error
console.log(r)
}
})
.catch((err) => console.log(err))
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId)
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
{ mergeTarget.id !== 0 ?
<>
{mergeOrderReversed ?
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
:
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
}
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
<div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</div>
</> :
''}
</div>
</Modal>
)
}

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

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

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
interface Props {
open: boolean
setOpen: Function
}
export default function SearchModal({ open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const closeSearchModal = () => {
setOpen(false)
setQuery('')
setData(undefined)
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h2>Search</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
placeholder="Search for an artist, album, or track"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal}/>
</div>
</div>
</Modal>
)
}

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

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

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

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

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

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

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

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

@ -0,0 +1,12 @@
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});

@ -0,0 +1,70 @@
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext
// If you have middleware enabled:
// loadContext: unstable_RouterContextProvider
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

@ -0,0 +1,10 @@
import { useContext } from 'react';
import { ThemeContext } from '../providers/ThemeProvider';
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

@ -0,0 +1,61 @@
import type { User } from "api/api";
import { createContext, useContext, useEffect, useState } from "react";
interface AppContextType {
user: User | null | undefined;
configurableHomeActivity: boolean;
homeItems: number;
setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void;
setUsername: (value: string) => void;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
export const useAppContext = () => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error("useAppContext must be used within an AppProvider");
}
return context;
};
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null | undefined>(undefined);
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0);
const setUsername = (value: string) => {
if (!user) {
return
}
setUser({...user, username: value})
}
useEffect(() => {
fetch("/apis/web/v1/user/me")
.then((res) => res.json())
.then((data) => {
data.error ? setUser(null) : setUser(data);
})
.catch(() => setUser(null));
setConfigurableHomeActivity(true);
setHomeItems(12);
}, []);
if (user === undefined) {
return null;
}
const contextValue: AppContextType = {
user,
configurableHomeActivity,
homeItems,
setConfigurableHomeActivity,
setHomeItems,
setUsername,
};
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
};

@ -0,0 +1,259 @@
import { createContext, useEffect, useState, type ReactNode } from 'react';
// a fair number of colors aren't actually used, but i'm keeping
// them so that I don't have to worry about colors when adding new ui elements
export type Theme = {
name: string,
bg: string
bgSecondary: string
bgTertiary: string
fg: string
fgSecondary: string
fgTertiary: string
primary: string
primaryDim: string
accent: string
accentDim: string
error: string
warning: string
info: string
success: string
}
export const themes: Theme[] = [
{
name: "yuu",
bg: "#161312",
bgSecondary: "#272120",
bgTertiary: "#382F2E",
fg: "#faf5f4",
fgSecondary: "#CCC7C6",
fgTertiary: "#B0A3A1",
primary: "#ff826d",
primaryDim: "#CE6654",
accent: "#464DAE",
accentDim: "#393D74",
error: "#FF6247",
warning: "#FFC107",
success: "#3ECE5F",
info: "#41C4D8",
},
{
name: "varia",
bg: "rgb(25, 25, 29)",
bgSecondary: "#222222",
bgTertiary: "#333333",
fg: "#eeeeee",
fgSecondary: "#aaaaaa",
fgTertiary: "#888888",
primary: "rgb(203, 110, 240)",
primaryDim: "#c28379",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
{
name: "midnight",
bg: "rgb(8, 15, 24)",
bgSecondary: "rgb(15, 27, 46)",
bgTertiary: "rgb(15, 41, 70)",
fg: "#dbdfe7",
fgSecondary: "#9ea3a8",
fgTertiary: "#74787c",
primary: "#1a97eb",
primaryDim: "#2680aa",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
{
name: "catppuccin",
bg: "#1e1e2e",
bgSecondary: "#181825",
bgTertiary: "#11111b",
fg: "#cdd6f4",
fgSecondary: "#a6adc8",
fgTertiary: "#9399b2",
primary: "#89b4fa",
primaryDim: "#739df0",
accent: "#f38ba8",
accentDim: "#d67b94",
error: "#f38ba8",
warning: "#f9e2af",
success: "#a6e3a1",
info: "#89dceb",
},
{
name: "autumn",
bg: "rgb(44, 25, 18)",
bgSecondary: "rgb(70, 40, 18)",
bgTertiary: "#4b2f1c",
fg: "#fef9f3",
fgSecondary: "#dbc6b0",
fgTertiary: "#a3917a",
primary: "#d97706",
primaryDim: "#b45309",
accent: "#8c4c28",
accentDim: "#6b3b1f",
error: "#d1433f",
warning: "#e38b29",
success: "#6b8e23",
info: "#c084fc",
},
{
name: "black",
bg: "#000000",
bgSecondary: "#1a1a1a",
bgTertiary: "#2a2a2a",
fg: "#dddddd",
fgSecondary: "#aaaaaa",
fgTertiary: "#888888",
primary: "#08c08c",
primaryDim: "#08c08c",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
{
name: "wine",
bg: "#23181E",
bgSecondary: "#2C1C25",
bgTertiary: "#422A37",
fg: "#FCE0B3",
fgSecondary: "#C7AC81",
fgTertiary: "#A78E64",
primary: "#EA8A64",
primaryDim: "#BD7255",
accent: "#FAE99B",
accentDim: "#C6B464",
error: "#fca5a5",
warning: "#fde68a",
success: "#bbf7d0",
info: "#bae6fd",
},
{
name: "pearl",
bg: "#FFFFFF",
bgSecondary: "#EEEEEE",
bgTertiary: "#E0E0E0",
fg: "#333333",
fgSecondary: "#555555",
fgTertiary: "#777777",
primary: "#007BFF",
primaryDim: "#0056B3",
accent: "#28A745",
accentDim: "#1E7E34",
error: "#DC3545",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
{
name: "asuka",
bg: "#3B1212",
bgSecondary: "#471B1B",
bgTertiary: "#020202",
fg: "#F1E9E6",
fgSecondary: "#CCB6AE",
fgTertiary: "#9F8176",
primary: "#F1E9E6",
primaryDim: "#CCB6AE",
accent: "#41CE41",
accentDim: "#3BA03B",
error: "#DC143C",
warning: "#FFD700",
success: "#32CD32",
info: "#1E90FF",
},
{
name: "urim",
bg: "#101713",
bgSecondary: "#1B2921",
bgTertiary: "#273B30",
fg: "#D2E79E",
fgSecondary: "#B4DA55",
fgTertiary: "#7E9F2A",
primary: "#ead500",
primaryDim: "#C1B210",
accent: "#28A745",
accentDim: "#1E7E34",
error: "#EE5237",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
{
name: "match",
bg: "#071014",
bgSecondary: "#0A181E",
bgTertiary: "#112A34",
fg: "#ebeaeb",
fgSecondary: "#BDBDBD",
fgTertiary: "#A2A2A2",
primary: "#fda827",
primaryDim: "#C78420",
accent: "#277CFD",
accentDim: "#1F60C1",
error: "#F14426",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
{
name: "lemon",
bg: "#1a171a",
bgSecondary: "#2E272E",
bgTertiary: "#443844",
fg: "#E6E2DC",
fgSecondary: "#B2ACA1",
fgTertiary: "#968F82",
primary: "#f5c737",
primaryDim: "#C29D2F",
accent: "#277CFD",
accentDim: "#1F60C1",
error: "#F14426",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
];
interface ThemeContextValue {
theme: string;
setTheme: (theme: string) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({
theme: initialTheme,
children,
}: {
theme: string;
children: ReactNode;
}) {
const [theme, setTheme] = useState(initialTheme);
useEffect(() => {
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
}
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export { ThemeContext }

@ -0,0 +1,138 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useRouteError,
} from "react-router";
import type { Route } from "./+types/root";
import './themes.css'
import "./app.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from './providers/ThemeProvider';
import Sidebar from "./components/sidebar/Sidebar";
import Footer from "./components/Footer";
import { AppProvider } from "./providers/AppProvider";
// Create a client
const queryClient = new QueryClient()
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" style={{backgroundColor: 'black'}}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Koito" />
<link rel="manifest" href="/site.webmanifest" />
<Meta />
<Links />
</head>
<body className="min-h-screen">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
let theme = localStorage.getItem('theme') ?? 'midnight'
return (
<>
<AppProvider>
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<div className="flex">
<Sidebar />
<div className="flex flex-col items-center mx-auto w-full">
<Outlet />
<Footer />
</div>
</div>
</QueryClientProvider>
</ThemeProvider>
</AppProvider>
</>
);
}
export function HydrateFallback() {
return null
}
export function ErrorBoundary() {
const error = useRouteError();
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 instanceof Error) {
details = error.message;
stack = error.stack;
}
let theme = 'midnight'
try {
theme = localStorage.getItem('theme') ?? theme
} catch(err) {
console.log(err)
}
const title = `${message} - Koito`
return (
<AppProvider>
<ThemeProvider theme={theme}>
<title>{title}</title>
<div className="flex">
<Sidebar />
<div className="w-full flex flex-col">
<main className="pt-16 p-4 container mx-auto flex-grow">
<div className="flex gap-4 items-end">
<img className="w-[200px] rounded" src="../public/yuu.jpg" />
<div>
<h1>{message}</h1>
<p>{details}</p>
</div>
</div>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
<Footer />
</div>
</div>
</ThemeProvider>
</AppProvider>
);
}

@ -0,0 +1,13 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/Home.tsx"),
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
route("/album/:id", "routes/MediaItems/Album.tsx"),
route("/track/:id", "routes/MediaItems/Track.tsx"),
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
route("/listens", "routes/Charts/Listens.tsx"),
route("/theme-helper", "routes/ThemeHelper.tsx"),
] satisfies RouteConfig;

@ -0,0 +1,58 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type PaginatedResponse } from "api/api";
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 res = await fetch(
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
);
if (!res.ok) {
throw new Response("Failed to load top albums", { status: 500 });
}
const top_albums: PaginatedResponse<Album> = await res.json();
return { top_albums };
}
export default function AlbumChart() {
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
return (
<ChartLayout
title="Top Albums"
initialData={initialData}
endpoint="chart/top-albums"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<TopItemList
separators
data={data}
width={600}
type="album"
/>
<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>
)}
/>
);
}

@ -0,0 +1,58 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type PaginatedResponse } from "api/api";
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 res = await fetch(
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
);
if (!res.ok) {
throw new Response("Failed to load top artists", { status: 500 });
}
const top_artists: PaginatedResponse<Album> = await res.json();
return { top_artists };
}
export default function Artist() {
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
return (
<ChartLayout
title="Top Artists"
initialData={initialData}
endpoint="chart/top-artists"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<TopItemList
separators
data={data}
width={600}
type="artist"
/>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
</div>
)}
/>
);
}

@ -0,0 +1,262 @@
import {
useFetcher,
useLocation,
useNavigate,
} from "react-router"
import { useEffect, useState } from "react"
import { average } from "color.js"
import { imageUrl, type PaginatedResponse } from "api/api"
import PeriodSelector from "~/components/PeriodSelector"
interface ChartLayoutProps<T> {
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
initialData: PaginatedResponse<T>
endpoint: string
render: (opts: {
data: PaginatedResponse<T>
page: number
onNext: () => void
onPrev: () => void
}) => React.ReactNode
}
export default function ChartLayout<T>({
title,
initialData,
endpoint,
render,
}: ChartLayoutProps<T>) {
const pgTitle = `${title} - Koito`
const fetcher = useFetcher()
const location = useLocation()
const navigate = useNavigate()
const currentParams = new URLSearchParams(location.search)
const currentPage = parseInt(currentParams.get("page") || "1", 10)
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
? fetcher.data[endpoint]
: initialData
const [bgColor, setBgColor] = useState<string>("(--color-bg)")
useEffect(() => {
if ((data?.items?.length ?? 0) === 0) return
const img = (data.items[0] as any)?.image
if (!img) return
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
})
}, [data])
const period = currentParams.get("period") ?? "day"
const year = currentParams.get("year")
const month = currentParams.get("month")
const week = currentParams.get("week")
const updateParams = (params: Record<string, string | null>) => {
const nextParams = new URLSearchParams(location.search)
for (const key in params) {
const val = params[key]
if (val !== null) {
nextParams.set(key, val)
} else {
nextParams.delete(key)
}
}
const url = `/${endpoint}?${nextParams.toString()}`
navigate(url, { replace: false })
}
const handleSetPeriod = (p: string) => {
updateParams({
period: p,
page: "1",
year: null,
month: null,
week: null,
})
}
const handleSetYear = (val: string) => {
if (val == "") {
updateParams({
period: period,
page: "1",
year: null,
month: null,
week: null
})
return
}
updateParams({
period: null,
page: "1",
year: val,
})
}
const handleSetMonth = (val: string) => {
updateParams({
period: null,
page: "1",
year: year ?? new Date().getFullYear().toString(),
month: val,
})
}
const handleSetWeek = (val: string) => {
updateParams({
period: null,
page: "1",
year: year ?? new Date().getFullYear().toString(),
month: null,
week: val,
})
}
useEffect(() => {
fetcher.load(`/${endpoint}?${currentParams.toString()}`)
}, [location.search])
const setPage = (nextPage: number) => {
const nextParams = new URLSearchParams(location.search)
nextParams.set("page", String(nextPage))
const url = `/${endpoint}?${nextParams.toString()}`
fetcher.load(url)
navigate(url, { replace: false })
}
const handleNextPage = () => setPage(currentPage + 1)
const handlePrevPage = () => setPage(currentPage - 1)
const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`)
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`)
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`)
const getDateRange = (): string => {
let from: Date
let to: Date
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-indexed
const currentDate = now.getDate()
if (year && month) {
from = new Date(parseInt(year), parseInt(month) - 1, 1)
to = new Date(from)
to.setMonth(from.getMonth() + 1)
to.setDate(0)
} else if (year && week) {
const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
const weekNumber = parseInt(week)
from = new Date(base)
from.setDate(base.getDate() + (weekNumber - 1) * 7)
to = new Date(from)
to.setDate(from.getDate() + 6)
} else if (year) {
from = new Date(parseInt(year), 0, 1)
to = new Date(parseInt(year), 11, 31)
} else {
switch (period) {
case "day":
from = new Date(now)
to = new Date(now)
break
case "week":
to = new Date(now)
from = new Date(now)
from.setDate(to.getDate() - 6)
break
case "month":
to = new Date(now)
from = new Date(now)
if (currentMonth === 0) {
from = new Date(currentYear - 1, 11, currentDate)
} else {
from = new Date(currentYear, currentMonth - 1, currentDate)
}
break
case "year":
to = new Date(now)
from = new Date(currentYear - 1, currentMonth, currentDate)
break
case "all_time":
return "All Time"
default:
return ""
}
}
const formatter = new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})
return `${formatter.format(from)} - ${formatter.format(to)}`
}
return (
<div
className="w-full min-h-screen"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
transition: "1000",
}}
>
<title>{pgTitle}</title>
<meta property="og:title" content={pgTitle} />
<meta name="description" content={pgTitle} />
<div className="w-17/20 mx-auto pt-12">
<h1>{title}</h1>
<div className="flex items-center gap-4">
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
<select
value={year ?? ""}
onChange={(e) => handleSetYear(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Year</option>
{yearOptions.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<select
value={month ?? ""}
onChange={(e) => handleSetMonth(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Month</option>
{monthOptions.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<select
value={week ?? ""}
onChange={(e) => handleSetWeek(e.target.value)}
className="px-2 py-1 rounded border border-gray-400"
>
<option value="">Week</option>
{weekOptions.map((w) => (
<option key={w} value={w}>{w}</option>
))}
</select>
</div>
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
<div className="mt-20 flex mx-auto justify-between">
{render({
data,
page: currentPage,
onNext: handleNextPage,
onPrev: handlePrevPage,
})}
</div>
</div>
</div>
)
}

@ -0,0 +1,66 @@
import ChartLayout from "./ChartLayout";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type Listen, type PaginatedResponse } from "api/api";
import { timeSince } from "~/utils/utils";
import ArtistLinks from "~/components/ArtistLinks";
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 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<Album> = await res.json();
return { listens };
}
export default function Listens() {
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
return (
<ChartLayout
title="Last Played"
initialData={initialData}
endpoint="listens"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
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 className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
</div>
)}
/>
);
}

@ -0,0 +1,58 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type PaginatedResponse } from "api/api";
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 res = await fetch(
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
);
if (!res.ok) {
throw new Response("Failed to load top tracks", { status: 500 });
}
const top_tracks: PaginatedResponse<Album> = await res.json();
return { top_tracks };
}
export default function TrackChart() {
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
return (
<ChartLayout
title="Top Tracks"
initialData={initialData}
endpoint="chart/top-tracks"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<TopItemList
separators
data={data}
width={600}
type="track"
/>
<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>
)}
/>
);
}

@ -0,0 +1,41 @@
import type { Route } from "./+types/Home";
import TopTracks from "~/components/TopTracks";
import LastPlays from "~/components/LastPlays";
import ActivityGrid from "~/components/ActivityGrid";
import TopAlbums from "~/components/TopAlbums";
import TopArtists from "~/components/TopArtists";
import AllTimeStats from "~/components/AllTimeStats";
import { useState } from "react";
import PeriodSelector from "~/components/PeriodSelector";
import { useAppContext } from "~/providers/AppProvider";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Koito" },
{ name: "description", content: "Koito" },
];
}
export default function Home() {
const [period, setPeriod] = useState('week')
const { homeItems } = useAppContext();
return (
<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 gap-20">
<AllTimeStats />
<ActivityGrid />
</div>
<PeriodSelector setter={setPeriod} current={period} />
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
<TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.5)} />
</div>
</div>
</main>
);
}

@ -0,0 +1,57 @@
import { useState } from "react";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import TopTracks from "~/components/TopTracks";
import { mergeAlbums, type Album } from "api/api";
import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
if (!res.ok) {
throw new Response("Failed to load album", { status: 500 });
}
const album: Album = await res.json();
return album;
}
export default function Album() {
const album = useLoaderData() as Album;
const [period, setPeriod] = useState('week')
console.log(album)
return (
<MediaLayout type="Album"
title={album.title}
img={album.image}
id={album.id}
musicbrainzId={album.musicbrainz_id}
imgItemId={album.id}
mergeFunc={mergeAlbums}
mergeCleanerFunc={(r, id) => {
r.artists = []
r.tracks = []
for (let i = 0; i < r.albums.length; i ++) {
if (r.albums[i].id === id) {
delete r.albums[i]
}
}
return r
}}
subContent={<>
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
</>}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} />
<ActivityGrid autoAdjust configurable albumId={album.id} />
</div>
</MediaLayout>
);
}

@ -0,0 +1,66 @@
import { useState } from "react";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import TopTracks from "~/components/TopTracks";
import { mergeArtists, type Artist } from "api/api";
import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
if (!res.ok) {
throw new Response("Failed to load artist", { status: 500 });
}
const artist: Artist = await res.json();
return artist;
}
export default function Artist() {
const artist = useLoaderData() as Artist;
const [period, setPeriod] = useState('week')
// remove canonical name from alias list
console.log(artist.aliases)
let index = artist.aliases.indexOf(artist.name);
if (index !== -1) {
artist.aliases.splice(index, 1);
}
return (
<MediaLayout type="Artist"
title={artist.name}
img={artist.image}
id={artist.id}
musicbrainzId={artist.musicbrainz_id}
imgItemId={artist.id}
mergeFunc={mergeArtists}
mergeCleanerFunc={(r, id) => {
r.albums = []
r.tracks = []
for (let i = 0; i < r.artists.length; i ++) {
if (r.artists[i].id === id) {
delete r.artists[i]
}
}
return r
}}
subContent={<>
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
</>}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-col gap-20">
<div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} />
<ActivityGrid configurable autoAdjust artistId={artist.id} />
</div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div>
</MediaLayout>
);
}

@ -0,0 +1,88 @@
import React, { useEffect, useState } from "react";
import { average } from "color.js";
import { imageUrl, type SearchResponse } from "api/api";
import ImageDropHandler from "~/components/ImageDropHandler";
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/RenameModal";
export type MergeFunc = (from: number, to: number) => Promise<Response>
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
interface Props {
type: "Track" | "Album" | "Artist"
title: string
img: string
id: number
musicbrainzId: string
imgItemId: number
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
children: React.ReactNode
subContent: React.ReactNode
}
export default function MediaLayout(props: Props) {
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const { user } = useAppContext();
useEffect(() => {
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
});
}, [props.img]);
const replaceImageCallback = () => {
window.location.reload()
}
const title = `${props.title} - Koito`
return (
<main
className="w-full flex flex-col flex-grow"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
transition: '1000',
}}
>
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
<title>{title}</title>
<meta property="og:title" content={title} />
<meta
name="description"
content={title}
/>
<div className="w-19/20 mx-auto pt-12">
<div className="flex gap-8 relative">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" />
<div className="flex flex-col items-start">
<h3>{props.type}</h3>
<h1>{props.title}</h1>
{props.subContent}
</div>
{ user &&
<div className="absolute right-1 flex gap-3 items-center">
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={30} /></button>
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={30} /></button>
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={30} /></button>
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={30} /></button>
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
</div>
}
</div>
{props.children}
</div>
</main>
);
}

@ -0,0 +1,59 @@
import { useState } from "react";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { mergeTracks, type Album, type Track } from "api/api";
import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
if (!res.ok) {
throw new Response("Failed to load track", { status: res.status });
}
const track: Track = await res.json();
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
if (!res.ok) {
throw new Response("Failed to load album for track", { status: res.status })
}
const album: Album = await res.json()
return {track: track, album: album};
}
export default function Track() {
const { track, album } = useLoaderData();
const [period, setPeriod] = useState('week')
return (
<MediaLayout type="Track"
title={track.title}
img={track.image}
id={track.id}
musicbrainzId={album.musicbrainz_id}
imgItemId={track.album_id}
mergeFunc={mergeTracks}
mergeCleanerFunc={(r, id) => {
r.albums = []
r.artists = []
for (let i = 0; i < r.tracks.length; i ++) {
if (r.tracks[i].id === id) {
delete r.tracks[i]
}
}
return r
}}
subContent={<div className="flex flex-col gap-4 items-start">
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
</div>}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex gap-20 mt-10">
<LastPlays limit={20} trackId={track.id}/>
<ActivityGrid trackId={track.id} configurable autoAdjust />
</div>
</MediaLayout>
)
}

@ -0,0 +1,43 @@
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>
);
}

@ -0,0 +1,67 @@
import { useState } from "react"
import { useAppContext } from "~/providers/AppProvider"
import { AsyncButton } from "../components/AsyncButton"
import AllTimeStats from "~/components/AllTimeStats"
import ActivityGrid from "~/components/ActivityGrid"
import LastPlays from "~/components/LastPlays"
import TopAlbums from "~/components/TopAlbums"
import TopArtists from "~/components/TopArtists"
import TopTracks from "~/components/TopTracks"
export default function ThemeHelper() {
const homeItems = 3
return (
<div className="mt-10 flex flex-col gap-10 items-center">
<div className="flex gap-5">
<AllTimeStats />
<ActivityGrid />
</div>
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
<TopArtists period="all_time" limit={homeItems} />
<TopAlbums period="all_time" limit={homeItems} />
<TopTracks period="all_time" limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.5)} />
</div>
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>Example User</strong></p>
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
/>
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm password"
className="w-full mx-auto fg bg rounded p-2"
/>
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
</div>
<div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
<label htmlFor="reverse-merge-order">Example checkbox</label>
</div>
<p className="success">successfully displayed example text</p>
<p className="error">this is an example of error text</p>
<p className="info">here is an informational example</p>
<p className="warning">heed this warning, traveller</p>
</div>
</div>
)
}

@ -0,0 +1,432 @@
/* Theme Definitions */
[data-theme="varia"]{
/* Backgrounds */
--color-bg:rgb(25, 25, 29);
--color-bg-secondary: #222222;
--color-bg-tertiary: #333333;
/* Foregrounds */
--color-fg: #eeeeee;
--color-fg-secondary: #aaaaaa;
--color-fg-tertiary: #888888;
/* Accents */
--color-primary:rgb(203, 110, 240);
--color-primary-dim: #c28379;
--color-accent: #f0ad0a;
--color-accent-dim: #d08d08;
/* Status Colors */
--color-error: #f44336;
--color-warning: #ff9800;
--color-success: #4caf50;
--color-info: #2196f3;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.5);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="wine"] {
/* Backgrounds */
--color-bg: #23181E;
--color-bg-secondary: #2C1C25;
--color-bg-tertiary: #422A37;
/* Foregrounds */
--color-fg: #FCE0B3;
--color-fg-secondary:#C7AC81;
--color-fg-tertiary:#A78E64;
/* Accents */
--color-primary: #EA8A64;
--color-primary-dim: #BD7255;
--color-accent: #FAE99B;
--color-accent-dim: #C6B464;
/* Status Colors */
--color-error: #fca5a5;
--color-warning: #fde68a;
--color-success: #bbf7d0;
--color-info: #bae6fd;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.05);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="asuka"] {
/* Backgrounds */
--color-bg: #3B1212;
--color-bg-secondary: #471B1B;
--color-bg-tertiary: #020202;
/* Foregrounds */
--color-fg: #F1E9E6;
--color-fg-secondary: #CCB6AE;
--color-fg-tertiary: #9F8176;
/* Accents */
--color-primary: #F1E9E6;
--color-primary-dim: #CCB6AE;
--color-accent: #41CE41;
--color-accent-dim: #3BA03B;
/* Status Colors */
--color-error: #EB97A8;
--color-warning: #FFD700;
--color-success: #32CD32;
--color-info: #1E90FF;
/* Borders and Shadows (derived from existing colors for consistency) */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.1); /* Slightly more prominent shadow for contrast */
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="midnight"] {
/* Backgrounds */
--color-bg:rgb(8, 15, 24);
--color-bg-secondary:rgb(15, 27, 46);
--color-bg-tertiary:rgb(15, 41, 70);
/* Foregrounds */
--color-fg: #dbdfe7;
--color-fg-secondary: #9ea3a8;
--color-fg-tertiary: #74787c;
/* Accents */
--color-primary: #1a97eb;
--color-primary-dim: #2680aa;
--color-accent: #f0ad0a;
--color-accent-dim: #d08d08;
/* Status Colors */
--color-error: #f44336;
--color-warning: #ff9800;
--color-success: #4caf50;
--color-info: #2196f3;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.5);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
/* TODO: Adjust */
[data-theme="catppuccin"] {
/* Backgrounds */
--color-bg: #1e1e2e;
--color-bg-secondary: #181825;
--color-bg-tertiary: #11111b;
/* Foregrounds */
--color-fg: #cdd6f4;
--color-fg-secondary: #a6adc8;
--color-fg-tertiary: #9399b2;
/* Accents */
--color-primary: #cba6f7;
--color-primary-dim: #739df0;
--color-accent: #f38ba8;
--color-accent-dim: #d67b94;
/* Status Colors */
--color-error: #f38ba8;
--color-warning: #f9e2af;
--color-success: #a6e3a1;
--color-info: #89dceb;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.5);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="pearl"] {
/* Backgrounds */
--color-bg: #FFFFFF;
--color-bg-secondary: #EEEEEE;
--color-bg-tertiary: #E0E0E0;
/* Foregrounds */
--color-fg: #333333;
--color-fg-secondary: #555555;
--color-fg-tertiary: #777777;
/* Accents */
--color-primary: #007BFF;
--color-primary-dim: #0056B3;
--color-accent: #28A745;
--color-accent-dim: #1E7E34;
/* Status Colors */
--color-error: #DC3545;
--color-warning: #CE9B00;
--color-success: #099B2B;
--color-info: #02B3CE;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.1);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="urim"] {
/* Backgrounds */
--color-bg: #101713;
--color-bg-secondary: #1B2921;
--color-bg-tertiary: #273B30;
/* Foregrounds */
--color-fg: #D2E79E;
--color-fg-secondary: #B4DA55;
--color-fg-tertiary: #7E9F2A;
/* Accents */
--color-primary: #ead500;
--color-primary-dim: #C1B210;
--color-accent: #28A745;
--color-accent-dim: #1E7E34;
/* Status Colors */
--color-error: #EE5237;
--color-warning: #FFC107;
--color-success: #28A745;
--color-info: #17A2B8;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.1);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="yuu"] {
/* Backgrounds */
--color-bg: #161312;
--color-bg-secondary: #272120;
--color-bg-tertiary: #382F2E;
/* Foregrounds */
--color-fg: #faf5f4;
--color-fg-secondary: #CCC7C6;
--color-fg-tertiary: #B0A3A1;
/* Accents */
--color-primary: #ff826d;
--color-primary-dim: #CE6654;
--color-accent: #464DAE;
--color-accent-dim: #393D74;
/* Status Colors */
--color-error: #FF6247;
--color-warning: #FFC107;
--color-success: #3ECE5F;
--color-info: #41C4D8;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.1);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="match"] {
/* Backgrounds */
--color-bg: #071014;
--color-bg-secondary: #0A181E;
--color-bg-tertiary: #112A34;
/* Foregrounds */
--color-fg: #ebeaeb;
--color-fg-secondary: #BDBDBD;
--color-fg-tertiary: #A2A2A2;
/* Accents */
--color-primary: #fda827;
--color-primary-dim: #C78420;
--color-accent: #277CFD;
--color-accent-dim: #1F60C1;
/* Status Colors */
--color-error: #F14426;
--color-warning: #FFC107;
--color-success: #28A745;
--color-info: #17A2B8;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.1);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="lemon"] {
/* Backgrounds */
--color-bg: #1a171a;
--color-bg-secondary: #2E272E;
--color-bg-tertiary: #443844;
/* Foregrounds */
--color-fg: #E6E2DC;
--color-fg-secondary: #B2ACA1;
--color-fg-tertiary: #968F82;
/* Accents */
--color-primary: #f5c737;
--color-primary-dim: #C29D2F;
--color-accent: #277CFD;
--color-accent-dim: #1F60C1;
/* Status Colors */
--color-error: #F14426;
--color-warning: #FFC107;
--color-success: #28A745;
--color-info: #17A2B8;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.1);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="autumn"] {
/* Backgrounds */
--color-bg:rgb(44, 25, 18);
--color-bg-secondary:rgb(70, 40, 18);
--color-bg-tertiary: #4b2f1c;
/* Foregrounds */
--color-fg: #fef9f3;
--color-fg-secondary: #dbc6b0;
--color-fg-tertiary: #a3917a;
/* Accents */
--color-primary: #d97706;
--color-primary-dim: #b45309;
--color-accent: #8c4c28;
--color-accent-dim: #6b3b1f;
/* Status Colors */
--color-error: #d1433f;
--color-warning: #e38b29;
--color-success: #6b8e23;
--color-info: #c084fc;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.4);
/* Interactive Elements */
--color-link: var(--color-primary);
--color-link-hover: var(--color-primary-dim);
}
[data-theme="black"] {
/* Backgrounds */
--color-bg: #000000;
--color-bg-secondary: #1a1a1a;
--color-bg-tertiary: #2a2a2a;
/* Foregrounds */
--color-fg: #dddddd;
--color-fg-secondary: #aaaaaa;
--color-fg-tertiary: #888888;
/* Accents */
--color-primary: #08c08c;
--color-primary-dim: #08c08c;
--color-accent: #f0ad0a;
--color-accent-dim: #d08d08;
/* Status Colors */
--color-error: #f44336;
--color-warning: #ff9800;
--color-success: #4caf50;
--color-info: #2196f3;
/* Borders and Shadows */
--color-border: var(--color-bg-tertiary);
--color-shadow: rgba(0, 0, 0, 0.5);
/* Interactive Elements */
--color-link: #0af0af;
--color-link-hover: #08c08c;
}
/* Theme Helper Classes */
/* Foreground Text */
.color-fg { color: var(--color-fg); }
.color-fg-secondary { color: var(--color-fg-secondary); }
.color-fg-tertiary { color: var(--color-fg-tertiary); }
.hover-color-fg:hover { color: var(--color-fg); }
.hover-color-fg-secondary:hover { color: var(--color-fg-secondary); }
.hover-color-fg-tertiary:hover { color: var(--color-fg-tertiary); }
/* Backgrounds */
.bg { background-color: var(--color-bg); }
.bg-secondary { background-color: var(--color-bg-secondary); }
.bg-tertiary { background-color: var(--color-bg-tertiary); }
.hover-bg:hover { background-color: var(--color-bg); }
.hover-bg-secondary:hover { background-color: var(--color-bg-secondary); }
.hover-bg-tertiary:hover { background-color: var(--color-bg-tertiary); }
/* Borders */
.border { border: 1px solid var(--color-border); }
/* Accent Colors */
.color-primary { color: var(--color-primary); }
.bg-primary { background-color: var(--color-primary); }
.color-accent { color: var(--color-accent); }
.bg-secondary-accent { background-color: var(--color-accent); }
/* Status Colors */
.error { color: var(--color-error); }
.bg-error { background-color: var(--color-error); }
.warning { color: var(--color-warning); }
.bg-warning { background-color: var(--color-warning); }
.success { color: var(--color-success); }
.bg-success { background-color: var(--color-success); }
.info { color: var(--color-info); }
.bg-info { background-color: var(--color-info); }
/* Links */
.link { color: var(--color-link); transition: color var(--transition-speed); }
.link:hover { color: var(--color-link-hover); }

@ -0,0 +1,9 @@
enum Timeframe {
Day = 1,
Week,
Month,
Year,
AllTime,
}
export default Timeframe

@ -0,0 +1,90 @@
import Timeframe from "~/types/timeframe"
const timeframeToInterval = (timeframe: Timeframe): string => {
switch (timeframe) {
case Timeframe.Day:
return "1 day"
case Timeframe.Week:
return "1 week"
case Timeframe.Month:
return "1 month"
case Timeframe.Year:
return "1 year"
case Timeframe.AllTime:
return "99 years"
}
}
function timeSince(date: Date) {
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const intervals = [
{ label: 'year', seconds: 31536000 },
{ label: 'month', seconds: 2592000 },
{ label: 'week', seconds: 604800 },
{ label: 'day', seconds: 86400 },
{ label: 'hour', seconds: 3600 },
{ label: 'minute', seconds: 60 },
{ label: 'second', seconds: 1 },
];
for (const interval of intervals) {
const count = Math.floor(seconds / interval.seconds);
if (count >= 1) {
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
}
}
return 'just now';
}
export { timeSince }
type hsl = {
h: number,
s: number,
l: number,
}
const hexToHSL = (hex: string): hsl => {
let r = 0, g = 0, b = 0;
hex = hex.replace('#', '');
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16);
} else if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
}
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
case g: h = ((b - r) / d + 2); break;
case b: h = ((r - g) / d + 4); break;
}
h /= 6;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
};
export {hexToHSL}
export type {hsl}

@ -0,0 +1,35 @@
{
"name": "koito",
"version": "v0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.12",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"@tanstack/react-query": "^5.80.6",
"color.js": "^1.2.0",
"isbot": "^5.1.27",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.5.3"
},
"devDependencies": {
"@react-router/dev": "^7.5.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite-tsconfig-paths": "^5.1.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V3M15 10V17M7 13V17M19 5V17M11 7V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3103 1.77586C11.6966 1.40805 12.3034 1.40805 12.6897 1.77586L20.6897 9.39491L23.1897 11.7759C23.5896 12.1567 23.605 12.7897 23.2241 13.1897C22.8433 13.5896 22.2103 13.605 21.8103 13.2241L21 12.4524V20C21 21.1046 20.1046 22 19 22H14H10H5C3.89543 22 3 21.1046 3 20V12.4524L2.18966 13.2241C1.78972 13.605 1.15675 13.5896 0.775862 13.1897C0.394976 12.7897 0.410414 12.1567 0.810345 11.7759L3.31034 9.39491L11.3103 1.77586ZM5 10.5476V20H9V15C9 13.3431 10.3431 12 12 12C13.6569 12 15 13.3431 15 15V20H19V10.5476L12 3.88095L5 10.5476ZM13 20V15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15V20H13Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 6C13.7614 6 16 8.23858 16 11M16.6588 16.6549L21 21M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 491 B

@ -0,0 +1,21 @@
{
"name": "Koito",
"short_name": "Koito",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#161312",
"background_color": "#161312",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
} satisfies Config;

@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

@ -0,0 +1,29 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
const isDocker = process.env.BUILD_TARGET === 'docker';
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
server: {
proxy: {
'/apis': {
target: 'http://localhost:4110',
changeOrigin: true,
},
'/images': {
target: 'http://192.168.0.153:4110',
changeOrigin: true,
}
}
},
resolve: {
alias: {
...(isDocker
? { 'react-dom/server': 'react-dom/server.node' }
: {}),
},
},
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,18 @@
package main
import (
"fmt"
"os"
"github.com/gabehf/koito/engine"
)
func main() {
if err := engine.Run(
os.Getenv,
os.Stdout,
); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save