Compare commits

...

87 commits
v0.0.4 ... main

Author SHA1 Message Date
Gabe Farrell
0ec7b458cc
ui: tweaks and fixes (#194)
* reduce min width of top chart on mobile

* adjust error page style

* adjust h1 line height
2026-02-04 13:41:12 -05:00
Gabe Farrell
531c72899c
fix: add null check for top charts bg gradient (#193) 2026-02-03 11:23:30 -05:00
Gabe Farrell
b06685c1af
fix: rewind navigation (#191) 2026-02-02 15:06:13 -05:00
Gabe Farrell
64236c99c9
fix: invalid json response when login gate is disabled (#184) 2026-01-26 14:49:30 -05:00
Gabe Farrell
42b32c7920
feat: add api key auth to web api (#183) 2026-01-26 13:48:43 -05:00
PythonGermany
bf1c03e9fd
docs: fix typo in index.mdx (#182) 2026-01-26 13:43:01 -05:00
Gabe Farrell
35e104c97e
fix: gradient background on top charts (#181) 2026-01-26 13:03:27 -05:00
Gabe Farrell
c8a11ef018
fix: ensure mbids in mbidmapping are discovered (#180) 2026-01-25 15:51:07 -05:00
Gabe Farrell
937f9062b5
fix: include time zone name overrides and add KOITO_FORCE_TZ cfg option (#176)
* timezone overrides and force_tz option

* docs for force_tz

* add link to time zone names in docs
2026-01-24 13:19:04 -05:00
Gabe Farrell
1ed055d098
fix: ui tweaks and fixes (#170)
* add subtle gradient to home page

* tweak autumn theme primary color

* reduce home page top margin on mobile

* use focus-active instead of focus for outline

* fix gradient on rewind page

* align checkbox on login form

* i forgot what the pseudo class was called
2026-01-22 21:31:14 -05:00
Gabe Farrell
08fc9eed86
fix: correct interest bucket queries (#169) 2026-01-22 17:01:46 -05:00
Gabe Farrell
cb4d177875
fix: release associations and add cleanup migration (#168)
* fix: release associations and add cleanup migration

* fix: incorrect test
2026-01-22 15:33:38 -05:00
Gabe Farrell
16cee8cfca
fix: speedup top-artists and top-albums queries (#167) 2026-01-21 17:30:59 -05:00
onespaceman
c59c6c3baa
QOL changes to client (#165) 2026-01-21 16:03:27 -05:00
Gabe Farrell
e7ba34710c
feat: lastfm image support (#166)
* feat: lastfm image support

* docs
2026-01-21 16:03:05 -05:00
Gabe Farrell
56ac73d12b
fix: improve subsonic image searching (#164) 2026-01-21 14:54:52 -05:00
Gabe Farrell
1a8099e902
feat: refetch missing images on startup (#160)
* artist image refetching

* album image refetching

* remove unused var
2026-01-20 12:10:54 -05:00
Gabe Farrell
5e294b839c
feat: all time rank display (#149)
* add all time rank to item pages

* fix artist albums component

* add no rows check

* fix rewind page
2026-01-16 01:03:23 -05:00
d08e05220f docs: add disclaimer about subsonic config 2026-01-15 22:01:25 -05:00
c0de721a7c chore: ignore README for docker workflow 2026-01-15 21:27:59 -05:00
Gabe Farrell
d2d6924e05
fix: use sql rank (#148) 2026-01-15 21:08:30 -05:00
Gabe Farrell
aa7fddd518
fix: a couple ui fixes (#147)
* fix: reduce loading component width

* improve theme selector for mobile

* match interest graph width to activity grid
2026-01-15 20:21:05 -05:00
Gabe Farrell
1eb1cd0fd5
chore: call relay early to prevent missed relays (#145)
* chore: call relay early to prevent missed relays

* fix: get current time in tz for listen activity (#146)

* fix: get current time in tz for listen activity

* fix: adjust test to prevent timezone errors
2026-01-15 19:40:38 -05:00
Gabe Farrell
92648167f0
fix: get current time in tz for listen activity (#146)
* fix: get current time in tz for listen activity

* fix: adjust test to prevent timezone errors
2026-01-15 19:36:48 -05:00
Gabe Farrell
9dbdfe5e41
update README 2026-01-15 18:21:51 -05:00
Gabe Farrell
94108953ec
fix: conditional rendering on artist and album pages (#140) 2026-01-14 22:12:57 -05:00
Gabe Farrell
d87ed2eb97
fix: ensure listen activity correctly sums listen activity in step (#139)
* remove impossible nil check

* fix listen activity not correctly aggregating step

* remove stray log

* fix test
2026-01-14 21:35:01 -05:00
Gabe Farrell
3305ad269e
Add Star History section to README
Added Star History section with visualization.
2026-01-14 17:21:52 -05:00
Gabe Farrell
20bbf62254
update README
Added logo and Ko-Fi badge to README.
2026-01-14 14:47:21 -05:00
Gabe Farrell
a94584da23
create FUNDING.yml 2026-01-14 14:06:14 -05:00
Gabe Farrell
8223a29be6
fix: correctly cycle tracks in backfill (#138) 2026-01-14 12:46:17 -05:00
231e751be3 docs: add navidrome quickstart guide 2026-01-14 01:26:01 -05:00
feef66da12 fix: add required parameters for subsonic request 2026-01-14 01:09:17 -05:00
Gabe Farrell
25d7bb41c1
Revise README for project status and update screenshots
Updated project status to reflect active development and instability. Added new images to the screenshots section and made minor text adjustments.

Also since when does AI write GitHub default commit messages...
2026-01-14 00:24:19 -05:00
Gabe Farrell
df59605418
feat: backfill duration from musicbrainz (#135)
* feat: backfill durations from musicbrainz

* chore: make request body dump info level
2026-01-14 00:08:05 -05:00
Gabe Farrell
288d04d714
fix: ui tweaks and fixes (#134) 2026-01-13 23:25:31 -05:00
Gabe Farrell
c2a0987946
fix: improved mobile ui for rewind (#133) 2026-01-13 11:13:54 -05:00
6e7b4e0522 fix: rewind ui bug 2026-01-13 01:02:25 -05:00
Gabe Farrell
62267652ba
feat: improve rewind page (#130)
* add timeframe selectors for rewind

* alter rewind nav to default to monthly rewind

* fix rewind default page

* remove superfluous parameters
2026-01-12 23:22:29 -05:00
Gabe Farrell
ddb0becc0f
fix: ui fixes and koito import time config fix (#128)
* fix: add import time checking to koito import

* adjust interest graph css

* show musicbrainz link when not logged in

* remove chart animation

* change interest steps to 16
2026-01-12 17:44:33 -05:00
Gabe Farrell
231eb1b0fb
feat: interest over time graph (#127)
* api

* ui

* test

* add margin to prevent clipping
2026-01-12 16:20:31 -05:00
Gabe Farrell
e45099c71a
fix: improve matching with identically named albums (#126)
* fix: improve matching with identically named albums

* fix: incorrect sql query
2026-01-12 13:03:04 -05:00
Gabe Farrell
97cd378535
feat: add endpoint and ui to update mbz id (#125)
* wip

* wip

* feat: add endpoint and ui to update mbz id
2026-01-11 01:50:27 -05:00
Gabe Farrell
7cf7cd3a10
feat: add musicbrainz link where possible (#124) 2026-01-11 01:39:56 -05:00
Gabe Farrell
d61e814306
fix: do not update mbz id when one already exists (#123) 2026-01-11 01:39:41 -05:00
Gabe Farrell
f51771bc34
feat: add ranks to top items charts (#122) 2026-01-11 00:15:46 -05:00
d3faa9728e chore: use named volume in dev 2026-01-11 00:03:46 -05:00
Gabe Farrell
f48dd6c039
fix: respect client timezone for requests (#119)
* maybe fixed for total listen activity

* maybe actually fixed now

* fix unset location panics
2026-01-10 01:45:31 -05:00
2925425750 docs: only release docs on new version 2026-01-01 18:41:07 -05:00
Gabe Farrell
c346c7cb31
fix: associate tracks with release when scrobbling (#118) 2026-01-01 02:40:27 -05:00
Gabe Farrell
d327729bff
transition time ranged queries to timeframe (#117) 2026-01-01 01:56:16 -05:00
ad3c51a70e fix: mobile ui for rewind 2025-12-31 19:23:20 -05:00
Gabe Farrell
d4ac96f780
feat: Rewind (#116)
* wip

* chore: update counts to allow unix timeframe

* feat: add db functions for counting new items

* wip: endpoint working

* wip

* wip: initial ui done

* add header, adjust ui

* add time listened toggle

* fix layout, year param

* param fixes
2025-12-31 18:44:55 -05:00
c0a8c64243 fix: dont build new image when only docs change 2025-12-29 12:54:23 -05:00
456b84c4ca docs: clarify token usage 2025-12-28 23:32:10 -05:00
e69ef0cb01 fix: revert to default theme if saved theme is invalid 2025-11-30 19:25:15 -05:00
682e543aa5 feat: replace asuka theme with new rosebud theme 2025-11-30 19:14:34 -05:00
20bc343fd8 chore: rm changelog 2025-11-30 19:14:14 -05:00
1bceeeb2f6 fix: add message to suggest dnd local file 2025-11-24 23:57:52 -05:00
fda416fe75 feat: set primary artist option for tracks 2025-11-24 23:49:39 -05:00
383be25bfc fix: use minutes instead of hours for time listened 2025-11-24 23:43:58 -05:00
63d953b192 fix: make all-time stats look better 2025-11-24 20:13:55 -05:00
fdaea6284e fix: better error handling on client 2025-11-24 20:05:46 -05:00
fed2c5b956 chore: add gitignore 2025-11-20 22:51:09 -05:00
Gabe Farrell
daa1bb2456
feat: config to gate all statistics behind login (#99)
* feat: gate all stats behind login

* docs: add config reference for login gate
2025-11-20 22:50:15 -05:00
Matt Foxx
c77481fd59
feat: Add unix timestamp date range parameters for fetching paginated listens (#98) 2025-11-20 11:43:09 -05:00
Gabe Farrell
620e3b65cb
chore: Update issue templates 2025-11-20 03:22:55 -05:00
6a53fca8f3 chore: update workflow to eliminate dev branch 2025-11-19 20:42:37 -05:00
Gabe Farrell
36f984a1a2
Pre-release version v0.0.14 (#96)
* add dev branch container to workflow

* correctly set the default range of ActivityGrid

* fix: set name/short_name to koito (#61)

* fix dev container push workflow

* fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* fix: set first artist listed as primary by default (#81)

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* docs: add default theme cfg option to docs

* feat: add ability to manually scrobble track (#91)

* feat: add button to manually scrobble from ui

* fix: ensure timestamp is in the past, log fix

* test: add integration test

* feat: add first listened to dates for media items (#92)

* fix: ensure error checks for ErrNoRows

* feat: add now playing endpoint and ui (#93)

* wip

* feat: add now playing

* fix: set default theme when config is not set

* feat: fetch images from subsonic server (#94)

* fix: useQuery instead of useEffect for now playing

* feat: custom artist separator regex (#95)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* fix: rebase errors

---------

Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com>
Co-authored-by: mlandry <mike.landry@gmail.com>
Co-authored-by: m0d3rnX <jesper@posteo.de>
2025-11-19 20:26:56 -05:00
m0d3rnX
bf0ec68cfe
docs: add example for usage of the main listenbrainz instance (#71)
* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>
2025-09-27 19:08:52 -04:00
b980026f1f fix: add buildx for multi-platform build 2025-07-26 16:12:24 -04:00
Gabe Farrell
5419178012
Pre-release version v0.0.13 (#52)
* feat: search/merge items by id

* feat: update track duration using musicbrainz

* chore: changelog

* fix: make username updates case insensitive

* feat: add minutes listened to ui and fix image drop

* chore: changelog

* fix: embed db migrations (#37)

* feat: Add support for ARM in publish workflow (#51)

* chore: changelog

* docs: search by id and custom theme support

---------

Co-authored-by: potatoattack <lvl70nub@gmail.com>
Co-authored-by: Benjamin Jonard <benjaminjonard@users.noreply.github.com>
2025-07-26 15:57:46 -04:00
5537b6fb89 fix: reset mbid for artists when native importing 2025-06-18 17:46:48 -04:00
b32c5d3735 fix: ensure duration is saved when inserting track 2025-06-18 09:03:00 -04:00
Gabe Farrell
c16b557c21
feat: v0.0.10 (#23)
* feat: single SOT for themes + basic custom support

* fix: adjust colors for yuu theme

* feat: Allow loading of environment variables from file (#20)

* feat: allow loading of environment variables from file

* Panic if a file for an environment variable cannot be read

* Use log.Fatalf + os.Exit instead of panic

* fix: remove supurfluous call to os.Exit()

---------

Co-authored-by: adaexec <nixos-git.s1pht@simplelogin.com>
Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* chore: add pr test workflow

* chore: changelog

* feat: make all activity grids configurable

* fix: adjust activity grid style

* fix: make background gradient consistent size

* revert: remove year from activity grid opts

* style: adjust top item list min size to 200px

* feat: add support for custom themes

* fix: stabilized the order of top items

* chore: update changelog

* feat: native import & export

* fix: use correct request body for alias requests

* fix: clear input when closing edit modal

* chore: changelog

* docs: make endpoint clearer for some apps

* feat: add ui and handler for export

* fix: fix pr test workflow

---------

Co-authored-by: adaexec <78047743+adaexec@users.noreply.github.com>
Co-authored-by: adaexec <nixos-git.s1pht@simplelogin.com>
2025-06-18 08:48:19 -04:00
486f5d0269 chore: update changelog 2025-06-17 17:14:12 -04:00
31d57fd79a fix: strip sub-second precision from incoming listens 2025-06-17 17:08:09 -04:00
80b6f4deaa feat: v0.0.8 2025-06-16 21:55:39 -04:00
00e7782be2 chore: update changelog 2025-06-16 11:28:26 -04:00
5a8b999f73 fix: hide delete listen button when not logged in 2025-06-16 11:22:53 -04:00
ef064cd9bd fix: use correct form body for login and user update 2025-06-16 11:14:11 -04:00
b1bac4feb5 fix: remove old test 2025-06-15 22:28:43 -04:00
2981ec4e8a chore: update changelog 2025-06-15 22:27:10 -04:00
57cc60534d feat: mark album as various artists 2025-06-15 22:26:17 -04:00
dc5dcbd474 fix: associate artists with merged items 2025-06-15 22:25:55 -04:00
bf9b84a171 fix: bump dockerfile go version 2025-06-15 19:19:46 -04:00
242a82ad8c feat: v0.0.5 2025-06-15 19:09:44 -04:00
212 changed files with 15620 additions and 5495 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
KOITO_ALLOWED_HOSTS=*
KOITO_LOG_LEVEL=debug
KOITO_CONFIG_DIR=test_config_dir
KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable
TZ=Etc/UTC

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
ko_fi: gabehf

30
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View file

@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug] "
labels: bug
assignees: gabehf
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots/Logs**
If applicable, add screenshots to help explain your problem and any relevant logs with `KOITO_LOG_LEVEL=debug` if possible.
**Version (please complete the following information):**
- Koito version: v0.0.X
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Enhancement] "
labels: enhancement
assignees: gabehf
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Why would you like this feature to be added?**
A clear description of why this feature might be useful for you will help inform development decisions.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -2,10 +2,13 @@ name: Deploy to GitHub Pages
on: on:
push: push:
branches: [main] tags:
- "v*"
paths: paths:
- 'docs/**' - "docs/**"
- '.github/workflows/**' - ".github/workflows/**"
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@ -21,9 +24,9 @@ jobs:
- name: Install, build, and upload your site output - name: Install, build, and upload your site output
uses: withastro/action@v4 uses: withastro/action@v4
with: with:
path: ./docs # The root location of your Astro project inside the repository. (optional) path: ./docs # The root location of your Astro project inside the repository. (optional)
node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional) node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional)
package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional) package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
deploy: deploy:
needs: build needs: build

View file

@ -12,66 +12,64 @@ name: Publish Docker image
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
branches:
- main
paths-ignore:
- "docs/**"
- "README.md"
workflow_dispatch:
jobs: jobs:
test: test:
name: Go Test name: Go Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Install libvips - name: Install libvips
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libvips-dev sudo apt-get install -y libvips-dev
- name: Verify libvips install - name: Verify libvips install
run: vips --version run: vips --version
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...
- name: Test - name: Test
uses: robherley/go-test-action@v0 uses: robherley/go-test-action@v0
push_to_registry: push_to_registry:
name: Push Docker image to Docker Hub name: Push Docker image to Docker Hub (release)
if: startsWith(github.ref, 'refs/tags/')
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps: steps:
- name: Check out the repo - uses: actions/checkout@v4
uses: actions/checkout@v4
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Set up Docker Buildx
id: meta uses: docker/setup-buildx-action@v3
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: gabehf/koito
- name: Extract tag version - name: Extract tag version
id: extract_version
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Build and push Docker image - name: Build and push release image
id: push id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@ -81,10 +79,34 @@ jobs:
gabehf/koito:${{ env.KOITO_VERSION }} gabehf/koito:${{ env.KOITO_VERSION }}
build-args: | build-args: |
KOITO_VERSION=${{ env.KOITO_VERSION }} KOITO_VERSION=${{ env.KOITO_VERSION }}
platforms: linux/amd64,linux/arm64
- name: Generate artifact attestation push_dev:
uses: actions/attest-build-provenance@v2 name: Push Docker image (dev branch)
if: github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with: with:
subject-name: index.docker.io/gabehf/koito username: ${{ secrets.DOCKER_USERNAME }}
subject-digest: ${{ steps.push.outputs.digest }} password: ${{ secrets.DOCKER_TOKEN }}
push-to-registry: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push dev image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
gabehf/koito:dev
gabehf/koito:dev-${{ github.sha }}
build-args: |
KOITO_VERSION=dev
platforms: linux/amd64,linux/arm64

32
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Test
on:
pull_request:
branches:
- main
jobs:
test:
name: Go Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install libvips
run: |
sudo apt-get update
sudo apt-get install -y libvips-dev
- name: Verify libvips install
run: vips --version
- name: Build
run: go build -v ./...
- name: Test
uses: robherley/go-test-action@v0

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
test_config_dir
.env

View file

@ -1,3 +0,0 @@
# v0.0.4
## Enhancements
- Re-download images missing from cache on request

View file

@ -11,7 +11,7 @@ COPY ./client .
RUN yarn run build RUN yarn run build
FROM golang:1.23 AS backend FROM golang:1.24 AS backend
ARG KOITO_VERSION ARG KOITO_VERSION
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1

View file

@ -1,3 +1,8 @@
ifneq (,$(wildcard ./.env))
include .env
export
endif
.PHONY: all test clean client .PHONY: all test clean client
postgres.schemadump: postgres.schemadump:
@ -10,7 +15,10 @@ postgres.schemadump:
-v --dbname="koitodb" -f "/tmp/dump/schema.sql" -v --dbname="koitodb" -f "/tmp/dump/schema.sql"
postgres.run: postgres.run:
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres docker run --name koito-db -p 5432:5432 -v koito_dev_db:/var/lib/postgresql -e POSTGRES_PASSWORD=secret -d postgres
postgres.run-scratch:
docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres
postgres.start: postgres.start:
docker start koito-db docker start koito-db
@ -18,8 +26,17 @@ postgres.start:
postgres.stop: postgres.stop:
docker stop koito-db docker stop koito-db
api.debug: postgres.remove:
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go docker stop koito-db && docker rm koito-db
postgres.remove-scratch:
docker stop koito-scratch && docker rm koito-scratch
api.debug: postgres.start
go run cmd/api/main.go
api.scratch: postgres.run-scratch
KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go
api.test: api.test:
go test ./... -timeout 60s go test ./... -timeout 60s

View file

@ -1,9 +1,21 @@
# Koito <div align="center">
![Koito logo](https://github.com/user-attachments/assets/bd69a050-b40f-4da7-8ff1-4607554bfd6d)
*Koito (小糸) is a Japanese surname. It is also homophonous with the words 恋と (koi to), meaning "and/with love".*
</div>
<div align="center">
[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/gabehf)
</div>
Koito is a modern, themeable ListenBrainz-compatible scrobbler for self-hosters who want control over their data and insights into their listening habits. Koito is a modern, themeable ListenBrainz-compatible scrobbler for self-hosters who want control over their data and insights into their listening habits.
It supports relaying to other compatible scrobblers, so you can try it safely without replacing your current setup. It supports relaying to other compatible scrobblers, so you can try it safely without replacing your current setup.
> This project is currently pre-release, and therefore you can expect rapid development and some bugs. If you don't want to replace your current scrobbler > This project is under active development and still considered "unstable", and therefore you can expect some bugs. If you don't want to replace your current scrobbler
with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible
scrobbler. This is what I've been doing for the entire development of this app and it hasn't failed me once. Or, you can always use something scrobbler. This is what I've been doing for the entire development of this app and it hasn't failed me once. Or, you can always use something
like [multi-scrobbler](https://github.com/FoxxMD/multi-scrobbler). like [multi-scrobbler](https://github.com/FoxxMD/multi-scrobbler).
@ -23,8 +35,9 @@ You can view my public instance with my listening data at https://koito.mnrva.de
## Screenshots ## Screenshots
![screenshot one](assets/screenshot1.png) ![screenshot one](assets/screenshot1.png)
![screenshot two](assets/screenshot2.png) <img width="2021" height="1330" alt="image" src="https://github.com/user-attachments/assets/956748ff-f61f-4102-94b2-50783d9ee72b" />
![screenshot three](assets/screenshot3.png) <img width="1505" height="1018" alt="image" src="https://github.com/user-attachments/assets/5f7e1162-f723-4e4b-a528-06cf26d1d870" />
## Installation ## Installation
@ -75,6 +88,16 @@ There are currently some known issues that I am actively working on, in addition
If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next. If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next.
## Star History
<a href="https://www.star-history.com/#gabehf/koito&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=gabehf/koito&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=gabehf/koito&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=gabehf/koito&type=date&legend=top-left" />
</picture>
</a>
## Albums that fueled development + notes ## Albums that fueled development + notes
More relevant here than any of my other projects... More relevant here than any of my other projects...
@ -84,5 +107,4 @@ Not just during development, you can see my complete listening data on my [live
#### Random notes #### Random notes
- I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to... - I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to...
- It's funny how you can see the days in my listening history when I was just working on this project because they have way more listens than other days.
- About 50% of the reason I built this was minor/not-so-minor greivances with Maloja. Could I have just contributed to Maloja? Maybe, but I like building stuff and I like Koito's UI a lot more anyways. - About 50% of the reason I built this was minor/not-so-minor greivances with Maloja. Could I have just contributed to Maloja? Maybe, but I like building stuff and I like Koito's UI a lot more anyways.

BIN
assets/Jost-Regular.ttf Normal file

Binary file not shown.

Binary file not shown.

View file

@ -1,287 +1,501 @@
interface getItemsArgs { interface getItemsArgs {
limit: number, limit: number;
period: string, period: string;
page: number, page: number;
artist_id?: number, artist_id?: number;
album_id?: number, album_id?: number;
track_id?: number track_id?: number;
} }
interface getActivityArgs { interface getActivityArgs {
step: string step: string;
range: number range: number;
month: number month: number;
year: number year: number;
artist_id: number artist_id: number;
album_id: number album_id: number;
track_id: number track_id: number;
}
interface timeframe {
week?: number;
month?: number;
year?: number;
from?: number;
to?: number;
period?: string;
}
interface getInterestArgs {
buckets: number;
artist_id: number;
album_id: number;
track_id: number;
} }
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> { async function handleJson<T>(r: Response): Promise<T> {
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>>) if (!r.ok) {
const err = await r.json();
throw Error(err.error);
}
return (await r.json()) as T;
}
async function getLastListens(
args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
const r = await 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}`
);
return handleJson<PaginatedResponse<Listen>>(r);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { async function getTopTracks(
if (args.artist_id) { args: getItemsArgs
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>>) ): Promise<PaginatedResponse<Ranked<Track>>> {
} else if (args.album_id) { let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
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 { if (args.artist_id) url += `&artist_id=${args.artist_id}`;
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) else if (args.album_id) url += `&album_id=${args.album_id}`;
}
const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Track>>>(r);
} }
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { async function getTopAlbums(
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` args: getItemsArgs
if (args.artist_id) { ): Promise<PaginatedResponse<Ranked<Album>>> {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>) let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
} else { if (args.artist_id) url += `&artist_id=${args.artist_id}`;
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
} const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Album>>>(r);
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { async function getTopArtists(
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` args: getItemsArgs
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>) ): Promise<PaginatedResponse<Ranked<Artist>>> {
const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Artist>>>(r);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { async function getActivity(
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[]>) args: getActivityArgs
): Promise<ListenActivityItem[]> {
const r = await 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}`
);
return handleJson<ListenActivityItem[]>(r);
} }
function getStats(period: string): Promise<Stats> { async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>) const r = await fetch(
`/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
);
return handleJson<InterestBucket[]>(r);
}
async function getStats(period: string): Promise<Stats> {
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
return handleJson<Stats>(r);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>) q = encodeURIComponent(q);
return fetch(`/apis/web/v1/search?q=${q}`).then(
(r) => r.json() as Promise<SearchResponse>
);
} }
function imageUrl(id: string, size: string) { function imageUrl(id: string, size: string) {
if (!id) { if (!id) {
id = 'default' id = "default";
} }
return `/images/${size}/${id}` return `/images/${size}/${id}`;
} }
function replaceImage(form: FormData): Promise<Response> { function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, { return fetch(`/apis/web/v1/replace-image`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function mergeTracks(from: number, to: number): Promise<Response> { function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST", method: "POST",
}) });
} }
function mergeAlbums(from: number, to: number): Promise<Response> { function mergeAlbums(
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, { from: number,
method: "POST", to: number,
}) replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST",
}
);
} }
function mergeArtists(from: number, to: number): Promise<Response> { function mergeArtists(
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, { from: number,
method: "POST", to: number,
}) replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST",
}
);
} }
function login(username: string, password: string, remember: boolean): Promise<Response> { function login(
return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, { username: string,
method: "POST", password: string,
}) remember: boolean
): Promise<Response> {
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
form.append("remember_me", String(remember));
return fetch(`/apis/web/v1/login`, {
method: "POST",
body: form,
});
} }
function logout(): Promise<Response> { function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, { return fetch(`/apis/web/v1/logout`, {
method: "POST", method: "POST",
}) });
}
function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
}
function submitListen(id: string, ts: Date): Promise<Response> {
const form = new URLSearchParams();
form.append("track_id", id);
const ms = new Date(ts).getTime();
const unix = Math.floor(ms / 1000);
form.append("unix", unix.toString());
return fetch(`/apis/web/v1/listen`, {
method: "POST",
body: form,
});
} }
function getApiKeys(): Promise<ApiKey[]> { function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then(
(r) => r.json() as Promise<ApiKey[]>
);
} }
const createApiKey = async (label: string): Promise<ApiKey> => { const createApiKey = async (label: string): Promise<ApiKey> => {
const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, { const form = new URLSearchParams();
method: "POST" form.append("label", label);
}); const r = await fetch(`/apis/web/v1/user/apikeys`, {
if (!r.ok) { method: "POST",
let errorMessage = `error: ${r.status}`; body: form,
try { });
const errorData: ApiError = await r.json(); if (!r.ok) {
if (errorData && typeof errorData.error === 'string') { let errorMessage = `error: ${r.status}`;
errorMessage = errorData.error; try {
} const errorData: ApiError = await r.json();
} catch (e) { if (errorData && typeof errorData.error === "string") {
console.error("unexpected api error:", e); errorMessage = errorData.error;
} }
throw new Error(errorMessage); } catch (e) {
console.error("unexpected api error:", e);
} }
const data: ApiKey = await r.json(); throw new Error(errorMessage);
return data; }
const data: ApiKey = await r.json();
return data;
}; };
function deleteApiKey(id: number): Promise<Response> { function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateApiKeyLabel(id: number, label: string): Promise<Response> { function updateApiKeyLabel(id: number, label: string): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, { const form = new URLSearchParams();
method: "PATCH" form.append("id", String(id));
}) form.append("label", label);
return fetch(`/apis/web/v1/user/apikeys`, {
method: "PATCH",
body: form,
});
} }
function deleteItem(itemType: string, id: number): Promise<Response> { function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, { return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateUser(username: string, password: string) { function updateUser(username: string, password: string) {
return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, { const form = new URLSearchParams();
method: "PATCH" form.append("username", username);
}) form.append("password", password);
return fetch(`/apis/web/v1/user`, {
method: "PATCH",
body: form,
});
} }
function getAliases(type: string, id: number): Promise<Alias[]> { function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as 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> { function createAlias(
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { type: string,
method: 'POST' id: number,
}) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases`, {
method: "POST",
body: form,
});
} }
function deleteAlias(type: string, id: number, alias: string): Promise<Response> { function deleteAlias(
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { type: string,
method: "DELETE" id: number,
}) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/delete`, {
method: "POST",
body: form,
});
} }
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> { function setPrimaryAlias(
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, { type: string,
method: "POST" id: number,
}) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/primary`, {
method: "POST",
body: form,
});
}
function updateMbzId(
type: string,
id: number,
mbzid: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("mbz_id", mbzid);
return fetch(`/apis/web/v1/mbzid`, {
method: "PATCH",
body: form,
});
}
function getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(
(r) => r.json() as Promise<Album>
);
} }
function deleteListen(listen: Listen): Promise<Response> { function deleteListen(listen: Listen): Promise<Response> {
const ms = new Date(listen.time).getTime() const ms = new Date(listen.time).getTime();
const unix= Math.floor(ms / 1000); const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE" method: "DELETE",
}) });
}
function getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
}
async function getRewindStats(args: timeframe): Promise<RewindStats> {
const r = await fetch(
`/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}`
);
return handleJson<RewindStats>(r);
} }
export { export {
getLastListens, getLastListens,
getTopTracks, getTopTracks,
getTopAlbums, getTopAlbums,
getTopArtists, getTopArtists,
getActivity, getActivity,
getStats, getInterest,
search, getStats,
replaceImage, search,
mergeTracks, replaceImage,
mergeAlbums, mergeTracks,
mergeArtists, mergeAlbums,
imageUrl, mergeArtists,
login, imageUrl,
logout, login,
deleteItem, logout,
updateUser, getCfg,
getAliases, deleteItem,
createAlias, updateUser,
deleteAlias, getAliases,
setPrimaryAlias, createAlias,
getApiKeys, deleteAlias,
createApiKey, setPrimaryAlias,
deleteApiKey, updateMbzId,
updateApiKeyLabel, getApiKeys,
deleteListen, createApiKey,
} deleteApiKey,
updateApiKeyLabel,
deleteListen,
getAlbum,
getExport,
submitListen,
getNowPlaying,
getRewindStats,
};
type Track = { type Track = {
id: number id: number;
title: string title: string;
artists: SimpleArtists[] artists: SimpleArtists[];
listen_count: number listen_count: number;
image: string image: string;
album_id: number album_id: number;
musicbrainz_id: string musicbrainz_id: string;
} time_listened: number;
first_listen: number;
all_time_rank: number;
};
type Artist = { type Artist = {
id: number id: number;
name: string name: string;
image: string, image: string;
aliases: string[] aliases: string[];
listen_count: number listen_count: number;
musicbrainz_id: string musicbrainz_id: string;
} time_listened: number;
first_listen: number;
is_primary: boolean;
all_time_rank: number;
};
type Album = { type Album = {
id: number, id: number;
title: string title: string;
image: string image: string;
listen_count: number listen_count: number;
is_various_artists: boolean is_various_artists: boolean;
artists: SimpleArtists[] artists: SimpleArtists[];
musicbrainz_id: string musicbrainz_id: string;
} time_listened: number;
first_listen: number;
all_time_rank: number;
};
type Alias = { type Alias = {
id: number id: number;
alias: string alias: string;
source: string source: string;
is_primary: boolean is_primary: boolean;
} };
type Listen = { type Listen = {
time: string, time: string;
track: Track, track: Track;
} };
type PaginatedResponse<T> = { type PaginatedResponse<T> = {
items: T[], items: T[];
total_record_count: number, total_record_count: number;
has_next_page: boolean, has_next_page: boolean;
current_page: number, current_page: number;
items_per_page: number, items_per_page: number;
} };
type Ranked<T> = {
item: T;
rank: number;
};
type ListenActivityItem = { type ListenActivityItem = {
start_time: Date, start_time: Date;
listens: number listens: number;
} };
type InterestBucket = {
bucket_start: Date;
bucket_end: Date;
listen_count: number;
};
type SimpleArtists = { type SimpleArtists = {
name: string name: string;
id: number id: number;
} };
type Stats = { type Stats = {
listen_count: number listen_count: number;
track_count: number track_count: number;
album_count: number album_count: number;
artist_count: number artist_count: number;
hours_listened: number minutes_listened: number;
} };
type SearchResponse = { type SearchResponse = {
albums: Album[] albums: Album[];
artists: Artist[] artists: Artist[];
tracks: Track[] tracks: Track[];
} };
type User = { type User = {
id: number id: number;
username: string username: string;
role: 'user' | 'admin' role: "user" | "admin";
} };
type ApiKey = { type ApiKey = {
id: number id: number;
key: string key: string;
label: string label: string;
created_at: Date created_at: Date;
} };
type ApiError = { type ApiError = {
error: string error: string;
} };
type Config = {
default_theme: string;
};
type NowPlaying = {
currently_playing: boolean;
track: Track;
};
type RewindStats = {
title: string;
top_artists: Ranked<Artist>[];
top_albums: Ranked<Album>[];
top_tracks: Ranked<Track>[];
minutes_listened: number;
avg_minutes_listened_per_day: number;
plays: number;
avg_plays_per_day: number;
unique_tracks: number;
unique_albums: number;
unique_artists: number;
new_tracks: number;
new_albums: number;
new_artists: number;
};
export type { export type {
getItemsArgs, getItemsArgs,
getActivityArgs, getActivityArgs,
Track, getInterestArgs,
Artist, Track,
Album, Artist,
Listen, Album,
SearchResponse, Listen,
PaginatedResponse, SearchResponse,
ListenActivityItem, PaginatedResponse,
User, Ranked,
Alias, ListenActivityItem,
ApiKey, InterestBucket,
ApiError User,
} Alias,
ApiKey,
ApiError,
Config,
NowPlaying,
Stats,
RewindStats,
};

View file

@ -1,59 +1,56 @@
@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 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"; @import "tailwindcss";
@theme { @theme {
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif, --font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "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-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards; --animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale { @keyframes fade-in-scale {
0% { 0% {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
100% {
@keyframes fade-out-scale { opacity: 1;
0% { transform: scale(1);
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.95);
}
} }
}
--animate-fade-in: fade-in 0.1s ease forwards; @keyframes fade-out-scale {
--animate-fade-out: fade-out 0.1s ease forwards; 0% {
opacity: 1;
@keyframes fade-in { transform: scale(1);
0% {
opacity: 0;
}
100% {
opacity: 1;
}
} }
100% {
@keyframes fade-out { opacity: 0;
0% { transform: scale(0.95);
opacity: 1;
}
100% {
opacity: 0;
}
} }
}
--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 { :root {
--header-xl: 36px; --header-xl: 36px;
--header-lg: 28px; --header-lg: 28px;
@ -61,20 +58,21 @@
--header-sm: 16px; --header-sm: 16px;
--header-xl-weight: 600; --header-xl-weight: 600;
--header-weight: 600; --header-weight: 600;
--header-line-height: 3rem;
} }
@media (min-width: 60rem) { @media (min-width: 60rem) {
:root { :root {
--header-xl: 78px; --header-xl: 78px;
--header-lg: 28px; --header-lg: 36px;
--header-md: 22px; --header-md: 22px;
--header-sm: 16px; --header-sm: 16px;
--header-xl-weight: 600; --header-xl-weight: 600;
--header-weight: 600; --header-weight: 600;
--header-line-height: 1.3em;
} }
} }
html, html,
body { body {
background-color: var(--color-bg); background-color: var(--color-bg);
@ -102,21 +100,24 @@ h1 {
font-family: "League Spartan"; font-family: "League Spartan";
font-weight: var(--header-weight); font-weight: var(--header-weight);
font-size: var(--header-xl); font-size: var(--header-xl);
line-height: var(--header-line-height);
} }
h2 { h2 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-lg);
}
h3 {
font-family: "League Spartan"; font-family: "League Spartan";
font-weight: var(--header-weight); font-weight: var(--header-weight);
font-size: var(--header-md); font-size: var(--header-md);
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
h3 { h4 {
font-family: "League Spartan"; font-family: "League Spartan";
font-size: var(--header-sm); font-size: var(--header-sm);
font-weight: var(--header-weight); font-weight: var(--header-weight);
} }
h4 {
font-size: var(--header-md);
}
.header-font { .header-font {
font-family: "League Spartan"; font-family: "League Spartan";
} }
@ -132,23 +133,21 @@ h4 {
text-decoration: underline; text-decoration: underline;
} }
input[type="text"] { input[type="text"],
input[type="password"],
textarea {
border: 1px solid var(--color-bg); border: 1px solid var(--color-bg);
} }
input[type="text"]:focus { input[type="checkbox"] {
outline: none; height: fit-content;
border: 1px solid var(--color-fg-tertiary);
} }
input[type="password"] { input:focus-visible,
border: 1px solid var(--color-bg); button:focus-visible,
} a:focus-visible,
input[type="password"]:focus { select:focus-visible,
outline: none; textarea:focus-visible {
border: 1px solid var(--color-fg-tertiary); border-color: transparent;
} outline: 2px solid var(--color-fg-tertiary);
input[type="checkbox"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
} }
button:hover { button:hover {

View file

@ -1,186 +1,196 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api" import {
import Popup from "./Popup" getActivity,
import { useEffect, useState } from "react" type getActivityArgs,
import { useTheme } from "~/hooks/useTheme" type ListenActivityItem,
import ActivityOptsSelector from "./ActivityOptsSelector" } from "api/api";
import Popup from "./Popup";
import { useState } from "react";
import { useTheme } from "~/hooks/useTheme";
import ActivityOptsSelector from "./ActivityOptsSelector";
import type { Theme } from "~/styles/themes.css";
function getPrimaryColor(): string { function getPrimaryColor(theme: Theme): string {
const value = getComputedStyle(document.documentElement) const value = theme.primary;
.getPropertyValue('--color-primary') const rgbMatch = value.match(
.trim(); /^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("");
}
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); return value;
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 { interface Props {
step?: string step?: string;
range?: number range?: number;
month?: number month?: number;
year?: number year?: number;
artistId?: number artistId?: number;
albumId?: number albumId?: number;
trackId?: number trackId?: number;
configurable?: boolean configurable?: boolean;
autoAdjust?: boolean autoAdjust?: boolean;
} }
export default function ActivityGrid({ export default function ActivityGrid({
step = 'day', step = "day",
range = 182, range = 182,
month = 0, month = 0,
year = 0, year = 0,
artistId = 0, artistId = 0,
albumId = 0, albumId = 0,
trackId = 0, trackId = 0,
configurable = false, configurable = false,
autoAdjust = false, }: Props) {
}: Props) { const [stepState, setStep] = useState(step);
const [rangeState, setRange] = useState(range);
const [color, setColor] = useState(getPrimaryColor()) const { isPending, isError, data, error } = useQuery({
const [stepState, setStep] = useState(step) queryKey: [
const [rangeState, setRange] = useState(range) "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 { isPending, isError, data, error } = useQuery({ const { theme } = useTheme();
queryKey: [ const color = getPrimaryColor(theme);
'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
}
}
return (<div className="flex flex-col items-start">
<h2>Activity</h2>
{configurable ? (
<ActivityOptsSelector
rangeSetter={setRange}
currentRange={rangeState}
stepSetter={setStep}
currentStep={stepState}
/>
) : (
''
)}
<div className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px]">
{data.map((item) => (
<div
key={new Date(item.start_time).toString()}
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
>
<Popup
position="top"
space={12}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
>
<div
style={{
display: 'inline-block',
background:
item.listens > 0
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
: 'var(--color-bg-secondary)',
}}
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
></div>
</Popup>
</div>
))}
</div>
</div>
if (isPending) {
return (
<div className="w-[350px]">
<h3>Activity</h3>
<p>Loading...</p>
</div>
); );
} else if (isError) {
return (
<div className="w-[350px]">
<h3>Activity</h3>
<p className="error">Error: {error.message}</p>
</div>
);
}
// 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 => {
// really ugly way to just check if this is for all items and not a specific item.
// is it jsut better to just pass the target in as a var? probably.
const adjustment =
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1;
// 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 * adjustment;
break;
case "week":
t = 20 * adjustment;
break;
case "month":
t = 50 * adjustment;
break;
case "year":
t = 100 * adjustment;
break;
}
v = Math.min(v, t);
return ((v - t) / t) * 0.8;
};
const CHUNK_SIZE = 26 * 7;
const chunks = [];
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
chunks.push(data.slice(i, i + CHUNK_SIZE));
}
return (
<div className="flex flex-col items-start">
<h3>Activity</h3>
{configurable ? (
<ActivityOptsSelector
rangeSetter={setRange}
currentRange={rangeState}
stepSetter={setStep}
currentStep={stepState}
/>
) : null}
{chunks.map((chunk, index) => (
<div
key={index}
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
>
{chunk.map((item) => (
<div
key={new Date(item.start_time).toString()}
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
>
<Popup
position="top"
space={12}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${
item.listens
} plays`}
>
<div
style={{
display: "inline-block",
background:
item.listens > 0
? LightenDarkenColor(
color,
getDarkenAmount(item.listens, 100)
)
: "var(--color-bg-secondary)",
}}
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
item.listens > 0
? ""
: "border-[0.5px] border-(--color-bg-tertiary)"
}`}
></div>
</Popup>
</div>
))}
</div>
))}
</div>
);
} }

View file

@ -1,4 +1,5 @@
import { useEffect } from "react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
interface Props { interface Props {
stepSetter: (value: string) => void; stepSetter: (value: string) => void;
@ -15,18 +16,15 @@ export default function ActivityOptsSelector({
currentRange, currentRange,
disableCache = false, disableCache = false,
}: Props) { }: Props) {
const stepPeriods = ['day', 'week', 'month', 'year']; const stepPeriods = ['day', 'week', 'month'];
const rangePeriods = [105, 182, 365]; const rangePeriods = [105, 182, 364];
const [collapsed, setCollapsed] = useState(true);
const stepDisplay = (str: string): string => { const setMenuOpen = (val: boolean) => {
return str.split('_').map(w => setCollapsed(val)
w.split('').map((char, index) => if (!disableCache) {
index === 0 ? char.toUpperCase() : char).join('') localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val));
).join(' '); }
};
const rangeDisplay = (r: number): string => {
return `${r}`
} }
const setStep = (val: string) => { const setStep = (val: string) => {
@ -45,53 +43,64 @@ export default function ActivityOptsSelector({
useEffect(() => { useEffect(() => {
if (!disableCache) { if (!disableCache) {
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35'); // TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default.
if (cachedRange) { const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182');
rangeSetter(cachedRange); if (cachedRange) rangeSetter(cachedRange);
}
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]); const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
if (cachedStep) { if (cachedStep) stepSetter(cachedStep);
stepSetter(cachedStep); const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]);
} if (cachedStep) setMenuOpen(cachedConfiguring !== "true");
} }
}, []); }, []);
return ( return (
<div className="flex flex-col"> <div className="relative w-full">
<div className="flex gap-2 items-center"> <button
<p>Step:</p> onClick={() => setMenuOpen(!collapsed)}
{stepPeriods.map((p, i) => ( className="absolute left-[75px] -top-9 text-muted hover:color-fg transition"
<div key={`step_selector_${p}`}> title="Toggle options"
<button >
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`} {collapsed ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
onClick={() => setStep(p)} </button>
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"> <div
<p>Range:</p> className={`overflow-hidden transition-[max-height,opacity] duration-250 ease ${
{rangePeriods.map((r, i) => ( collapsed ? 'max-h-0 opacity-0' : 'max-h-[100px] opacity-100'
<div key={`range_selector_${r}`}> }`}
<button >
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`} <div className="flex flex-wrap gap-4 mt-1 text-sm">
onClick={() => setRange(r)} <div className="flex items-center gap-1">
disabled={r === currentRange} <span className="text-muted">Step:</span>
> {stepPeriods.map((p) => (
{rangeDisplay(r)} <button
</button> key={p}
<span className="color-fg-secondary"> className={`px-1 rounded transition ${
{i !== rangePeriods.length - 1 ? '|' : ''} p === currentStep ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
</span> }`}
onClick={() => setStep(p)}
disabled={p === currentStep}
>
{p}
</button>
))}
</div> </div>
))}
<div className="flex items-center gap-1">
<span className="text-muted">Range:</span>
{rangePeriods.map((r) => (
<button
key={r}
className={`px-1 rounded transition ${
r === currentRange ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
}`}
onClick={() => setRange(r)}
disabled={r === currentRange}
>
{r}
</button>
))}
</div>
</div>
</div> </div>
</div> </div>
); );

View file

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

View file

@ -1,45 +1,58 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { getStats } from "api/api" import { getStats, type Stats, type ApiError } from "api/api";
export default function AllTimeStats() { export default function AllTimeStats() {
const { isPending, isError, data, error } = useQuery({
queryKey: ["stats", "all_time"],
queryFn: ({ queryKey }) => getStats(queryKey[1]),
});
const { isPending, isError, data, error } = useQuery({ const header = "All time stats";
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'
if (isPending) {
return ( return (
<div>
<h3>{header}</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<>
<div> <div>
<h2>All Time Stats</h2> <h3>{header}</h3>
<div> <p className="error">Error: {error.message}</p>
<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> </div>
) </>
);
}
const numberClasses = "header-font font-bold text-xl";
return (
<div>
<h3>{header}</h3>
<div>
<span
className={numberClasses}
title={Math.floor(data.minutes_listened / 60) + " hours"}
>
{data.minutes_listened}
</span>{" "}
Minutes Listened
</div>
<div>
<span className={numberClasses}>{data.listen_count}</span> Plays
</div>
<div>
<span className={numberClasses}>{data.track_count}</span> Tracks
</div>
<div>
<span className={numberClasses}>{data.album_count}</span> Albums
</div>
<div>
<span className={numberClasses}>{data.artist_count}</span> Artists
</div>
</div>
);
} }

View file

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

View file

@ -3,11 +3,10 @@ import { useEffect } from 'react';
interface Props { interface Props {
itemType: string, itemType: string,
id: number,
onComplete: Function onComplete: Function
} }
export default function ImageDropHandler({ itemType, id, onComplete }: Props) { export default function ImageDropHandler({ itemType, onComplete }: Props) {
useEffect(() => { useEffect(() => {
const handleDragOver = (e: DragEvent) => { const handleDragOver = (e: DragEvent) => {
console.log('dragover!!') console.log('dragover!!')
@ -25,7 +24,11 @@ export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
const formData = new FormData(); const formData = new FormData();
formData.append('image', imageFile); formData.append('image', imageFile);
formData.append(itemType.toLowerCase()+'_id', String(id)) const pathname = window.location.pathname;
const segments = pathname.split('/');
const filteredSegments = segments.filter(segment => segment !== '');
const lastSegment = filteredSegments[filteredSegments.length - 1];
formData.append(itemType.toLowerCase()+'_id', lastSegment)
replaceImage(formData).then((r) => { replaceImage(formData).then((r) => {
if (r.status >= 200 && r.status < 300) { if (r.status >= 200 && r.status < 300) {
onComplete() onComplete()

View file

@ -0,0 +1,112 @@
import { useQuery } from "@tanstack/react-query";
import { getInterest, type getInterestArgs } from "api/api";
import { useTheme } from "~/hooks/useTheme";
import type { Theme } from "~/styles/themes.css";
import { Area, AreaChart } from "recharts";
import { RechartsDevtools } from "@recharts/devtools";
function getPrimaryColor(theme: Theme): string {
const value = theme.primary;
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 {
buckets?: number;
artistId?: number;
albumId?: number;
trackId?: number;
}
export default function InterestGraph({
buckets = 16,
artistId = 0,
albumId = 0,
trackId = 0,
}: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"interest",
{
buckets: buckets,
artist_id: artistId,
album_id: albumId,
track_id: trackId,
},
],
queryFn: ({ queryKey }) => getInterest(queryKey[1] as getInterestArgs),
});
const { theme } = useTheme();
const color = getPrimaryColor(theme);
if (isPending) {
return (
<div className="w-[350px] sm:w-[500px]">
<h3>Interest over time</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[350px] sm:w-[500px]">
<h3>Interest over time</h3>
<p className="error">Error: {error.message}</p>
</div>
);
}
// Note: I would really like to have the animation for the graph, however
// the line graph can get weirdly clipped before the animation is done
// so I think I just have to remove it for now.
return (
<div className="flex flex-col items-start w-full max-w-[335px] sm:max-w-[500px]">
<h3>Interest over time</h3>
<AreaChart
style={{
width: "100%",
aspectRatio: 3.5,
maxWidth: 440,
overflow: "visible",
}}
data={data}
margin={{ top: 15, bottom: 20 }}
>
<defs>
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.5} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
dataKey="listen_count"
type="natural"
stroke="none"
fill="url(#colorGradient)"
animationDuration={0}
animationEasing="ease-in-out"
activeDot={false}
/>
<Area
dataKey="listen_count"
type="natural"
stroke={color}
fill="none"
strokeWidth={2}
animationDuration={0}
animationEasing="ease-in-out"
dot={false}
activeDot={false}
style={{ filter: `drop-shadow(0px 0px 0px ${color})` }}
/>
</AreaChart>
</div>
);
}

View file

@ -1,106 +1,156 @@
import { useState } from "react" import { useState } from "react";
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { timeSince } from "~/utils/utils" import { timeSince } from "~/utils/utils";
import ArtistLinks from "./ArtistLinks" import ArtistLinks from "./ArtistLinks";
import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api" import {
import { Link } from "react-router" deleteListen,
getLastListens,
getNowPlaying,
type getItemsArgs,
type Listen,
type Track,
} from "api/api";
import { Link } from "react-router";
import { useAppContext } from "~/providers/AppProvider";
interface Props { interface Props {
limit: number limit: number;
artistId?: Number artistId?: Number;
albumId?: Number albumId?: Number;
trackId?: number trackId?: number;
hideArtists?: boolean hideArtists?: boolean;
showNowPlaying?: boolean;
} }
export default function LastPlays(props: Props) { export default function LastPlays(props: Props) {
const { isPending, isError, data, error } = useQuery({ const { user } = useAppContext();
queryKey: ['last-listens', { const { isPending, isError, data, error } = useQuery({
limit: props.limit, queryKey: [
period: 'all_time', "last-listens",
artist_id: props.artistId, {
album_id: props.albumId, limit: props.limit,
track_id: props.trackId period: "all_time",
}], artist_id: props.artistId,
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), album_id: props.albumId,
}) track_id: props.trackId,
},
],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
});
const { data: npData } = useQuery({
queryKey: ["now-playing"],
queryFn: () => getNowPlaying(),
});
const [items, setItems] = useState<Listen[] | null>(null) const header = "Last played";
const handleDelete = async (listen: Listen) => { const [items, setItems] = useState<Listen[] | null>(null);
if (!data) return
try { const handleDelete = async (listen: Listen) => {
const res = await deleteListen(listen) if (!data) return;
if (res.ok || (res.status >= 200 && res.status < 300)) { try {
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) const res = await deleteListen(listen);
} else { if (res.ok || (res.status >= 200 && res.status < 300)) {
console.error("Failed to delete listen:", res.status) setItems((prev) =>
} (prev ?? data.items).filter((i) => i.time !== listen.time)
} catch (err) { );
console.error("Error deleting listen:", err) } else {
} console.error("Failed to delete listen:", res.status);
}
} catch (err) {
console.error("Error deleting listen:", err);
} }
};
if (isPending) { if (isPending) {
return (
<div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error: {error.message}</p>
}
const listens = items ?? data.items
let params = ''
params += props.artistId ? `&artist_id=${props.artistId}` : ''
params += props.albumId ? `&album_id=${props.albumId}` : ''
params += props.trackId ? `&track_id=${props.trackId}` : ''
return ( return (
<div className="text-sm sm:text-[16px]"> <div className="w-[300px] sm:w-[500px]">
<h2 className="hover:underline"> <h3>{header}</h3>
<Link to={`/listens?period=all_time${params}`}>Last Played</Link> <p>Loading...</p>
</h2> </div>
<table className="-ml-4"> );
<tbody> } else if (isError) {
{listens.map((item) => ( return (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> <div className="w-[300px] sm:w-[500px]">
<td className="w-[1px] pr-2 align-middle"> <h3>{header}</h3>
<button <p className="error">Error: {error.message}</p>
onClick={() => handleDelete(item)} </div>
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)" );
aria-label="Delete" }
>
× const listens = items ?? data.items;
</button>
</td> let params = "";
<td params += props.artistId ? `&artist_id=${props.artistId}` : "";
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" params += props.albumId ? `&album_id=${props.albumId}` : "";
title={new Date(item.time).toString()} params += props.trackId ? `&track_id=${props.trackId}` : "";
>
{timeSince(new Date(item.time))} return (
</td> <div className="text-sm sm:text-[16px]">
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> <h3 className="hover:underline">
{props.hideArtists ? null : ( <Link to={`/listens?period=all_time${params}`}>{header}</Link>
<> </h3>
<ArtistLinks artists={item.track.artists} /> {' '} <table className="-ml-4">
</> <tbody>
)} {props.showNowPlaying && npData && npData.currently_playing && (
<Link <tr className="group hover:bg-[--color-bg-secondary]">
className="hover:text-[--color-fg-secondary]" <td className="w-[18px] pr-2 align-middle"></td>
to={`/track/${item.track.id}`} <td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
> Now Playing
{item.track.title} </td>
</Link> <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
</td> {props.hideArtists ? null : (
</tr> <>
))} <ArtistLinks artists={npData.track.artists} /> {" "}
</tbody> </>
</table> )}
</div> <Link
) className="hover:text-[--color-fg-secondary]"
to={`/track/${npData.track.id}`}
>
{npData.track.title}
</Link>
</td>
</tr>
)}
{listens.map((item) => (
<tr
key={`last_listen_${item.time}`}
className="group hover:bg-[--color-bg-secondary]"
>
<td className="w-[18px] pr-2 align-middle">
<button
onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete"
hidden={user === null || user === undefined}
>
×
</button>
</td>
<td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()}
>
{timeSince(new Date(item.time))}
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : (
<>
<ArtistLinks artists={item.track.artists} /> {" "}
</>
)}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${item.track.id}`}
>
{item.track.title}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
} }

View file

@ -16,19 +16,19 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
const selectItem = (title: string, id: number) => { const selectItem = (title: string, id: number) => {
if (selected === id) { if (selected === id) {
setSelected(0) setSelected(0)
onSelect({id: id, title: title}) onSelect({id: 0, title: ''})
} else { } else {
setSelected(id) setSelected(id)
onSelect({id: id, title: title}) onSelect({id: id, title: title})
} }
} }
if (data === undefined) { if (!data) {
return <></> return <></>
} }
return ( return (
<div className="w-full"> <div className="w-full">
{ data.artists.length > 0 && { data.artists && data.artists.length > 0 &&
<> <>
<h3 className={hClasses}>Artists</h3> <h3 className={hClasses}>Artists</h3>
<div className={classes}> <div className={classes}>
@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
</div> </div>
</> </>
} }
{ data.albums.length > 0 && { data.albums && data.albums.length > 0 &&
<> <>
<h3 className={hClasses}>Albums</h3> <h3 className={hClasses}>Albums</h3>
<div className={classes}> <div className={classes}>
@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
</div> </div>
</> </>
} }
{ data.tracks.length > 0 && { data.tracks && data.tracks.length > 0 &&
<> <>
<h3 className={hClasses}>Tracks</h3> <h3 className={hClasses}>Tracks</h3>
<div className={classes}> <div className={classes}>

View file

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

View file

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

View file

@ -1,142 +1,171 @@
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import ArtistLinks from "./ArtistLinks"; import ArtistLinks from "./ArtistLinks";
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api"; import {
imageUrl,
type Album,
type Artist,
type Track,
type PaginatedResponse,
type Ranked,
} from "api/api";
type Item = Album | Track | Artist; type Item = Album | Track | Artist;
interface Props<T extends Item> { interface Props<T extends Ranked<Item>> {
data: PaginatedResponse<T> data: PaginatedResponse<T>;
separators?: ConstrainBoolean separators?: ConstrainBoolean;
type: "album" | "track" | "artist"; ranked?: boolean;
className?: string, type: "album" | "track" | "artist";
className?: string;
} }
export default function TopItemList<T extends Item>({ data, separators, type, className }: Props<T>) { export default function TopItemList<T extends Ranked<Item>>({
data,
separators,
type,
className,
ranked,
}: Props<T>) {
return (
<div className={`flex flex-col gap-1 ${className} min-w-[200px]`}>
{data.items.map((item, index) => {
const key = `${type}-${item.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
ranked={ranked}
rank={item.rank}
item={item.item}
type={type}
key={type + item.item.id}
/>
</div>
);
})}
</div>
);
}
return ( function ItemCard({
<div className={`flex flex-col gap-1 ${className} min-w-[300px]`}> item,
{data.items.map((item, index) => { type,
const key = `${type}-${item.id}`; rank,
return ( ranked,
<div }: {
key={key} item: Item;
style={{ fontSize: 12 }} type: "album" | "track" | "artist";
className={`${ rank: number;
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : '' ranked?: boolean;
}`} }) {
> const itemClasses = `flex items-center gap-2`;
<ItemCard item={item} type={type} key={type+item.id} />
</div> switch (type) {
); case "album": {
})} const album = item as Album;
return (
<div style={{ fontSize: 12 }} className={itemClasses}>
{ranked && <div className="w-7 text-end">{rank}</div>}
<Link to={`/album/${album.id}`}>
<img
loading="lazy"
src={imageUrl(album.image, "small")}
alt={album.title}
className="min-w-[48px]"
/>
</Link>
<div>
<Link
to={`/album/${album.id}`}
className="hover:text-(--color-fg-secondary)"
>
<span style={{ fontSize: 14 }}>{album.title}</span>
</Link>
<br />
{album.is_various_artists ? (
<span className="color-fg-secondary">Various Artists</span>
) : (
<div>
<ArtistLinks
artists={
album.artists
? [album.artists[0]]
: [{ id: 0, name: "Unknown Artist" }]
}
/>
</div>
)}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</div>
</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();
} }
case "track": {
const track = item as Track;
switch (type) { return (
case "album": { <div style={{ fontSize: 12 }} className={itemClasses}>
const album = item as Album; {ranked && <div className="w-7 text-end">{rank}</div>}
<Link to={`/track/${track.id}`}>
const handleKeyDown = (event: React.KeyboardEvent) => { <img
if (event.key === 'Enter') { loading="lazy"
handleItemClick("album", album.id); src={imageUrl(track.image, "small")}
} alt={track.title}
}; className="min-w-[48px]"
/>
return ( </Link>
<div style={{fontSize: 12}}> <div>
<div <Link
className={itemClasses} to={`/track/${track.id}`}
onClick={() => handleItemClick("album", album.id)} className="hover:text-(--color-fg-secondary)"
onKeyDown={handleKeyDown} >
role="link" <span style={{ fontSize: 14 }}>{track.title}</span>
tabIndex={0} </Link>
aria-label={`View album: ${album.title}`} <br />
style={{ cursor: 'pointer' }} <div>
> <ArtistLinks
<img src={imageUrl(album.image, "small")} alt={album.title} /> artists={track.artists || [{ id: 0, Name: "Unknown Artist" }]}
<div> />
<span style={{fontSize: 14}}>{album.title}</span> </div>
<br /> <div className="color-fg-secondary">{track.listen_count} plays</div>
{album.is_various_artists ? </div>
<span className="color-fg-secondary">Various Artists</span> </div>
: );
<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>
);
}
} }
case "artist": {
const artist = item as Artist;
return (
<div style={{ fontSize: 12 }} className={itemClasses}>
{ranked && <div className="w-7 text-end">{rank}</div>}
<Link
className={
itemClasses + " mt-1 mb-[6px] hover:text-(--color-fg-secondary)"
}
to={`/artist/${artist.id}`}
>
<img
loading="lazy"
src={imageUrl(artist.image, "small")}
alt={artist.name}
className="min-w-[48px]"
/>
<div>
<span style={{ fontSize: 14 }}>{artist.name}</span>
<div className="color-fg-secondary">
{artist.listen_count} plays
</div>
</div>
</Link>
</div>
);
}
}
} }

View file

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

View file

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

View file

@ -0,0 +1,23 @@
interface Props {
size: number;
hover?: boolean;
}
export default function MbzIcon({ 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="var(--color-fg)"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.582 0L1.418 5.832v12.336L11.582 24V10.01L7.1 12.668v3.664c.01.111.01.225 0 .336-.103.435-.54.804-1 1.111-.802.537-1.752.509-2.166-.111-.413-.62-.141-1.631.666-2.168.384-.28.863-.399 1.334-.332V6.619c0-.154.134-.252.226-.308L11.582 3zm.836 0v6.162c.574.03 1.14.16 1.668.387a2.225 2.225 0 0 0 1.656-.717 1.02 1.02 0 1 1 1.832-.803l.004.006a1.022 1.022 0 0 1-1.295 1.197c-.34.403-.792.698-1.297.85.34.263.641.576.891.928a1.04 1.04 0 0 1 .777.125c.768.486.568 1.657-.318 1.857-.886.2-1.574-.77-1.09-1.539.02-.03.042-.06.065-.09a3.598 3.598 0 0 0-1.436-1.166 4.142 4.142 0 0 0-1.457-.369v4.01c.855.06 1.256.493 1.555.834.227.256.356.39.578.402.323.018.568.008.806 0a5.44 5.44 0 0 1 .895.022c.94-.017 1.272-.226 1.605-.446a2.533 2.533 0 0 1 1.131-.463 1.027 1.027 0 0 1 .12-.263 1.04 1.04 0 0 1 .105-.137c.023-.025.047-.044.07-.066a4.775 4.775 0 0 1 0-2.405l-.012-.01a1.02 1.02 0 1 1 .692.272h-.057a4.288 4.288 0 0 0 0 1.877h.063a1.02 1.02 0 1 1-.545 1.883l-.047-.033a1 1 0 0 1-.352-.442 1.885 1.885 0 0 0-.814.354 3.03 3.03 0 0 1-.703.365c.757.555 1.772 1.6 2.199 2.299a1.03 1.03 0 0 1 .256-.033 1.02 1.02 0 1 1-.545 1.88l-.047-.03a1.017 1.017 0 0 1-.27-1.376.72.72 0 0 1 .051-.072c-.445-.775-2.026-2.28-2.46-2.387a4.037 4.037 0 0 0-1.31-.117c-.24.008-.513.018-.866 0-.515-.027-.783-.333-1.043-.629-.26-.296-.51-.56-1.055-.611V18.5a1.877 1.877 0 0 0 .426-.135.333.333 0 0 1 .058-.027c.56-.267 1.421-.91 2.096-2.447a1.02 1.02 0 0 1-.27-1.344 1.02 1.02 0 1 1 .915 1.54 6.273 6.273 0 0 1-1.432 2.136 1.785 1.785 0 0 1 .691.306.667.667 0 0 0 .37.168 3.31 3.31 0 0 0 .888-.222 1.02 1.02 0 0 1 1.787-.79v-.005a1.02 1.02 0 0 1-.773 1.683 1.022 1.022 0 0 1-.719-.287 3.935 3.935 0 0 1-1.168.287h-.05a1.313 1.313 0 0 1-.71-.275c-.262-.177-.51-.345-1.402-.12a2.098 2.098 0 0 1-.707.2V24l10.164-5.832V5.832zm4.154 4.904a.352.352 0 0 0-.197.639l.018.01c.163.1.378.053.484-.108v-.002a.352.352 0 0 0-.303-.539zm-4.99 1.928L7.082 9.5v2l4.5-2.668zm8.385.38a.352.352 0 0 0-.295.165v.002a.35.35 0 0 0 .096.473l.013.01a.357.357 0 0 0 .487-.108.352.352 0 0 0-.301-.541zM16.09 8.647a.352.352 0 0 0-.277.163.355.355 0 0 0 .296.54c.482 0 .463-.73-.02-.703zm3.877 2.477a.352.352 0 0 0-.295.164.35.35 0 0 0 .094.475l.015.01a.357.357 0 0 0 .485-.11.352.352 0 0 0-.3-.539zm-4.375 3.594a.352.352 0 0 0-.291.172.35.35 0 0 0-.04.265.352.352 0 1 0 .33-.437zm4.375.789a.352.352 0 0 0-.295.164v.002a.352.352 0 0 0 .094.473l.015.01a.357.357 0 0 0 .485-.108.352.352 0 0 0-.3-.54zm-2.803 2.488v.002a.347.347 0 0 0-.223.084.352.352 0 0 0 .23.62.347.347 0 0 0 .23-.085.348.348 0 0 0 .12-.24.353.353 0 0 0-.35-.38.347.347 0 0 0-.007 0Z"></path>
</svg>
</div>
);
}

View file

@ -1,106 +1,124 @@
import { logout, updateUser } from "api/api" import { logout, updateUser } from "api/api";
import { useState } from "react" import { useState } from "react";
import { AsyncButton } from "../AsyncButton" import { AsyncButton } from "../AsyncButton";
import { useAppContext } from "~/providers/AppProvider" import { useAppContext } from "~/providers/AppProvider";
export default function Account() { export default function Account() {
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [password, setPassword] = useState('') const [password, setPassword] = useState("");
const [confirmPw, setConfirmPw] = useState('') const [confirmPw, setConfirmPw] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [success, setSuccess] = useState('') const [success, setSuccess] = useState("");
const { user, setUsername: setCtxUsername } = useAppContext() const { user, setUsername: setCtxUsername } = useAppContext();
const logoutHandler = () => { const logoutHandler = () => {
setLoading(true) setLoading(true);
logout() logout()
.then(r => { .then((r) => {
if (r.ok) { if (r.ok) {
window.location.reload() window.location.reload();
} else { } else {
r.json().then(r => setError(r.error)) r.json().then((r) => setError(r.error));
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
setError('')
setSuccess('')
if (password != "" && confirmPw === "") {
setError("confirm your new password before submitting")
return
} }
setError('') })
setSuccess('') .catch((err) => setError(err));
setLoading(true) setLoading(false);
updateUser(username, password) };
.then(r => { const updateHandler = () => {
if (r.ok) { setError("");
setSuccess("sucessfully updated user") setSuccess("");
if (username != "") { if (password != "" && confirmPw === "") {
setCtxUsername(username) setError("confirm your new password before submitting");
} return;
setUsername('')
setPassword('')
setConfirmPw('')
} else {
r.json().then((r) => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} }
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 ( return (
<> <>
<h2>Account</h2> <h3>Account</h3>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center"> <div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p> <p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton> You're logged in as <strong>{user?.username}</strong>
</div> </p>
<h2>Update User</h2> <AsyncButton loading={loading} onClick={logoutHandler}>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4"> Logout
<div className="flex flex gap-4"> </AsyncButton>
<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="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<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 new 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>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div> </div>
</> <h3>Update User</h3>
) <form
action="#"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<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="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>
Submit
</AsyncButton>
</div>
</form>
<form
action="#"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<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 new 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>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>
</>
);
} }

View file

@ -0,0 +1,60 @@
import { useState } from "react";
import { Modal } from "./Modal";
import { AsyncButton } from "../AsyncButton";
import { submitListen } from "api/api";
import { useNavigate } from "react-router";
interface Props {
open: boolean;
setOpen: Function;
trackid: number;
}
export default function AddListenModal({ open, setOpen, trackid }: Props) {
const [ts, setTS] = useState<Date>(new Date());
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate();
const close = () => {
setOpen(false);
};
const submit = () => {
setLoading(true);
submitListen(trackid.toString(), ts).then((r) => {
if (r.ok) {
setLoading(false);
navigate(0);
} else {
r.json().then((r) => setError(r.error));
setLoading(false);
}
});
};
const formatForDatetimeLocal = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
d.getDate()
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
return (
<Modal isOpen={open} onClose={close}>
<h3>Add Listen</h3>
<div className="flex flex-col items-center gap-4">
<input
type="datetime-local"
className="w-full mx-auto fg bg rounded p-2"
value={formatForDatetimeLocal(ts)}
onChange={(e) => setTS(new Date(e.target.value))}
/>
<AsyncButton loading={loading} onClick={submit}>
Submit
</AsyncButton>
<p className="error">{error}</p>
</div>
</Modal>
);
}

View file

@ -5,172 +5,183 @@ import { useEffect, useRef, useState } from "react";
import { Copy, Trash } from "lucide-react"; import { Copy, Trash } from "lucide-react";
type CopiedState = { type CopiedState = {
x: number; x: number;
y: number; y: number;
visible: boolean; visible: boolean;
}; };
export default function ApiKeysModal() { export default function ApiKeysModal() {
const [input, setInput] = useState('') const [input, setInput] = useState("");
const [loading, setLoading ] = useState(false) const [loading, setLoading] = useState(false);
const [err, setError ] = useState<string>() const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<ApiKey[]>([]) const [displayData, setDisplayData] = useState<ApiKey[]>([]);
const [copied, setCopied] = useState<CopiedState | null>(null); const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({}); const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => { const handleRevealAndSelect = (key: string) => {
setExpandedKey(key); setExpandedKey(key);
setTimeout(() => { setTimeout(() => {
const el = textRefs.current[key]; const el = textRefs.current[key];
if (el) { if (el) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
const sel = window.getSelection(); const sel = window.getSelection();
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
} }
}, 0); }, 0);
}; };
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: ["api-keys"],
'api-keys' queryFn: () => {
], return getApiKeys();
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) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
const parentRect = (
e.currentTarget.closest(".relative") as HTMLElement
).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
y: buttonRect.top - parentRect.top - 8,
visible: true,
}); });
useEffect(() => { setTimeout(() => setCopied(null), 1500);
if (data) { };
setDisplayData(data)
}
}, [data])
if (isError) { const fallbackCopy = (text: string) => {
return ( const textarea = document.createElement("textarea");
<p className="error">Error: {error.message}</p> textarea.value = text;
) textarea.style.position = "fixed"; // prevent scroll to bottom
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} catch (err) {
console.error("Fallback: Copy failed", err);
} }
if (isPending) { document.body.removeChild(textarea);
return ( };
<p>Loading...</p>
) 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 handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => { const handleDeleteApiKey = (id: number) => {
if (navigator.clipboard && navigator.clipboard.writeText) { setError(undefined);
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); setLoading(true);
} else { deleteApiKey(id).then((r) => {
fallbackCopy(text); if (r.ok) {
} setDisplayData(displayData.filter((v) => v.id != id));
} else {
r.json().then((r) => setError(r.error));
}
});
setLoading(false);
};
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect(); return (
const buttonRect = e.currentTarget.getBoundingClientRect(); <div className="">
<h3>API Keys</h3>
setCopied({ <div className="flex flex-col gap-4 relative">
x: buttonRect.left - parentRect.left + buttonRect.width / 2, {displayData.map((v) => (
y: buttonRect.top - parentRect.top - 8, <div className="flex gap-2">
visible: true, <div
}); key={v.key}
ref={(el) => {
setTimeout(() => setCopied(null), 1500); textRefs.current[v.key] = el;
}; }}
onClick={() => handleRevealAndSelect(v.key)}
const fallbackCopy = (text: string) => { className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
const textarea = document.createElement("textarea"); expandedKey === v.key ? "" : "truncate"
textarea.value = text; }`}
textarea.style.position = "fixed"; // prevent scroll to bottom style={{ whiteSpace: "nowrap" }}
document.body.appendChild(textarea); title={v.key} // optional tooltip
textarea.focus(); >
textarea.select(); {expandedKey === v.key
try { ? v.key
document.execCommand("copy"); : `${v.key.slice(0, 8)}... ${v.label}`}
} catch (err) {
console.error("Fallback: Copy failed", err);
}
document.body.removeChild(textarea);
};
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
key={v.key}
ref={el => {
textRefs.current[v.key] = el;
}}
onClick={() => handleRevealAndSelect(v.key)}
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
expandedKey === v.key ? '' : 'truncate'
}`}
style={{ whiteSpace: 'nowrap' }}
title={v.key} // optional tooltip
>
{expandedKey === v.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> </div>
{err && <p className="error">{err}</p>} <button
{copied?.visible && ( onClick={(e) => handleCopy(e, v.key)}
<div className="large-button px-5 rounded-md"
style={{ >
position: "absolute", <Copy size={16} />
top: copied.y, </button>
left: copied.x, <AsyncButton
transform: "translate(-50%, -100%)", loading={loading}
}} onClick={() => handleDeleteApiKey(v.id)}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade" confirm
> >
Copied! <Trash size={16} />
</div> </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> </div>
</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>
);
} }

View file

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

View file

@ -0,0 +1,165 @@
import { useQuery } from "@tanstack/react-query";
import {
createAlias,
deleteAlias,
getAliases,
setPrimaryAlias,
updateMbzId,
type Alias,
} from "api/api";
import { Modal } from "../Modal";
import { AsyncButton } from "../../AsyncButton";
import { useEffect, useState } from "react";
import { Trash } from "lucide-react";
import SetVariousArtists from "./SetVariousArtist";
import SetPrimaryArtist from "./SetPrimaryArtist";
import UpdateMbzID from "./UpdateMbzID";
interface Props {
type: string;
id: number;
open: boolean;
setOpen: Function;
}
export default function EditModal({ 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("no input");
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);
};
const handleClose = () => {
setOpen(false);
setInput("");
};
return (
<Modal maxW={1000} isOpen={open} onClose={handleClose}>
<div className="flex flex-col items-start gap-6 w-full">
<div className="w-full">
<h3>Alias Manager</h3>
<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>
</div>
{type.toLowerCase() === "album" && (
<>
<SetVariousArtists id={id} />
<SetPrimaryArtist id={id} type="album" />
</>
)}
{type.toLowerCase() === "track" && (
<SetPrimaryArtist id={id} type="track" />
)}
<UpdateMbzID type={type} id={id} />
</div>
</Modal>
);
}

View file

@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { getAlbum, type Artist } from "api/api";
import { useEffect, useState } from "react";
interface Props {
id: number;
type: string;
}
export default function SetPrimaryArtist({ id, type }: Props) {
const [err, setErr] = useState("");
const [primary, setPrimary] = useState<Artist>();
const [success, setSuccess] = useState("");
const { isPending, isError, data, error } = useQuery({
queryKey: [
"get-artists-" + type.toLowerCase(),
{
id: id,
},
],
queryFn: () => {
return fetch(
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
).then((r) => r.json()) as Promise<Artist[]>;
},
});
useEffect(() => {
if (data) {
for (let a of data) {
if (a.is_primary) {
setPrimary(a);
break;
}
}
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const updatePrimary = (artist: number, val: boolean) => {
setErr("");
setSuccess("");
fetch(
`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
).then((r) => {
if (r.ok) {
setSuccess("successfully updated primary artists");
} else {
r.json().then((r) => setErr(r.error));
}
});
};
return (
<div className="w-full">
<h3>Set Primary Artist</h3>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-60 px-3 py-2 rounded-md"
value={primary?.name || ""}
onChange={(e) => {
for (let a of data) {
if (a.name === e.target.value) {
setPrimary(a);
updatePrimary(a.id, true);
}
}
}}
>
<option value="" disabled>
Select an artist
</option>
{data.map((a) => (
<option key={a.id} value={a.name}>
{a.name}
</option>
))}
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { getAlbum } from "api/api";
import { useEffect, useState } from "react";
interface Props {
id: number;
}
export default function SetVariousArtists({ id }: Props) {
const [err, setErr] = useState("");
const [va, setVA] = useState(false);
const [success, setSuccess] = useState("");
const { isPending, isError, data, error } = useQuery({
queryKey: [
"get-album",
{
id: id,
},
],
queryFn: ({ queryKey }) => {
const params = queryKey[1] as { id: number };
return getAlbum(params.id);
},
});
useEffect(() => {
if (data) {
setVA(data.is_various_artists);
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const updateVA = (val: boolean) => {
setErr("");
setSuccess("");
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, {
method: "PATCH",
}).then((r) => {
if (r.ok) {
setSuccess("Successfully updated album");
} else {
r.json().then((r) => setErr(r.error));
}
});
};
return (
<div className="w-full">
<h3>Mark as Various Artists</h3>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-30 px-3 py-2 rounded-md"
value={va.toString()}
onChange={(e) => {
const val = e.target.value === "true";
setVA(val);
updateVA(val);
}}
>
<option value="true">True</option>
<option value="false">False</option>
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { updateMbzId } from "api/api";
import { useState } from "react";
import { AsyncButton } from "~/components/AsyncButton";
interface Props {
type: string;
id: number;
}
export default function UpdateMbzID({ type, id }: Props) {
const [err, setError] = useState<string | undefined>();
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [mbzid, setMbzid] = useState<"">();
const [success, setSuccess] = useState("");
const handleUpdateMbzID = () => {
setError(undefined);
if (input === "") {
setError("no input");
return;
}
setLoading(true);
updateMbzId(type, id, input).then((r) => {
if (r.ok) {
setSuccess("successfully updated MusicBrainz ID");
} else {
r.json().then((r) => setError(r.error));
}
});
setLoading(false);
};
return (
<div className="w-full">
<h3>Update MusicBrainz ID</h3>
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Update MusicBrainz ID"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleUpdateMbzID}>
Submit
</AsyncButton>
</div>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
);
}

View file

@ -0,0 +1,47 @@
import { useState } from "react";
import { AsyncButton } from "../AsyncButton";
import { getExport } from "api/api";
export default function ExportModal() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleExport = () => {
setLoading(true);
fetch(`/apis/web/v1/export`, {
method: "GET",
})
.then((res) => {
if (res.ok) {
res.blob().then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "koito_export.json";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
setLoading(false);
});
} else {
res.json().then((r) => setError(r.error));
setLoading(false);
}
})
.catch((err) => {
setError(err);
setLoading(false);
});
};
return (
<div>
<h3>Export</h3>
<AsyncButton loading={loading} onClick={handleExport}>
Export Data
</AsyncButton>
{error && <p className="error">{error}</p>}
</div>
);
}

View file

@ -5,86 +5,111 @@ import SearchResults from "../SearchResults";
import { AsyncButton } from "../AsyncButton"; import { AsyncButton } from "../AsyncButton";
interface Props { interface Props {
type: string type: string;
id: number id: number;
musicbrainzId?: string musicbrainzId?: string;
open: boolean open: boolean;
setOpen: Function setOpen: Function;
} }
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) { export default function ImageReplaceModal({
const [query, setQuery] = useState(''); musicbrainzId,
const [loading, setLoading] = useState(false) type,
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true) id,
open,
setOpen,
}: Props) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true);
const doImageReplace = (url: string) => { const doImageReplace = (url: string) => {
setLoading(true) setLoading(true);
const formData = new FormData setError("");
formData.set(`${type.toLowerCase()}_id`, id.toString()) const formData = new FormData();
formData.set("image_url", url) formData.set(`${type.toLowerCase()}_id`, id.toString());
replaceImage(formData) formData.set("image_url", url);
.then((r) => { replaceImage(formData)
if (r.ok) { .then((r) => {
window.location.reload() if (r.status >= 200 && r.status < 300) {
} else { window.location.reload();
console.log(r) } else {
setLoading(false) r.json().then((r) => setError(r.error));
} setLoading(false);
}) }
.catch((err) => console.log(err)) })
} .catch((err) => setError(err));
};
const closeModal = () => { const closeModal = () => {
setOpen(false) setOpen(false);
setQuery('') setQuery("");
} setError("");
};
return ( return (
<Modal isOpen={open} onClose={closeModal}> <Modal isOpen={open} onClose={closeModal}>
<h2>Replace Image</h2> <h3>Replace Image</h3>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<input <input
type="text" type="text"
autoFocus autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal // 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`} placeholder={`Enter image URL, or drag-and-drop a local file`}
className="w-full mx-auto fg bg rounded p-2" className="w-full mx-auto fg bg rounded p-2"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} 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`}
/> />
{ query != "" ? </div>
<div className="flex gap-2 mt-4"> </button>
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton> </>
</div> : ) : (
''} ""
{ type === "Album" && musicbrainzId ? )}
<> <p className="error">{error}</p>
<h3 className="mt-5">Suggested Image (Click to Apply)</h3> </div>
<button </Modal>
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>
)
} }

View file

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

View file

@ -2,124 +2,159 @@ import { useEffect, useState } from "react";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api"; import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults"; import SearchResults from "../SearchResults";
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout"; import type {
MergeFunc,
MergeSearchCleanerFunc,
} from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
interface Props { interface Props {
open: boolean open: boolean;
setOpen: Function setOpen: Function;
type: string type: string;
currentId: number currentId: number;
currentTitle: string currentTitle: string;
mergeFunc: MergeFunc mergeFunc: MergeFunc;
mergeCleanerFunc: MergeSearchCleanerFunc mergeCleanerFunc: MergeSearchCleanerFunc;
} }
export default function MergeModal(props: Props) { export default function MergeModal(props: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState(props.currentTitle);
const [data, setData] = useState<SearchResponse>(); const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query); const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0}) const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
const [mergeOrderReversed, setMergeOrderReversed] = useState(false) { title: "", id: 0 }
const navigate = useNavigate() );
const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
const [replaceImage, setReplaceImage] = useState(false);
const navigate = useNavigate();
const closeMergeModal = () => {
props.setOpen(false);
setQuery("");
setData(undefined);
setMergeOrderReversed(false);
setMergeTarget({ title: "", id: 0 });
};
const closeMergeModal = () => { const toggleSelect = ({ title, id }: { title: string; id: number }) => {
props.setOpen(false) setMergeTarget({ title: title, id: id });
setQuery('') };
setData(undefined)
setMergeOrderReversed(false) useEffect(() => {
setMergeTarget({title: '', id: 0}) console.log("mergeTarget", 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
const toggleSelect = ({title, id}: {title: string, id: number}) => { .mergeFunc(from.id, to.id, replaceImage)
if (mergeTarget.id === 0) { .then((r) => {
setMergeTarget({title: title, id: id}) if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
closeMergeModal();
} else {
window.location.reload();
}
} else { } else {
setMergeTarget({title:"", id: 0}) // 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]);
useEffect(() => { return (
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.id}`)
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}> <Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2> <h3>Merge {props.type}s</h3>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<input <input
type="text" type="text"
autoFocus autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal defaultValue={props.currentTitle}
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`} // i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
className="w-full mx-auto fg bg rounded p-2" placeholder={`Search for a${props.type.toLowerCase()[0] === "a" ? "n" : ""
onChange={(e) => setQuery(e.target.value)} } ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
/> className="w-full mx-auto fg bg rounded p-2"
<SearchResults selectorMode data={data} onSelect={toggleSelect}/> onFocus={(e) => { setQuery(e.target.value); e.target.select()}}
{ mergeTarget.id !== 0 ? onChange={(e) => setQuery(e.target.value)}
<> />
{mergeOrderReversed ? <SearchResults selectorMode data={data} onSelect={toggleSelect} />
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p> {mergeTarget.id !== 0 ? (
: <>
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p> {mergeOrderReversed ? (
} <p className="mt-5">
<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> <strong>{props.currentTitle}</strong> will be merged into{" "}
<div className="flex gap-2 mt-3"> <strong>{mergeTarget.title}</strong>
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} /> </p>
<label htmlFor="reverse-merge-order">Reverse merge order</label> ) : (
<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 items-center 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>
</> : {(props.type.toLowerCase() === "album" ||
''} props.type.toLowerCase() === "artist") && (
</div> <div className="flex items-center gap-2 mt-3">
<input
type="checkbox"
name="replace-image"
checked={replaceImage}
onChange={() => setReplaceImage(!replaceImage)}
/>
<label htmlFor="replace-image">Replace image</label>
</div>
)}
</>
) : (
""
)}
</div>
</Modal> </Modal>
) );
} }

View file

@ -32,10 +32,34 @@ export function Modal({
} }
}, [isOpen, shouldRender]); }, [isOpen, shouldRender]);
// Close on Escape key // Handle keyboard events
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); // Close on Escape key
if (e.key === 'Escape') {
onClose()
// Trap tab navigation to the modal
} else if (e.key === 'Tab') {
if (modalRef.current) {
const focusableEls = modalRef.current.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const activeEl = document.activeElement
if (e.shiftKey && activeEl === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && activeEl === lastEl) {
e.preventDefault();
firstEl.focus();
} else if (!Array.from(focusableEls).find(node => node.isEqualNode(activeEl))) {
e.preventDefault();
firstEl.focus();
}
}
};
}; };
if (isOpen) document.addEventListener('keydown', handleKeyDown); if (isOpen) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
@ -70,13 +94,13 @@ export function Modal({
}`} }`}
style={{ maxWidth: maxW ?? 600, height: h ?? '' }} style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
> >
{children}
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer" className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
> >
🞪 🞪
</button> </button>
{children}
</div> </div>
</div>, </div>,
document.body document.body

View file

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

View file

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

View file

@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
import ThemeHelper from "../../routes/ThemeHelper"; import ThemeHelper from "../../routes/ThemeHelper";
import { useAppContext } from "~/providers/AppProvider"; import { useAppContext } from "~/providers/AppProvider";
import ApiKeysModal from "./ApiKeysModal"; import ApiKeysModal from "./ApiKeysModal";
import { AsyncButton } from "../AsyncButton";
import ExportModal from "./ExportModal";
interface Props { interface Props {
open: boolean open: boolean
@ -19,7 +21,7 @@ export default function SettingsModal({ open, setOpen } : Props) {
const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto" const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
return ( return (
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}> <Modal h={700} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Tabs <Tabs
defaultValue="Appearance" defaultValue="Appearance"
orientation="vertical" // still vertical, but layout is responsive via Tailwind orientation="vertical" // still vertical, but layout is responsive via Tailwind
@ -29,9 +31,12 @@ export default function SettingsModal({ open, setOpen } : Props) {
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger> <TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger> <TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
{user && ( {user && (
<TabsTrigger className={triggerClasses} value="API Keys"> <>
API Keys <TabsTrigger className={triggerClasses} value="API Keys">
</TabsTrigger> API Keys
</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Export">Export</TabsTrigger>
</>
)} )}
</TabsList> </TabsList>
@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) {
<TabsContent value="API Keys" className={contentClasses}> <TabsContent value="API Keys" className={contentClasses}>
<ApiKeysModal /> <ApiKeysModal />
</TabsContent> </TabsContent>
<TabsContent value="Export" className={contentClasses}>
<ExportModal />
</TabsContent>
</Tabs> </Tabs>
</Modal> </Modal>
) )

View file

@ -0,0 +1,79 @@
import { imageUrl, type RewindStats } from "api/api";
import RewindStatText from "./RewindStatText";
import { RewindTopItem } from "./RewindTopItem";
interface Props {
stats: RewindStats;
includeTime?: boolean;
}
export default function Rewind(props: Props) {
const artistimg = props.stats.top_artists[0]?.item.image;
const albumimg = props.stats.top_albums[0]?.item.image;
const trackimg = props.stats.top_tracks[0]?.item.image;
if (
!props.stats.top_artists[0] ||
!props.stats.top_albums[0] ||
!props.stats.top_tracks[0]
) {
return <p>Not enough data exists to create a Rewind for this period :(</p>;
}
return (
<div className="flex flex-col gap-7">
<h2>{props.stats.title}</h2>
<RewindTopItem
title="Top Artist"
imageSrc={imageUrl(artistimg, "medium")}
items={props.stats.top_artists}
getLabel={(a) => a.name}
includeTime={props.includeTime}
/>
<RewindTopItem
title="Top Album"
imageSrc={imageUrl(albumimg, "medium")}
items={props.stats.top_albums}
getLabel={(a) => a.title}
includeTime={props.includeTime}
/>
<RewindTopItem
title="Top Track"
imageSrc={imageUrl(trackimg, "medium")}
items={props.stats.top_tracks}
getLabel={(t) => t.title}
includeTime={props.includeTime}
/>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-y-5">
<RewindStatText
figure={`${props.stats.minutes_listened}`}
text="Minutes listened"
/>
<RewindStatText figure={`${props.stats.unique_tracks}`} text="Tracks" />
<RewindStatText
figure={`${props.stats.new_tracks}`}
text="New tracks"
/>
<RewindStatText figure={`${props.stats.plays}`} text="Plays" />
<RewindStatText figure={`${props.stats.unique_albums}`} text="Albums" />
<RewindStatText
figure={`${props.stats.new_albums}`}
text="New albums"
/>
<RewindStatText
figure={`${props.stats.avg_plays_per_day.toFixed(1)}`}
text="Plays per day"
/>
<RewindStatText
figure={`${props.stats.unique_artists}`}
text="Artists"
/>
<RewindStatText
figure={`${props.stats.new_artists}`}
text="New artists"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
interface Props {
figure: string;
text: string;
}
export default function RewindStatText(props: Props) {
return (
<div className="flex items-baseline gap-1.5">
<div className="w-23 text-end shrink-0">
<span
className="
relative inline-block
text-2xl font-semibold
"
>
<span
className="
absolute inset-0
-translate-x-2 translate-y-8
bg-(--color-primary)
z-0
h-1
"
aria-hidden
/>
<span className="relative z-1">{props.figure}</span>
</span>
</div>
<span className="text-sm">{props.text}</span>
</div>
);
}

View file

@ -0,0 +1,57 @@
import type { Ranked } from "api/api";
type TopItemProps<T> = {
title: string;
imageSrc: string;
items: Ranked<T>[];
getLabel: (item: T) => string;
includeTime?: boolean;
};
export function RewindTopItem<
T extends {
id: string | number;
listen_count: number;
time_listened: number;
}
>({ title, imageSrc, items, getLabel, includeTime }: TopItemProps<T>) {
const [top, ...rest] = items;
if (!top) return null;
return (
<div className="flex flex-col sm:flex-row gap-5">
<div className="rewind-top-item-image">
<img className="max-w-48 max-h-48" src={imageSrc} />
</div>
<div className="flex flex-col gap-1">
<h4 className="-mb-1">{title}</h4>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start mb-2">
<h2>{getLabel(top.item)}</h2>
<span className="text-(--color-fg-tertiary) -mt-3 text-sm">
{`${top.item.listen_count} plays`}
{includeTime
? ` (${Math.floor(top.item.time_listened / 60)} minutes)`
: ``}
</span>
</div>
</div>
{rest.map((e) => (
<div key={e.item.id} className="text-sm">
{getLabel(e.item)}
<span className="text-(--color-fg-tertiary)">
{` - ${e.item.listen_count} plays`}
{includeTime
? ` (${Math.floor(e.item.time_listened / 60)} minutes)`
: ``}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -1,13 +1,15 @@
import { ExternalLink, Home, Info } from "lucide-react"; import { ExternalLink, History, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch"; import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem"; import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings"; import SidebarSettings from "./SidebarSettings";
import { getRewindParams, getRewindYear } from "~/utils/utils";
export default function Sidebar() { export default function Sidebar() {
const iconSize = 20; const iconSize = 20;
return ( return (
<div className=" <div
className="
z-50 z-50
flex flex
sm:flex-col sm:flex-col
@ -28,28 +30,44 @@ export default function Sidebar() {
sm:px-1 sm:px-1
px-4 px-4
bg-(--color-bg) bg-(--color-bg)
"> "
<div className="flex gap-4 sm:flex-col"> >
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}> <div className="flex gap-4 sm:flex-col">
<Home size={iconSize} /> <SidebarItem
</SidebarItem> space={10}
<SidebarSearch size={iconSize} /> to="/"
</div> name="Home"
<div className="flex gap-4 sm:flex-col"> onClick={() => {}}
<SidebarItem modal={<></>}
icon >
keyHint={<ExternalLink size={14} />} <Home size={iconSize} />
space={22} </SidebarItem>
externalLink <SidebarSearch size={iconSize} />
to="https://koito.io" <SidebarItem
name="About" space={10}
onClick={() => {}} to="/rewind"
modal={<></>} name="Rewind"
> onClick={() => {}}
<Info size={iconSize} /> modal={<></>}
</SidebarItem> >
<SidebarSettings size={iconSize} /> <History size={iconSize} />
</div> </SidebarItem>
</div> </div>
); <div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
);
} }

View file

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

View file

@ -1,36 +1,78 @@
// ThemeSwitcher.tsx import { useState } from "react";
import { useEffect } from 'react'; import { useTheme } from "../../hooks/useTheme";
import { useTheme } from '../../hooks/useTheme'; import themes from "~/styles/themes.css";
import { themes } from '~/providers/ThemeProvider'; import ThemeOption from "./ThemeOption";
import ThemeOption from './ThemeOption'; import { AsyncButton } from "../AsyncButton";
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { theme, setTheme } = useTheme(); const { setTheme } = useTheme();
const initialTheme = {
bg: "#1e1816",
bgSecondary: "#2f2623",
bgTertiary: "#453733",
fg: "#f8f3ec",
fgSecondary: "#d6ccc2",
fgTertiary: "#b4a89c",
primary: "#f5a97f",
primaryDim: "#d88b65",
accent: "#f9db6d",
accentDim: "#d9bc55",
error: "#e26c6a",
warning: "#f5b851",
success: "#8fc48f",
info: "#87b8dd",
};
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
const [custom, setCustom] = useState(
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
);
useEffect(() => { const handleCustomTheme = () => {
const saved = localStorage.getItem('theme'); console.log(custom);
if (saved && saved !== theme) { try {
setTheme(saved); const themeData = JSON.parse(custom);
} else if (!saved) { setCustomTheme(themeData);
localStorage.setItem('theme', theme) setCustom(JSON.stringify(themeData, null, " "));
} console.log(themeData);
}, []); } catch (err) {
console.log(err);
}
};
useEffect(() => { return (
if (theme) { <div className="flex flex-col gap-10">
localStorage.setItem('theme', theme) <div>
} <div className="flex items-center gap-3">
}, [theme]); <h3>Select Theme</h3>
<div className="mb-3">
return ( <AsyncButton onClick={resetTheme}>Reset</AsyncButton>
<> </div>
<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> </div>
</> <div className="grid grid-cols-1 sm:grid-cols-2 items-center gap-2">
); {Object.entries(themes).map(([name, themeData]) => (
<ThemeOption
setTheme={setTheme}
key={name}
theme={themeData}
themeName={name}
/>
))}
</div>
</div>
<div>
<h3>Use Custom Theme</h3>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea
name="custom-theme"
onChange={(e) => setCustom(e.target.value)}
id="custom-theme-input"
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
value={custom}
/>
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div>
</div>
</div>
);
} }

View file

@ -1,10 +1,11 @@
import type { User } from "api/api"; import { getCfg, type User } from "api/api";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
interface AppContextType { interface AppContextType {
user: User | null | undefined; user: User | null | undefined;
configurableHomeActivity: boolean; configurableHomeActivity: boolean;
homeItems: number; homeItems: number;
defaultTheme: string;
setConfigurableHomeActivity: (value: boolean) => void; setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void; setHomeItems: (value: number) => void;
setUsername: (value: string) => void; setUsername: (value: string) => void;
@ -22,15 +23,19 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => { export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null | undefined>(undefined); const [user, setUser] = useState<User | null | undefined>(undefined);
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false); const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
undefined
);
const [configurableHomeActivity, setConfigurableHomeActivity] =
useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0); const [homeItems, setHomeItems] = useState<number>(0);
const setUsername = (value: string) => { const setUsername = (value: string) => {
if (!user) { if (!user) {
return return;
} }
setUser({...user, username: value}) setUser({ ...user, username: value });
} };
useEffect(() => { useEffect(() => {
fetch("/apis/web/v1/user/me") fetch("/apis/web/v1/user/me")
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true); setConfigurableHomeActivity(true);
setHomeItems(12); setHomeItems(12);
getCfg().then((cfg) => {
console.log(cfg);
if (cfg.default_theme !== "") {
setDefaultTheme(cfg.default_theme);
} else {
setDefaultTheme("yuu");
}
});
}, []); }, []);
if (user === undefined) { // Block rendering the app until config is loaded
if (user === undefined || defaultTheme === undefined) {
return null; return null;
} }
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user, user,
configurableHomeActivity, configurableHomeActivity,
homeItems, homeItems,
defaultTheme,
setConfigurableHomeActivity, setConfigurableHomeActivity,
setHomeItems, setHomeItems,
setUsername, setUsername,
}; };
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>; return (
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
);
}; };

View file

@ -1,259 +1,135 @@
import { createContext, useEffect, useState, type ReactNode } from 'react'; import {
createContext,
// a fair number of colors aren't actually used, but i'm keeping useEffect,
// them so that I don't have to worry about colors when adding new ui elements useState,
export type Theme = { useCallback,
name: string, type ReactNode,
bg: string } from "react";
bgSecondary: string import { type Theme, themes } from "~/styles/themes.css";
bgTertiary: string import { themeVars } from "~/styles/vars.css";
fg: string import { useAppContext } from "./AppProvider";
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 { interface ThemeContextValue {
theme: string; themeName: string;
theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
resetTheme: () => void;
setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined;
} }
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ function toKebabCase(str: string) {
theme: initialTheme, return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
children, }
}: {
theme: string; function applyCustomThemeVars(theme: Theme) {
children: ReactNode; const root = document.documentElement;
}) { for (const [key, value] of Object.entries(theme)) {
const [theme, setTheme] = useState(initialTheme); if (key === "name") continue;
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
}
}
function clearCustomThemeVars() {
for (const cssVar of Object.values(themeVars)) {
document.documentElement.style.removeProperty(cssVar);
}
}
function getStoredCustomTheme(): Theme | undefined {
const themeStr = localStorage.getItem("custom-theme");
if (!themeStr) return undefined;
try {
const parsed = JSON.parse(themeStr);
const { name, ...theme } = parsed;
return theme as Theme;
} catch {
return undefined;
}
}
export function ThemeProvider({ children }: { children: ReactNode }) {
let defaultTheme = useAppContext().defaultTheme;
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
const [themeName, setThemeName] = useState(
themes[initialTheme] ? initialTheme : defaultTheme
);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === "custom") {
const customTheme = getStoredCustomTheme();
return customTheme || themes[defaultTheme];
}
return themes[initialTheme] || themes[defaultTheme];
});
const setTheme = (newThemeName: string) => {
setThemeName(newThemeName);
if (newThemeName === "custom") {
const customTheme = getStoredCustomTheme();
if (customTheme) {
setCurrentTheme(customTheme);
} else {
// Fallback to default theme if no custom theme found
setThemeName(defaultTheme);
setCurrentTheme(themes[defaultTheme]);
}
} else {
const foundTheme = themes[newThemeName];
if (foundTheme) {
localStorage.setItem("theme", newThemeName);
setCurrentTheme(foundTheme);
} else {
setTheme(defaultTheme);
}
}
};
const resetTheme = () => {
setThemeName(defaultTheme);
localStorage.removeItem("theme");
setCurrentTheme(themes[defaultTheme]);
};
const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
applyCustomThemeVars(customTheme);
setThemeName("custom");
localStorage.setItem("theme", "custom");
setCurrentTheme(customTheme);
}, []);
const getCustomTheme = (): Theme | undefined => {
return getStoredCustomTheme();
};
useEffect(() => { useEffect(() => {
if (theme) { const root = document.documentElement;
document.documentElement.setAttribute('data-theme', theme);
root.setAttribute("data-theme", themeName);
if (themeName === "custom") {
applyCustomThemeVars(currentTheme);
} else {
clearCustomThemeVars();
} }
}, [theme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider value={{ theme, setTheme }}> <ThemeContext.Provider
value={{
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme,
}}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );
} }
export { ThemeContext } export { ThemeContext };

View file

@ -9,16 +9,19 @@ import {
} from "react-router"; } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import './themes.css' import "./themes.css";
import "./app.css"; import "./app.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from './providers/ThemeProvider'; import { ThemeProvider } from "./providers/ThemeProvider";
import Sidebar from "./components/sidebar/Sidebar"; import Sidebar from "./components/sidebar/Sidebar";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import { AppProvider } from "./providers/AppProvider"; import { AppProvider } from "./providers/AppProvider";
import { initTimezoneCookie } from "./tz";
initTimezoneCookie();
// Create a client // Create a client
const queryClient = new QueryClient() const queryClient = new QueryClient();
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
@ -35,14 +38,23 @@ export const links: Route.LinksFunction = () => [
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" style={{backgroundColor: 'black'}}> <html lang="en" style={{ backgroundColor: "black" }}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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/png"
href="/favicon-96x96.png"
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="Koito" /> <meta name="apple-mobile-web-app-title" content="Koito" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<Meta /> <Meta />
@ -58,81 +70,73 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
let theme = localStorage.getItem('theme') ?? 'yuu'
return ( return (
<> <>
<AppProvider> <AppProvider>
<ThemeProvider theme={theme}> <ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="flex-col flex sm:flex-row"> <div className="flex-col flex sm:flex-row">
<Sidebar /> <Sidebar />
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]"> <div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
<Outlet /> <Outlet />
<Footer /> <Footer />
</div> </div>
</div> </div>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
</AppProvider> </AppProvider>
</> </>
); );
} }
export function HydrateFallback() { export function HydrateFallback() {
return null return null;
} }
export function ErrorBoundary() { export function ErrorBoundary() {
const error = useRouteError(); const error = useRouteError();
let message = "Oops!"; let message = "Oops!";
let details = "An unexpected error occurred."; let details = "An unexpected error occurred.";
let stack: string | undefined; let stack: string | undefined;
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"; message = error.status === 404 ? "404" : "Error";
details = error.status === 404 details =
error.status === 404
? "The requested page could not be found." ? "The requested page could not be found."
: error.statusText || details; : error.statusText || details;
} else if (import.meta.env.DEV && error instanceof Error) { } else if (import.meta.env.DEV && error instanceof Error) {
details = error.message; details = error.message;
stack = error.stack; stack = error.stack;
} }
let theme = 'yuu' const title = `${message} - Koito`;
try {
theme = localStorage.getItem('theme') ?? theme
} catch(err) {
console.log(err)
}
const title = `${message} - Koito` return (
<AppProvider>
return ( <ThemeProvider>
<AppProvider> <title>{title}</title>
<ThemeProvider theme={theme}> <Sidebar />
<title>{title}</title> <div className="flex">
<div className="flex"> <div className="w-full flex flex-col">
<Sidebar /> <main className="pt-16 p-4 mx-auto flex-grow">
<div className="w-full flex flex-col"> <div className="md:flex gap-4">
<main className="pt-16 p-4 container mx-auto flex-grow"> <img className="w-[200px] rounded mb-3" src="../yuu.jpg" />
<div className="flex gap-4 items-end"> <div>
<img className="w-[200px] rounded" src="../yuu.jpg" /> <h1>{message}</h1>
<div> <p>{details}</p>
<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> </div>
</ThemeProvider> </div>
</AppProvider> {stack && (
); <pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
<Footer />
</div>
</div>
</ThemeProvider>
</AppProvider>
);
} }

View file

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

View file

@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList"; import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout"; import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router"; import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type PaginatedResponse } from "api/api"; import { type Album, type PaginatedResponse, type Ranked } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) { export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const page = url.searchParams.get("page") || "0"; const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page) url.searchParams.set("page", page);
const res = await fetch( const res = await fetch(
`/apis/web/v1/top-albums?${url.searchParams.toString()}` `/apis/web/v1/top-albums?${url.searchParams.toString()}`
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
} }
export default function AlbumChart() { export default function AlbumChart() {
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>(); const { top_albums: initialData } = useLoaderData<{
top_albums: PaginatedResponse<Ranked<Album>>;
}>();
return ( return (
<ChartLayout <ChartLayout
@ -28,26 +30,35 @@ export default function AlbumChart() {
initialData={initialData} initialData={initialData}
endpoint="chart/top-albums" endpoint="chart/top-albums"
render={({ data, page, onNext, onPrev }) => ( render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5 w-full">
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}> <button className="default" onClick={onPrev} disabled={page <= 1}>
Prev Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button
Next className="default"
</button> onClick={onNext}
</div> disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList <TopItemList
ranked
separators separators
data={data} data={data}
className="w-[400px] sm:w-[600px]" className="w-11/12 sm:w-[600px]"
type="album" type="album"
/> />
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}> <button className="default" onClick={onPrev} disabled={page === 0}>
Prev Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next Next
</button> </button>
</div> </div>

View file

@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList"; import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout"; import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router"; import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type PaginatedResponse } from "api/api"; import { type Album, type PaginatedResponse, type Ranked } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) { export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const page = url.searchParams.get("page") || "0"; const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page) url.searchParams.set("page", page);
const res = await fetch( const res = await fetch(
`/apis/web/v1/top-artists?${url.searchParams.toString()}` `/apis/web/v1/top-artists?${url.searchParams.toString()}`
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
} }
export default function Artist() { export default function Artist() {
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>(); const { top_artists: initialData } = useLoaderData<{
top_artists: PaginatedResponse<Ranked<Album>>;
}>();
return ( return (
<ChartLayout <ChartLayout
@ -28,26 +30,35 @@ export default function Artist() {
initialData={initialData} initialData={initialData}
endpoint="chart/top-artists" endpoint="chart/top-artists"
render={({ data, page, onNext, onPrev }) => ( render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5 w-full">
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}> <button className="default" onClick={onPrev} disabled={page <= 1}>
Prev Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button
Next className="default"
</button> onClick={onNext}
</div> disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList <TopItemList
ranked
separators separators
data={data} data={data}
className="w-[400px] sm:w-[600px]" className="w-11/12 sm:w-[600px]"
type="artist" type="artist"
/> />
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}> <button className="default" onClick={onPrev} disabled={page <= 1}>
Prev Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next Next
</button> </button>
</div> </div>

View file

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

View file

@ -4,6 +4,7 @@ import { deleteListen, type Listen, type PaginatedResponse } from "api/api";
import { timeSince } from "~/utils/utils"; import { timeSince } from "~/utils/utils";
import ArtistLinks from "~/components/ArtistLinks"; import ArtistLinks from "~/components/ArtistLinks";
import { useState } from "react"; import { useState } from "react";
import { useAppContext } from "~/providers/AppProvider";
export async function clientLoader({ request }: LoaderFunctionArgs) { export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
@ -25,6 +26,7 @@ export default function Listens() {
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>(); const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
const [items, setItems] = useState<Listen[] | null>(null) const [items, setItems] = useState<Listen[] | null>(null)
const { user } = useAppContext()
const handleDelete = async (listen: Listen) => { const handleDelete = async (listen: Listen) => {
if (!initialData) return if (!initialData) return
@ -61,11 +63,12 @@ export default function Listens() {
<tbody> <tbody>
{listens.map((item) => ( {listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> <tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[1px] pr-2 align-middle"> <td className="w-[17px] pr-2 align-middle">
<button <button
onClick={() => handleDelete(item)} onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)" className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete" aria-label="Delete"
hidden={user === null || user === undefined}
> >
× ×
</button> </button>

View file

@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList"; import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout"; import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router"; import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { type Album, type PaginatedResponse } from "api/api"; import { type Track, type PaginatedResponse, type Ranked } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) { export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const page = url.searchParams.get("page") || "0"; const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page) url.searchParams.set("page", page);
const res = await fetch( const res = await fetch(
`/apis/web/v1/top-tracks?${url.searchParams.toString()}` `/apis/web/v1/top-tracks?${url.searchParams.toString()}`
@ -15,12 +15,14 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
throw new Response("Failed to load top tracks", { status: 500 }); throw new Response("Failed to load top tracks", { status: 500 });
} }
const top_tracks: PaginatedResponse<Album> = await res.json(); const top_tracks: PaginatedResponse<Track> = await res.json();
return { top_tracks }; return { top_tracks };
} }
export default function TrackChart() { export default function TrackChart() {
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>(); const { top_tracks: initialData } = useLoaderData<{
top_tracks: PaginatedResponse<Ranked<Track>>;
}>();
return ( return (
<ChartLayout <ChartLayout
@ -28,26 +30,35 @@ export default function TrackChart() {
initialData={initialData} initialData={initialData}
endpoint="chart/top-tracks" endpoint="chart/top-tracks"
render={({ data, page, onNext, onPrev }) => ( render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5 w-full">
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}> <button className="default" onClick={onPrev} disabled={page <= 1}>
Prev Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button
Next className="default"
</button> onClick={onNext}
</div> disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList <TopItemList
ranked
separators separators
data={data} data={data}
className="w-[400px] sm:w-[600px]" className="w-11/12 sm:w-[600px]"
type="track" type="track"
/> />
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}> <button className="default" onClick={onPrev} disabled={page === 0}>
Prev Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next Next
</button> </button>
</div> </div>

View file

@ -10,30 +10,30 @@ import PeriodSelector from "~/components/PeriodSelector";
import { useAppContext } from "~/providers/AppProvider"; import { useAppContext } from "~/providers/AppProvider";
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [ return [{ title: "Koito" }, { name: "description", content: "Koito" }];
{ title: "Koito" },
{ name: "description", content: "Koito" },
];
} }
export default function Home() { export default function Home() {
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
const { homeItems } = useAppContext(); const { homeItems } = useAppContext();
return ( return (
<main className="flex flex-grow justify-center pb-4"> <main className="flex flex-grow justify-center pb-4 w-full bg-linear-to-b to-(--color-bg) from-(--color-bg-secondary) to-60%">
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20"> <div className="flex-1 flex flex-col items-center gap-16 min-h-0 sm:mt-20 mt-10">
<div className="flex flex-col md:flex-row gap-10 md:gap-20"> <div className="flex flex-col md:flex-row gap-10 md:gap-20">
<AllTimeStats /> <AllTimeStats />
<ActivityGrid /> <ActivityGrid configurable />
</div> </div>
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5"> <div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
<TopArtists period={period} limit={homeItems} /> <TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} /> <TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} /> <TopTracks period={period} limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.5)} /> <LastPlays
showNowPlaying={true}
limit={Math.floor(homeItems * 2.7)}
/>
</div> </div>
</div> </div>
</main> </main>

View file

@ -6,6 +6,8 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector"; import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`); const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
@ -18,40 +20,62 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Album() { export default function Album() {
const album = useLoaderData() as Album; const album = useLoaderData() as Album;
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
console.log(album) console.log(album);
return ( return (
<MediaLayout type="Album" <MediaLayout
title={album.title} type="Album"
img={album.image} title={album.title}
id={album.id} img={album.image}
musicbrainzId={album.musicbrainz_id} id={album.id}
imgItemId={album.id} rank={album.all_time_rank}
mergeFunc={mergeAlbums} musicbrainzId={album.musicbrainz_id}
mergeCleanerFunc={(r, id) => { imgItemId={album.id}
r.artists = [] mergeFunc={mergeAlbums}
r.tracks = [] mergeCleanerFunc={(r, id) => {
for (let i = 0; i < r.albums.length; i ++) { r.artists = [];
if (r.albums[i].id === id) { r.tracks = [];
delete r.albums[i] for (let i = 0; i < r.albums.length; i++) {
} if (r.albums[i].id === id) {
} delete r.albums[i];
return r }
}} }
subContent={<> return r;
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>} }}
</>} subContent={
<div className="flex flex-col gap-2 items-start">
{album.listen_count !== 0 && (
<p>
{album.listen_count} play{album.listen_count > 1 ? "s" : ""}
</p>
)}
{album.time_listened !== 0 && (
<p title={Math.floor(album.time_listened / 60 / 60) + " hours"}>
{timeListenedString(album.time_listened)}
</p>
)}
{album.first_listen > 0 && (
<p title={new Date(album.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(album.first_listen * 1000).toLocaleDateString()}
</p>
)}
</div>
}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
</div> </div>
<div className="flex flex-wrap gap-20 mt-10"> <div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} /> <LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} /> <TopTracks limit={12} period={period} albumId={album.id} />
<ActivityGrid autoAdjust configurable albumId={album.id} /> <div className="flex flex-col items-start gap-4">
<ActivityGrid configurable albumId={album.id} />
<InterestGraph albumId={album.id} />
</div> </div>
</div>
</MediaLayout> </MediaLayout>
); );
} }

View file

@ -7,6 +7,8 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums"; import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`); const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
@ -19,48 +21,70 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Artist() { export default function Artist() {
const artist = useLoaderData() as Artist; const artist = useLoaderData() as Artist;
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
// remove canonical name from alias list // remove canonical name from alias list
console.log(artist.aliases) console.log(artist.aliases);
let index = artist.aliases.indexOf(artist.name); let index = artist.aliases.indexOf(artist.name);
if (index !== -1) { if (index !== -1) {
artist.aliases.splice(index, 1); artist.aliases.splice(index, 1);
} }
return ( return (
<MediaLayout type="Artist" <MediaLayout
title={artist.name} type="Artist"
img={artist.image} title={artist.name}
id={artist.id} img={artist.image}
musicbrainzId={artist.musicbrainz_id} id={artist.id}
imgItemId={artist.id} rank={artist.all_time_rank}
mergeFunc={mergeArtists} musicbrainzId={artist.musicbrainz_id}
mergeCleanerFunc={(r, id) => { imgItemId={artist.id}
r.albums = [] mergeFunc={mergeArtists}
r.tracks = [] mergeCleanerFunc={(r, id) => {
for (let i = 0; i < r.artists.length; i ++) { r.albums = [];
if (r.artists[i].id === id) { r.tracks = [];
delete r.artists[i] for (let i = 0; i < r.artists.length; i++) {
} if (r.artists[i].id === id) {
} delete r.artists[i];
return r }
}} }
subContent={<> return r;
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>} }}
</>} subContent={
<div className="flex flex-col gap-2 items-start">
{artist.listen_count && (
<p>
{artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
</p>
)}
{artist.time_listened !== 0 && (
<p title={Math.floor(artist.time_listened / 60 / 60) + " hours"}>
{timeListenedString(artist.time_listened)}
</p>
)}
{artist.first_listen > 0 && (
<p title={new Date(artist.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(artist.first_listen * 1000).toLocaleDateString()}
</p>
)}
</div>
}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
</div> </div>
<div className="flex flex-col gap-20"> <div className="flex flex-col gap-20">
<div className="flex gap-15 mt-10 flex-wrap"> <div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} /> <LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} /> <TopTracks limit={8} period={period} artistId={artist.id} />
<ActivityGrid configurable autoAdjust artistId={artist.id} /> <div className="flex flex-col items-start gap-4">
</div> <ActivityGrid configurable artistId={artist.id} />
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} /> <InterestGraph artistId={artist.id} />
</div>
</div> </div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div>
</MediaLayout> </MediaLayout>
); );
} }

View file

@ -2,96 +2,208 @@ import React, { useEffect, useState } from "react";
import { average } from "color.js"; import { average } from "color.js";
import { imageUrl, type SearchResponse } from "api/api"; import { imageUrl, type SearchResponse } from "api/api";
import ImageDropHandler from "~/components/ImageDropHandler"; import ImageDropHandler from "~/components/ImageDropHandler";
import { Edit, ImageIcon, Merge, Trash } from "lucide-react"; import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
import { useAppContext } from "~/providers/AppProvider"; import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal"; import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal"; import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/RenameModal"; import RenameModal from "~/components/modals/EditModal/EditModal";
import EditModal from "~/components/modals/EditModal/EditModal";
import AddListenModal from "~/components/modals/AddListenModal";
import MbzIcon from "~/components/icons/MbzIcon";
import { Link } from "react-router";
export type MergeFunc = (from: number, to: number) => Promise<Response> export type MergeFunc = (
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse from: number,
to: number,
replaceImage: boolean
) => Promise<Response>;
export type MergeSearchCleanerFunc = (
r: SearchResponse,
id: number
) => SearchResponse;
interface Props { interface Props {
type: "Track" | "Album" | "Artist" type: "Track" | "Album" | "Artist";
title: string title: string;
img: string img: string;
id: number id: number;
musicbrainzId: string rank: number;
imgItemId: number musicbrainzId: string;
mergeFunc: MergeFunc imgItemId: number;
mergeCleanerFunc: MergeSearchCleanerFunc mergeFunc: MergeFunc;
children: React.ReactNode mergeCleanerFunc: MergeSearchCleanerFunc;
subContent: React.ReactNode children: React.ReactNode;
subContent: React.ReactNode;
} }
export default function MediaLayout(props: Props) { export default function MediaLayout(props: Props) {
const [bgColor, setBgColor] = useState<string>("(--color-bg)"); const [bgColor, setBgColor] = useState<string>("(--color-bg)");
const [mergeModalOpen, setMergeModalOpen] = useState(false); const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false); const [renameModalOpen, setRenameModalOpen] = useState(false);
const { user } = useAppContext(); const [addListenModalOpen, setAddListenModalOpen] = useState(false);
const { user } = useAppContext();
useEffect(() => { useEffect(() => {
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => { average(imageUrl(props.img, "small"), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`); setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
}); });
}, [props.img]); }, [props.img]);
const replaceImageCallback = () => { const replaceImageCallback = () => {
window.location.reload() window.location.reload();
} };
const title = `${props.title} - Koito` const title = `${props.title} - Koito`;
const mobileIconSize = 22 const mobileIconSize = 22;
const normalIconSize = 30 const normalIconSize = 30;
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) let vw = Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0
);
let iconSize = vw > 768 ? normalIconSize : mobileIconSize let iconSize = vw > 768 ? normalIconSize : mobileIconSize;
return ( console.log("MBZ:", props.musicbrainzId);
<main
className="w-full flex flex-col flex-grow" return (
style={{ <main
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`, className="w-full flex flex-col flex-grow"
transition: '1000', style={{
}} background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 700px)`,
> transition: "1000",
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} /> }}
<title>{title}</title> >
<meta property="og:title" content={title} /> <ImageDropHandler
<meta itemType={props.type.toLowerCase() === "artist" ? "artist" : "album"}
name="description" onComplete={replaceImageCallback}
content={title} />
/> <title>{title}</title>
<div className="w-19/20 mx-auto pt-12"> <meta property="og:title" content={title} />
<div className="flex gap-8 flex-wrap relative"> <meta name="description" content={title} />
<div className="flex flex-col justify-around"> <div className="w-19/20 mx-auto pt-12">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" /> <div className="flex gap-8 flex-wrap md:flex-nowrap relative">
</div> <div className="flex flex-col justify-around">
<div className="flex flex-col items-start"> <img
<h3>{props.type}</h3> style={{ zIndex: 5 }}
<h1>{props.title}</h1> src={imageUrl(props.img, "large")}
{props.subContent} alt={props.title}
</div> className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg"
{ user && />
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center"> </div>
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button> <div className="flex flex-col items-start">
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button> <h3>{props.type}</h3>
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button> <div className="flex">
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={iconSize} /></button> <h1>
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/> {props.title}
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} /> <span className="text-xl font-medium text-(--color-fg-secondary)">
<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} /> #{props.rank}
</div> </span>
} </h1>
</div>
{props.children}
</div> </div>
</main> {props.subContent}
); </div>
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
{props.musicbrainzId && (
<Link
title="View on MusicBrainz"
target="_blank"
to={`https://musicbrainz.org/${props.type.toLowerCase()}/${
props.musicbrainzId
}`}
>
<MbzIcon size={iconSize} hover />
</Link>
)}
{user && (
<>
{props.type === "Track" && (
<>
<button
title="Add Listen"
className="hover:cursor-pointer"
onClick={() => setAddListenModalOpen(true)}
>
<Plus size={iconSize} />
</button>
<AddListenModal
open={addListenModalOpen}
setOpen={setAddListenModalOpen}
trackid={props.id}
/>
</>
)}
<button
title="Edit Item"
className="hover:cursor-pointer"
onClick={() => setRenameModalOpen(true)}
>
<Edit size={iconSize} />
</button>
{props.type !== "Track" && (
<button
title="Replace Image"
className="hover:cursor-pointer"
onClick={() => setImageModalOpen(true)}
>
<ImageIcon size={iconSize} />
</button>
)}
<button
title="Merge Items"
className="hover:cursor-pointer"
onClick={() => setMergeModalOpen(true)}
>
<Merge size={iconSize} />
</button>
<button
title="Delete Item"
className="hover:cursor-pointer"
onClick={() => setDeleteModalOpen(true)}
>
<Trash size={iconSize} />
</button>
<EditModal
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>
);
} }

View file

@ -5,55 +5,86 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector"; import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`); let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load track", { status: res.status }); throw new Response("Failed to load track", { status: res.status });
} }
const track: Track = await res.json(); const track: Track = await res.json();
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`) res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load album for track", { status: res.status }) throw new Response("Failed to load album for track", {
} status: res.status,
const album: Album = await res.json() });
return {track: track, album: album}; }
const album: Album = await res.json();
return { track: track, album: album };
} }
export default function Track() { export default function Track() {
const { track, album } = useLoaderData(); const { track, album } = useLoaderData();
const [period, setPeriod] = useState('week') const [period, setPeriod] = useState("week");
return ( return (
<MediaLayout type="Track" <MediaLayout
title={track.title} type="Track"
img={track.image} title={track.title}
id={track.id} img={track.image}
musicbrainzId={album.musicbrainz_id} id={track.id}
imgItemId={track.album_id} rank={track.all_time_rank}
mergeFunc={mergeTracks} musicbrainzId={track.musicbrainz_id}
mergeCleanerFunc={(r, id) => { imgItemId={track.album_id}
r.albums = [] mergeFunc={mergeTracks}
r.artists = [] mergeCleanerFunc={(r, id) => {
for (let i = 0; i < r.tracks.length; i ++) { r.albums = [];
if (r.tracks[i].id === id) { r.artists = [];
delete r.tracks[i] 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"> return r;
<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>} subContent={
</div>} <div className="flex flex-col gap-2 items-start">
> <p>
<div className="mt-10"> Appears on{" "}
<PeriodSelector setter={setPeriod} current={period} /> <Link className="hover:underline" to={`/album/${track.album_id}`}>
</div> {album.title}
<div className="flex flex-wrap gap-20 mt-10"> </Link>
<LastPlays limit={20} trackId={track.id}/> </p>
<ActivityGrid trackId={track.id} configurable autoAdjust /> {track.listen_count !== 0 && (
</div> <p>
</MediaLayout> {track.listen_count} play{track.listen_count > 1 ? "s" : ""}
) </p>
)}
{track.time_listened !== 0 && (
<p title={Math.floor(track.time_listened / 60 / 60) + " hours"}>
{timeListenedString(track.time_listened)}
</p>
)}
{track.first_listen > 0 && (
<p title={new Date(track.first_listen * 1000).toLocaleString()}>
Listening since{" "}
{new Date(track.first_listen * 1000).toLocaleDateString()}
</p>
)}
</div>
}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={20} trackId={track.id} />
<div className="flex flex-col items-start gap-4">
<ActivityGrid configurable trackId={track.id} />
<InterestGraph trackId={track.id} />
</div>
</div>
</MediaLayout>
);
} }

View file

@ -0,0 +1,213 @@
import Rewind from "~/components/rewind/Rewind";
import type { Route } from "./+types/Home";
import { imageUrl, type RewindStats } from "api/api";
import { useEffect, useState } from "react";
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { getRewindParams, getRewindYear } from "~/utils/utils";
import { useNavigate } from "react-router";
import { average } from "color.js";
import { ChevronLeft, ChevronRight } from "lucide-react";
// TODO: Bind year and month selectors to what data actually exists
const months = [
"Full Year",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const year = parseInt(
url.searchParams.get("year") || getRewindParams().year.toString()
);
const month = parseInt(
url.searchParams.get("month") || getRewindParams().month.toString()
);
const res = await fetch(`/apis/web/v1/summary?year=${year}&month=${month}`);
if (!res.ok) {
throw new Response("Failed to load summary", { status: 500 });
}
const stats: RewindStats = await res.json();
stats.title = `Your ${month === 0 ? "" : months[month]} ${year} Rewind`;
return { stats };
}
export default function RewindPage() {
const currentParams = new URLSearchParams(location.search);
let year = parseInt(
currentParams.get("year") || getRewindParams().year.toString()
);
let month = parseInt(
currentParams.get("month") || getRewindParams().month.toString()
);
const navigate = useNavigate();
const [showTime, setShowTime] = useState(false);
const { stats: stats } = useLoaderData<{ stats: RewindStats }>();
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
useEffect(() => {
if (!stats.top_artists[0]) return;
const img = (stats.top_artists[0] as any)?.item.image;
if (!img) return;
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
});
}, [stats]);
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);
}
}
const url = `/rewind?${nextParams.toString()}`;
navigate(url, { replace: false });
};
const navigateMonth = (direction: "prev" | "next") => {
if (direction === "next") {
if (month === 12) {
month = 0;
} else {
month += 1;
}
} else {
if (month === 0) {
month = 12;
} else {
month -= 1;
}
}
console.log(`Month: ${month}`);
updateParams({
year: year.toString(),
month: month.toString(),
});
};
const navigateYear = (direction: "prev" | "next") => {
if (direction === "next") {
year += 1;
} else {
year -= 1;
}
updateParams({
year: year.toString(),
month: month.toString(),
});
};
const pgTitle = `${stats.title} - Koito`;
return (
<div
className="w-full min-h-screen"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
transition: "1000",
}}
>
<div className="flex flex-col items-start sm:items-center gap-4">
<title>{pgTitle}</title>
<meta property="og:title" content={pgTitle} />
<meta name="description" content={pgTitle} />
<div className="flex flex-col lg:flex-row items-start lg:mt-15 mt-5 gap-10 w-19/20 px-5 md:px-20">
<div className="flex flex-col items-start gap-4">
<div className="flex flex-col items-start gap-4 py-8">
<div className="flex items-center gap-6 justify-around">
<button
onClick={() => navigateMonth("prev")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={
// Previous month is in the future OR
new Date(year, month - 2) > new Date() ||
// We are looking at current year and prev would take us to full year
(new Date().getFullYear() === year && month === 1)
}
>
<ChevronLeft size={20} />
</button>
<p className="font-medium text-xl text-center w-30">
{months[month]}
</p>
<button
onClick={() => navigateMonth("next")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={
// next month is current or future month and
month >= new Date().getMonth() &&
// we are looking at current (or future) year
year >= new Date().getFullYear()
}
>
<ChevronRight size={20} />
</button>
</div>
<div className="flex items-center gap-6 justify-around">
<button
onClick={() => navigateYear("prev")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={new Date(year - 1, month) > new Date()}
>
<ChevronLeft size={20} />
</button>
<p className="font-medium text-xl text-center w-30">{year}</p>
<button
onClick={() => navigateYear("next")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={
// Next year date is in the future OR
new Date(year + 1, month - 1) > new Date() ||
// Next year date is current full year OR
(month == 0 && new Date().getFullYear() === year + 1) ||
// Next year date is current month
(new Date().getMonth() === month - 1 &&
new Date().getFullYear() === year + 1)
}
>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<label htmlFor="show-time-checkbox">Show time listened?</label>
<input
type="checkbox"
name="show-time-checkbox"
checked={showTime}
onChange={(e) => setShowTime(!showTime)}
></input>
</div>
</div>
{stats !== undefined && (
<Rewind stats={stats} includeTime={showTime} />
)}
</div>
</div>
</div>
);
}

View file

@ -7,8 +7,40 @@ import LastPlays from "~/components/LastPlays"
import TopAlbums from "~/components/TopAlbums" import TopAlbums from "~/components/TopAlbums"
import TopArtists from "~/components/TopArtists" import TopArtists from "~/components/TopArtists"
import TopTracks from "~/components/TopTracks" import TopTracks from "~/components/TopTracks"
import { useTheme } from "~/hooks/useTheme"
import { themes, type Theme } from "~/styles/themes.css"
export default function ThemeHelper() { export default function ThemeHelper() {
const initialTheme = {
bg: "#1e1816",
bgSecondary: "#2f2623",
bgTertiary: "#453733",
fg: "#f8f3ec",
fgSecondary: "#d6ccc2",
fgTertiary: "#b4a89c",
primary: "#f5a97f",
primaryDim: "#d88b65",
accent: "#f9db6d",
accentDim: "#d9bc55",
error: "#e26c6a",
warning: "#f5b851",
success: "#8fc48f",
info: "#87b8dd",
}
const [custom, setCustom] = useState(JSON.stringify(initialTheme, null, " "))
const { setCustomTheme } = useTheme()
const handleCustomTheme = () => {
console.log(custom)
try {
const theme = JSON.parse(custom) as Theme
console.log(theme)
setCustomTheme(theme)
} catch(err) {
console.log(err)
}
}
const homeItems = 3 const homeItems = 3
@ -24,43 +56,49 @@ export default function ThemeHelper() {
<TopTracks period="all_time" limit={homeItems} /> <TopTracks period="all_time" limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.5)} /> <LastPlays limit={Math.floor(homeItems * 2.5)} />
</div> </div>
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg"> <div className="flex gap-10">
<div className="flex flex-col gap-4 items-center"> <div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<p>You're logged in as <strong>Example User</strong></p> <textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) w-[300px] p-5 h-full rounded-md" value={custom} />
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton> <AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div> </div>
<div className="flex flex gap-4"> <div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
<input <div className="flex flex-col gap-4 items-center">
name="koito-update-username" <p>You"re logged in as <strong>Example User</strong></p>
type="text" <AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
placeholder="Update username" </div>
className="w-full mx-auto fg bg rounded p-2" <div className="flex flex gap-4">
/> <input
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton> 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>
<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>
</div> </div>
) )

View file

@ -0,0 +1,241 @@
import { globalStyle } from "@vanilla-extract/css";
import { themeVars } from "./vars.css";
export type Theme = {
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 THEME_KEYS = ["--color"];
export const themes: Record<string, Theme> = {
yuu: {
bg: "#1e1816",
bgSecondary: "#2f2623",
bgTertiary: "#453733",
fg: "#f8f3ec",
fgSecondary: "#d6ccc2",
fgTertiary: "#b4a89c",
primary: "#fc9174",
primaryDim: "#d88b65",
accent: "#f9db6d",
accentDim: "#d9bc55",
error: "#e26c6a",
warning: "#f5b851",
success: "#8fc48f",
info: "#87b8dd",
},
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",
},
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",
},
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",
},
autumn: {
bg: "rgb(44, 25, 18)",
bgSecondary: "rgb(70, 40, 18)",
bgTertiary: "#4b2f1c",
fg: "#fef9f3",
fgSecondary: "#dbc6b0",
fgTertiary: "#a3917a",
primary: "#F0850A",
primaryDim: "#b45309",
accent: "#8c4c28",
accentDim: "#6b3b1f",
error: "#d1433f",
warning: "#e38b29",
success: "#6b8e23",
info: "#c084fc",
},
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",
},
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",
},
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",
},
rosebud: {
bg: "#260d19",
bgSecondary: "#3A1325",
bgTertiary: "#45182D",
fg: "#F3CAD8",
fgSecondary: "#C88B99",
fgTertiary: "#B2677D",
primary: "#d76fa2",
primaryDim: "#b06687",
accent: "#e79cb8",
accentDim: "#c27d8c",
error: "#e84b73",
warning: "#f2b38c",
success: "#6FC4A6",
info: "#6BAEDC",
},
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",
},
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",
},
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",
},
};
export default themes;
Object.entries(themes).forEach(([name, theme]) => {
const selector = `[data-theme="${name}"]`;
globalStyle(selector, {
vars: {
[themeVars.bg]: theme.bg,
[themeVars.bgSecondary]: theme.bgSecondary,
[themeVars.bgTertiary]: theme.bgTertiary,
[themeVars.fg]: theme.fg,
[themeVars.fgSecondary]: theme.fgSecondary,
[themeVars.fgTertiary]: theme.fgTertiary,
[themeVars.primary]: theme.primary,
[themeVars.primaryDim]: theme.primaryDim,
[themeVars.accent]: theme.accent,
[themeVars.accentDim]: theme.accentDim,
[themeVars.error]: theme.error,
[themeVars.warning]: theme.warning,
[themeVars.success]: theme.success,
[themeVars.info]: theme.info,
},
});
});

View file

@ -0,0 +1,16 @@
export const themeVars = {
bg: '--color-bg',
bgSecondary: '--color-bg-secondary',
bgTertiary: '--color-bg-tertiary',
fg: '--color-fg',
fgSecondary: '--color-fg-secondary',
fgTertiary: '--color-fg-tertiary',
primary: '--color-primary',
primaryDim: '--color-primary-dim',
accent: '--color-accent',
accentDim: '--color-accent-dim',
error: '--color-error',
warning: '--color-warning',
info: '--color-info',
success: '--color-success',
}

View file

@ -1,391 +1,5 @@
/* Theme Definitions */ /* 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 */ /* Theme Helper Classes */
/* Foreground Text */ /* Foreground Text */

10
client/app/tz.ts Normal file
View file

@ -0,0 +1,10 @@
export function initTimezoneCookie() {
if (typeof window === "undefined") return;
if (document.cookie.includes("tz=")) return;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!tz) return;
document.cookie = `tz=${tz}; Path=/; Max-Age=31536000; SameSite=Lax`;
}

View file

@ -1,90 +1,121 @@
import Timeframe from "~/types/timeframe" import Timeframe from "~/types/timeframe";
const timeframeToInterval = (timeframe: Timeframe): string => { const timeframeToInterval = (timeframe: Timeframe): string => {
switch (timeframe) { switch (timeframe) {
case Timeframe.Day: case Timeframe.Day:
return "1 day" return "1 day";
case Timeframe.Week: case Timeframe.Week:
return "1 week" return "1 week";
case Timeframe.Month: case Timeframe.Month:
return "1 month" return "1 month";
case Timeframe.Year: case Timeframe.Year:
return "1 year" return "1 year";
case Timeframe.AllTime: case Timeframe.AllTime:
return "99 years" 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} const getRewindYear = (): number => {
export type {hsl} return new Date().getFullYear() - 1;
};
const getRewindParams = (): { month: number; year: number } => {
const today = new Date();
if (today.getMonth() == 0) {
return { month: 0, year: today.getFullYear() - 1 };
} else {
return { month: today.getMonth(), year: today.getFullYear() };
}
};
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),
};
};
const timeListenedString = (seconds: number) => {
if (!seconds) return "";
let minutes = Math.floor(seconds / 60);
return `${minutes} minutes listened`;
};
export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
export type { hsl };

View file

@ -13,13 +13,17 @@
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@react-router/node": "^7.5.3", "@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3", "@react-router/serve": "^7.5.3",
"@recharts/devtools": "^0.0.7",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@vanilla-extract/css": "^1.17.4",
"color.js": "^1.2.0", "color.js": "^1.2.0",
"isbot": "^5.1.27", "isbot": "^5.1.27",
"lucide-react": "^0.513.0", "lucide-react": "^0.513.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router": "^7.5.3" "react-is": "^19.2.3",
"react-router": "^7.5.3",
"recharts": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.5.3", "@react-router/dev": "^7.5.3",
@ -27,6 +31,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@vanilla-extract/vite-plugin": "^5.0.6",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.3", "vite": "^6.3.3",

View file

@ -1,6 +1,6 @@
{ {
"name": "MyWebSite", "name": "Koito",
"short_name": "MySite", "short_name": "Koito",
"icons": [ "icons": [
{ {
"src": "/web-app-manifest-192x192.png", "src": "/web-app-manifest-192x192.png",

View file

@ -2,11 +2,12 @@ import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
const isDocker = process.env.BUILD_TARGET === 'docker'; const isDocker = process.env.BUILD_TARGET === 'docker';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), vanillaExtractPlugin()],
server: { server: {
proxy: { proxy: {
'/apis': { '/apis': {

View file

@ -24,7 +24,7 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82"
integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg== integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==
"@babel/core@^7.21.8", "@babel/core@^7.23.7": "@babel/core@^7.21.8", "@babel/core@^7.23.7", "@babel/core@^7.23.9":
version "7.27.4" version "7.27.4"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
@ -185,7 +185,7 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1"
"@babel/plugin-syntax-typescript@^7.27.1": "@babel/plugin-syntax-typescript@^7.23.3", "@babel/plugin-syntax-typescript@^7.27.1":
version "7.27.1" version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
@ -222,6 +222,11 @@
"@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-modules-commonjs" "^7.27.1"
"@babel/plugin-transform-typescript" "^7.27.1" "@babel/plugin-transform-typescript" "^7.27.1"
"@babel/runtime@^7.12.5":
version "7.27.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
"@babel/template@^7.27.2": "@babel/template@^7.27.2":
version "7.27.2" version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
@ -274,6 +279,11 @@
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
"@emotion/hash@^0.9.0":
version "0.9.2"
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b"
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
"@esbuild/aix-ppc64@0.25.5": "@esbuild/aix-ppc64@0.25.5":
version "0.25.5" version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
@ -679,6 +689,23 @@
morgan "^1.10.0" morgan "^1.10.0"
source-map-support "^0.5.21" source-map-support "^0.5.21"
"@recharts/devtools@^0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@recharts/devtools/-/devtools-0.0.7.tgz#a909d102efd76fc45bc2b7a150e67a02da04b4c1"
integrity sha512-ud66rUf3FYf1yQLGSCowI50EQyC/rcZblvDgNvfUIVaEXyQtr5K2DFgwegziqbVclsVBQLTxyntVViJN5H4oWQ==
"@reduxjs/toolkit@1.x.x || 2.x.x":
version "2.11.2"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==
dependencies:
"@standard-schema/spec" "^1.0.0"
"@standard-schema/utils" "^0.3.0"
immer "^11.0.0"
redux "^5.0.1"
redux-thunk "^3.1.0"
reselect "^5.1.0"
"@rollup/rollup-android-arm-eabi@4.42.0": "@rollup/rollup-android-arm-eabi@4.42.0":
version "4.42.0" version "4.42.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
@ -779,6 +806,16 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA== integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
"@standard-schema/spec@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@standard-schema/utils@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@tailwindcss/node@4.1.8": "@tailwindcss/node@4.1.8":
version "4.1.8" version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b" resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
@ -908,11 +945,69 @@
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
"@types/d3-array@^3.0.3":
version "3.2.2"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c"
integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==
"@types/d3-color@*":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
"@types/d3-ease@^3.0.0":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
"@types/d3-interpolate@^3.0.1":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
dependencies:
"@types/d3-color" "*"
"@types/d3-path@*":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a"
integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==
"@types/d3-scale@^4.0.2":
version "4.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb"
integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==
dependencies:
"@types/d3-time" "*"
"@types/d3-shape@^3.1.0":
version "3.1.8"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3"
integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==
dependencies:
"@types/d3-path" "*"
"@types/d3-time@*", "@types/d3-time@^3.0.0":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f"
integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==
"@types/d3-timer@^3.0.0":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
"@types/estree@1.0.7": "@types/estree@1.0.7":
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/node@*":
version "24.0.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab"
integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==
dependencies:
undici-types "~7.8.0"
"@types/node@^20": "@types/node@^20":
version "20.19.0" version "20.19.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
@ -932,6 +1027,75 @@
dependencies: dependencies:
csstype "^3.0.2" csstype "^3.0.2"
"@types/use-sync-external-store@^0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
integrity sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==
dependencies:
"@babel/core" "^7.23.9"
"@vanilla-extract/compiler@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@vanilla-extract/compiler/-/compiler-0.2.3.tgz#97c4bb989aea92ee8329f1ad0a3ec01bf3aa8479"
integrity sha512-SFEDLbvd5rhpjhrLp9BtvvVNHNxWupiUht/yrsHQ7xfkpEn4xg45gbfma7aX9fsOpi82ebqFmowHd/g6jHDQnA==
dependencies:
"@vanilla-extract/css" "^1.17.4"
"@vanilla-extract/integration" "^8.0.4"
vite "^5.0.0 || ^6.0.0"
vite-node "^3.2.2"
"@vanilla-extract/css@^1.17.4":
version "1.17.4"
resolved "https://registry.yarnpkg.com/@vanilla-extract/css/-/css-1.17.4.tgz#c73353992b8243e8ab140582bf6d673ebc709b0a"
integrity sha512-m3g9nQDWPtL+sTFdtCGRMI1Vrp86Ay4PBYq1Bo7Bnchj5ElNtAJpOqD+zg+apthVA4fB7oVpMWNjwpa6ElDWFQ==
dependencies:
"@emotion/hash" "^0.9.0"
"@vanilla-extract/private" "^1.0.9"
css-what "^6.1.0"
cssesc "^3.0.0"
csstype "^3.0.7"
dedent "^1.5.3"
deep-object-diff "^1.1.9"
deepmerge "^4.2.2"
lru-cache "^10.4.3"
media-query-parser "^2.0.2"
modern-ahocorasick "^1.0.0"
picocolors "^1.0.0"
"@vanilla-extract/integration@^8.0.4":
version "8.0.4"
resolved "https://registry.yarnpkg.com/@vanilla-extract/integration/-/integration-8.0.4.tgz#eb176376b3b03c44713bf596cc41d6d97ba9f5d3"
integrity sha512-cmOb7tR+g3ulKvFtSbmdw3YUyIS1d7MQqN+FcbwNhdieyno5xzUyfDCMjeWJhmCSMvZ6WlinkrOkgs6SHB+FRg==
dependencies:
"@babel/core" "^7.23.9"
"@babel/plugin-syntax-typescript" "^7.23.3"
"@vanilla-extract/babel-plugin-debug-ids" "^1.2.2"
"@vanilla-extract/css" "^1.17.4"
dedent "^1.5.3"
esbuild "npm:esbuild@>=0.17.6 <0.26.0"
eval "0.1.8"
find-up "^5.0.0"
javascript-stringify "^2.0.1"
mlly "^1.4.2"
"@vanilla-extract/private@^1.0.9":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.9.tgz#bb8aaf72d2e04439792f2e389d9b705cfe691bc0"
integrity sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==
"@vanilla-extract/vite-plugin@^5.0.6":
version "5.0.6"
resolved "https://registry.yarnpkg.com/@vanilla-extract/vite-plugin/-/vite-plugin-5.0.6.tgz#00084be8e872519dde5152d92241ad8ad1e85396"
integrity sha512-9dSPIuxR2NULvVk9bqCoTaZz3CtfBrvo5hImWaiWCblWZXzCcD7jIg7Nbcpdz9MvytO+mNta82/qCWj1G9mEMQ==
dependencies:
"@vanilla-extract/compiler" "^0.2.3"
"@vanilla-extract/integration" "^8.0.4"
accepts@~1.3.8: accepts@~1.3.8:
version "1.3.8" version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
@ -940,6 +1104,11 @@ accepts@~1.3.8:
mime-types "~2.1.34" mime-types "~2.1.34"
negotiator "0.6.3" negotiator "0.6.3"
acorn@^8.14.0:
version "8.15.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
ansi-regex@^5.0.1: ansi-regex@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
@ -1077,6 +1246,11 @@ chownr@^3.0.0:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
color-convert@^2.0.1: color-convert@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@ -1114,6 +1288,11 @@ compression@^1.7.4:
safe-buffer "5.2.1" safe-buffer "5.2.1"
vary "~1.1.2" vary "~1.1.2"
confbox@^0.1.8:
version "0.1.8"
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
content-disposition@0.5.4: content-disposition@0.5.4:
version "0.5.4" version "0.5.4"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@ -1155,11 +1334,92 @@ cross-spawn@^7.0.6:
shebang-command "^2.0.0" shebang-command "^2.0.0"
which "^2.0.1" which "^2.0.1"
csstype@^3.0.2: css-what@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
csstype@^3.0.2, csstype@^3.0.7:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
version "3.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
dependencies:
internmap "1 - 2"
"d3-color@1 - 3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
d3-ease@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
"d3-format@1 - 3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
d3-path@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
d3-scale@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
dependencies:
d3-array "2.10.0 - 3"
d3-format "1 - 3"
d3-interpolate "1.2.0 - 3"
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
d3-shape@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
dependencies:
d3-path "^3.1.0"
"d3-time-format@2 - 4":
version "4.1.0"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
dependencies:
d3-time "1 - 3"
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
dependencies:
d3-array "2 - 3"
d3-timer@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
debug@2.6.9: debug@2.6.9:
version "2.6.9" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -1174,11 +1434,26 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.1:
dependencies: dependencies:
ms "^2.1.3" ms "^2.1.3"
decimal.js-light@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
dedent@^1.5.3: dedent@^1.5.3:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2" resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA== integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==
deep-object-diff@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595"
integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==
deepmerge@^4.2.2:
version "4.3.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
depd@2.0.0, depd@~2.0.0: depd@2.0.0, depd@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@ -1273,7 +1548,12 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
dependencies: dependencies:
es-errors "^1.3.0" es-errors "^1.3.0"
esbuild@^0.25.0: es-toolkit@^1.39.3:
version "1.43.0"
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.43.0.tgz#2c278d55ffeb30421e6e73a009738ed37b10ef61"
integrity sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==
esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
version "0.25.5" version "0.25.5"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ== integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==
@ -1319,6 +1599,19 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
eval@0.1.8:
version "0.1.8"
resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.8.tgz#2b903473b8cc1d1989b83a1e7923f883eb357f85"
integrity sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==
dependencies:
"@types/node" "*"
require-like ">= 0.1.1"
eventemitter3@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
exit-hook@2.2.1: exit-hook@2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
@ -1379,6 +1672,14 @@ finalhandler@1.3.1:
statuses "2.0.1" statuses "2.0.1"
unpipe "~1.0.0" unpipe "~1.0.0"
find-up@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
dependencies:
locate-path "^6.0.0"
path-exists "^4.0.0"
foreground-child@^3.1.0: foreground-child@^3.1.0:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@ -1519,11 +1820,26 @@ iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3" safer-buffer ">= 2.1.2 < 3"
immer@^10.1.1:
version "10.2.0"
resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1"
integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==
immer@^11.0.0:
version "11.1.3"
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
inherits@2.0.4: inherits@2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
"internmap@1 - 2":
version "2.0.3"
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@ -1560,6 +1876,11 @@ jackspeak@^3.1.2:
optionalDependencies: optionalDependencies:
"@pkgjs/parseargs" "^0.11.0" "@pkgjs/parseargs" "^0.11.0"
javascript-stringify@^2.0.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
jiti@^2.4.2: jiti@^2.4.2:
version "2.4.2" version "2.4.2"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560" resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
@ -1667,12 +1988,19 @@ lightningcss@1.30.1:
lightningcss-win32-arm64-msvc "1.30.1" lightningcss-win32-arm64-msvc "1.30.1"
lightningcss-win32-x64-msvc "1.30.1" lightningcss-win32-x64-msvc "1.30.1"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
dependencies:
p-locate "^5.0.0"
lodash@^4.17.21: lodash@^4.17.21:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lru-cache@^10.2.0: lru-cache@^10.2.0, lru-cache@^10.4.3:
version "10.4.3" version "10.4.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
@ -1706,6 +2034,13 @@ math-intrinsics@^1.1.0:
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
media-query-parser@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29"
integrity sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==
dependencies:
"@babel/runtime" "^7.12.5"
media-typer@0.3.0: media-typer@0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@ -1767,6 +2102,21 @@ mkdirp@^3.0.1:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
mlly@^1.4.2, mlly@^1.7.4:
version "1.7.4"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
dependencies:
acorn "^8.14.0"
pathe "^2.0.1"
pkg-types "^1.3.0"
ufo "^1.5.4"
modern-ahocorasick@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz#9b1fa15d4f654be20a2ad7ecc44ec9d7645bb420"
integrity sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==
morgan@^1.10.0: morgan@^1.10.0:
version "1.10.0" version "1.10.0"
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
@ -1874,6 +2224,20 @@ on-headers@~1.0.2:
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
dependencies:
yocto-queue "^0.1.0"
p-locate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
dependencies:
p-limit "^3.0.2"
package-json-from-dist@^1.0.0: package-json-from-dist@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
@ -1884,6 +2248,11 @@ parseurl@~1.3.3:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
path-key@^3.1.0: path-key@^3.1.0:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
@ -1907,12 +2276,12 @@ pathe@^1.1.2:
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
pathe@^2.0.3: pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
picocolors@^1.1.1: picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@ -1922,6 +2291,15 @@ picomatch@^4.0.2:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
pkg-types@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
dependencies:
confbox "^0.1.8"
mlly "^1.7.4"
pathe "^2.0.1"
postcss@^8.5.3: postcss@^8.5.3:
version "8.5.4" version "8.5.4"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
@ -1991,6 +2369,19 @@ react-dom@^19.1.0:
dependencies: dependencies:
scheduler "^0.26.0" scheduler "^0.26.0"
react-is@^19.2.3:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==
"react-redux@8.x.x || 9.x.x":
version "9.2.0"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"
integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==
dependencies:
"@types/use-sync-external-store" "^0.0.6"
use-sync-external-store "^1.4.0"
react-refresh@^0.14.0: react-refresh@^0.14.0:
version "0.14.2" version "0.14.2"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
@ -2014,6 +2405,43 @@ readdirp@^4.0.1:
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
recharts@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.6.0.tgz#403f0606581153601857e46733277d1411633df3"
integrity sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==
dependencies:
"@reduxjs/toolkit" "1.x.x || 2.x.x"
clsx "^2.1.1"
decimal.js-light "^2.5.1"
es-toolkit "^1.39.3"
eventemitter3 "^5.0.1"
immer "^10.1.1"
react-redux "8.x.x || 9.x.x"
reselect "5.1.1"
tiny-invariant "^1.3.3"
use-sync-external-store "^1.2.2"
victory-vendor "^37.0.2"
redux-thunk@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
redux@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
"require-like@>= 0.1.1":
version "0.1.2"
resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
reselect@5.1.1, reselect@^5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
retry@^0.12.0: retry@^0.12.0:
version "0.12.0" version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
@ -2298,6 +2726,11 @@ tar@^7.4.3:
mkdirp "^3.0.1" mkdirp "^3.0.1"
yallist "^5.0.0" yallist "^5.0.0"
tiny-invariant@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
tinyglobby@^0.2.13: tinyglobby@^0.2.13:
version "0.2.14" version "0.2.14"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
@ -2334,11 +2767,21 @@ typescript@^5.8.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
ufo@^1.5.4:
version "1.6.1"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
undici-types@~6.21.0: undici-types@~6.21.0:
version "6.21.0" version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
undici-types@~7.8.0:
version "7.8.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
undici@^6.19.2: undici@^6.19.2:
version "6.21.3" version "6.21.3"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a" resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
@ -2362,6 +2805,11 @@ update-browserslist-db@^1.1.3:
escalade "^3.2.0" escalade "^3.2.0"
picocolors "^1.1.1" picocolors "^1.1.1"
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
utils-merge@1.0.1: utils-merge@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@ -2390,7 +2838,27 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
vite-node@^3.1.4: victory-vendor@^37.0.2:
version "37.3.6"
resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da"
integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==
dependencies:
"@types/d3-array" "^3.0.3"
"@types/d3-ease" "^3.0.0"
"@types/d3-interpolate" "^3.0.1"
"@types/d3-scale" "^4.0.2"
"@types/d3-shape" "^3.1.0"
"@types/d3-time" "^3.0.0"
"@types/d3-timer" "^3.0.0"
d3-array "^3.1.6"
d3-ease "^3.0.1"
d3-interpolate "^3.0.1"
d3-scale "^4.0.2"
d3-shape "^3.1.0"
d3-time "^3.0.0"
d3-timer "^3.0.1"
vite-node@^3.1.4, vite-node@^3.2.2:
version "3.2.3" version "3.2.3"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ== integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
@ -2410,7 +2878,7 @@ vite-tsconfig-paths@^5.1.4:
globrex "^0.1.2" globrex "^0.1.2"
tsconfck "^3.0.3" tsconfck "^3.0.3"
"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3: "vite@^5.0.0 || ^6.0.0", "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
version "6.3.5" version "6.3.5"
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3" resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ== integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
@ -2465,3 +2933,8 @@ yallist@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==

View file

@ -3,6 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"log"
"github.com/gabehf/koito/engine" "github.com/gabehf/koito/engine"
) )
@ -11,7 +13,7 @@ var Version = "dev"
func main() { func main() {
if err := engine.Run( if err := engine.Run(
os.Getenv, readEnvOrFile,
os.Stdout, os.Stdout,
Version, Version,
); err != nil { ); err != nil {
@ -19,3 +21,23 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
func readEnvOrFile(envName string) string {
envContent := os.Getenv(envName)
if envContent == "" {
filename := os.Getenv(envName + "_FILE")
if filename != "" {
b, err := os.ReadFile(filename)
if err != nil {
log.Fatalf("Failed to load file for %s_FILE (%s): %s", envName, filename, err)
}
envContent = strings.TrimSpace(string(b))
}
}
return envContent
}

View file

@ -0,0 +1,48 @@
-- +goose Up
-- +goose StatementBegin
SELECT 'up SQL query';
-- +goose StatementEnd
ALTER TABLE artist_tracks
ADD COLUMN is_primary boolean NOT NULL DEFAULT false;
ALTER TABLE artist_releases
ADD COLUMN is_primary boolean NOT NULL DEFAULT false;
-- +goose StatementBegin
CREATE FUNCTION get_artists_for_release(release_id INTEGER)
RETURNS JSONB AS $$
SELECT json_agg(
jsonb_build_object('id', a.id, 'name', a.name)
ORDER BY ar.is_primary DESC, a.name
)
FROM artist_releases ar
JOIN artists_with_name a ON a.id = ar.artist_id
WHERE ar.release_id = $1;
$$ LANGUAGE sql STABLE;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION get_artists_for_track(track_id INTEGER)
RETURNS JSONB AS $$
SELECT json_agg(
jsonb_build_object('id', a.id, 'name', a.name)
ORDER BY at.is_primary DESC, a.name
)
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = $1;
$$ LANGUAGE sql STABLE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
ALTER TABLE artist_tracks
DROP COLUMN is_primary;
ALTER TABLE artist_releases
DROP COLUMN is_primary;
DROP FUNCTION IF EXISTS get_artists_for_release(INTEGER);
DROP FUNCTION IF EXISTS get_artists_for_track(INTEGER);

View file

@ -0,0 +1,3 @@
-- +goose Up
UPDATE users
SET username = LOWER(username);

View file

@ -0,0 +1,9 @@
-- +goose Up
DELETE FROM artist_releases ar
WHERE NOT EXISTS (
SELECT 1
FROM artist_tracks at
JOIN tracks t ON at.track_id = t.id
WHERE at.artist_id = ar.artist_id
AND t.release_id = ar.release_id
);

View file

@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var Files embed.FS

View file

@ -14,22 +14,24 @@ GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
-- name: GetTrackArtists :many -- name: GetTrackArtists :many
SELECT SELECT
a.* a.*,
at.is_primary as is_primary
FROM artists_with_name a FROM artists_with_name a
LEFT JOIN artist_tracks at ON a.id = at.artist_id LEFT JOIN artist_tracks at ON a.id = at.artist_id
WHERE at.track_id = $1 WHERE at.track_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary;
-- name: GetArtistByImage :one -- name: GetArtistByImage :one
SELECT * FROM artists WHERE image = $1 LIMIT 1; SELECT * FROM artists WHERE image = $1 LIMIT 1;
-- name: GetReleaseArtists :many -- name: GetReleaseArtists :many
SELECT SELECT
a.* a.*,
ar.is_primary as is_primary
FROM artists_with_name a FROM artists_with_name a
LEFT JOIN artist_releases ar ON a.id = ar.artist_id LEFT JOIN artist_releases ar ON a.id = ar.artist_id
WHERE ar.release_id = $1 WHERE ar.release_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary;
-- name: GetArtistByName :one -- name: GetArtistByName :one
WITH artist_with_aliases AS ( WITH artist_with_aliases AS (
@ -54,28 +56,77 @@ LEFT JOIN artist_aliases aa ON a.id = aa.artist_id
WHERE a.musicbrainz_id = $1 WHERE a.musicbrainz_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
-- name: GetArtistsWithoutImages :many
SELECT
*
FROM artists_with_name
WHERE image IS NULL
AND id > $2
ORDER BY id ASC
LIMIT $1;
-- name: GetTopArtistsPaginated :many -- name: GetTopArtistsPaginated :many
SELECT SELECT
x.id,
x.name,
x.musicbrainz_id,
x.image,
x.listen_count,
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
FROM (
SELECT
a.id, a.id,
a.name, a.name,
a.musicbrainz_id, a.musicbrainz_id,
a.image, a.image,
COUNT(*) AS listen_count COUNT(*) AS listen_count
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
JOIN artist_tracks at ON at.track_id = t.id JOIN artist_tracks at ON at.track_id = t.id
JOIN artists_with_name a ON a.id = at.artist_id JOIN artists_with_name a ON a.id = at.artist_id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name GROUP BY a.id, a.name, a.musicbrainz_id, a.image
ORDER BY listen_count DESC ) x
ORDER BY x.listen_count DESC, x.id
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetArtistAllTimeRank :one
SELECT
artist_id,
rank
FROM (
SELECT
x.artist_id,
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
FROM (
SELECT
at.artist_id,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
GROUP BY at.artist_id
) x
)
WHERE artist_id = $1;
-- name: CountTopArtists :one -- name: CountTopArtists :one
SELECT COUNT(DISTINCT at.artist_id) AS total_count SELECT COUNT(DISTINCT at.artist_id) AS total_count
FROM listens l FROM listens l
JOIN artist_tracks at ON l.track_id = at.track_id JOIN artist_tracks at ON l.track_id = at.track_id
WHERE l.listened_at BETWEEN $1 AND $2; WHERE l.listened_at BETWEEN $1 AND $2;
-- name: CountNewArtists :one
SELECT COUNT(*) AS total_count
FROM (
SELECT at.artist_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
GROUP BY at.artist_id
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
) first_appearances;
-- name: UpdateArtistMbzID :exec -- name: UpdateArtistMbzID :exec
UPDATE artists SET musicbrainz_id = $2 UPDATE artists SET musicbrainz_id = $2
WHERE id = $1; WHERE id = $1;

View file

@ -3,7 +3,13 @@ DO $$
BEGIN BEGIN
DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l); DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l);
DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t); DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t);
-- DELETE FROM releases WHERE release_group_id NOT IN (SELECT t.release_group_id FROM tracks t);
-- DELETE FROM releases WHERE release_group_id NOT IN (SELECT rg.id FROM release_groups rg);
DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at); DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at);
DELETE FROM artist_releases ar
WHERE NOT EXISTS (
SELECT 1
FROM artist_tracks at
JOIN tracks t ON at.track_id = t.id
WHERE at.artist_id = ar.artist_id
AND t.release_id = ar.release_id
);
END $$; END $$;

139
db/queries/interest.sql Normal file
View file

@ -0,0 +1,139 @@
-- name: GetGroupedListensFromArtist :many
WITH bounds AS (
SELECT
MIN(l.listened_at) AS start_time,
NOW() AS end_time
FROM listens l
JOIN tracks t ON t.id = l.track_id
JOIN artist_tracks at ON at.track_id = t.id
WHERE at.artist_id = $1
),
stats AS (
SELECT
start_time,
end_time,
EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
((end_time - start_time) / sqlc.arg(bucket_count)::int) AS bucket_interval
FROM bounds
),
bucket_series AS (
SELECT generate_series(0, sqlc.arg(bucket_count)::int - 1) AS idx
),
listen_indices AS (
SELECT
LEAST(
sqlc.arg(bucket_count)::int - 1,
FLOOR(
(EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
* sqlc.arg(bucket_count)::int
)::int
) AS bucket_idx
FROM listens l
JOIN tracks t ON t.id = l.track_id
JOIN artist_tracks at ON at.track_id = t.id
CROSS JOIN stats s
WHERE at.artist_id = $1
AND s.start_time IS NOT NULL
)
SELECT
(s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
(s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
COUNT(li.bucket_idx) AS listen_count
FROM bucket_series bs
CROSS JOIN stats s
LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
WHERE s.start_time IS NOT NULL
GROUP BY bs.idx, s.start_time, s.bucket_interval
ORDER BY bs.idx;
-- name: GetGroupedListensFromRelease :many
WITH bounds AS (
SELECT
MIN(l.listened_at) AS start_time,
NOW() AS end_time
FROM listens l
JOIN tracks t ON t.id = l.track_id
WHERE t.release_id = $1
),
stats AS (
SELECT
start_time,
end_time,
EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
((end_time - start_time) / sqlc.arg(bucket_count)::int) AS bucket_interval
FROM bounds
),
bucket_series AS (
SELECT generate_series(0, sqlc.arg(bucket_count)::int - 1) AS idx
),
listen_indices AS (
SELECT
LEAST(
sqlc.arg(bucket_count)::int - 1,
FLOOR(
(EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
* sqlc.arg(bucket_count)::int
)::int
) AS bucket_idx
FROM listens l
JOIN tracks t ON t.id = l.track_id
CROSS JOIN stats s
WHERE t.release_id = $1
AND s.start_time IS NOT NULL
)
SELECT
(s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
(s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
COUNT(li.bucket_idx) AS listen_count
FROM bucket_series bs
CROSS JOIN stats s
LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
WHERE s.start_time IS NOT NULL
GROUP BY bs.idx, s.start_time, s.bucket_interval
ORDER BY bs.idx;
-- name: GetGroupedListensFromTrack :many
WITH bounds AS (
SELECT
MIN(l.listened_at) AS start_time,
NOW() AS end_time
FROM listens l
JOIN tracks t ON t.id = l.track_id
WHERE t.id = $1
),
stats AS (
SELECT
start_time,
end_time,
EXTRACT(EPOCH FROM (end_time - start_time)) AS total_seconds,
((end_time - start_time) / sqlc.arg(bucket_count)::int) AS bucket_interval
FROM bounds
),
bucket_series AS (
SELECT generate_series(0, sqlc.arg(bucket_count)::int - 1) AS idx
),
listen_indices AS (
SELECT
LEAST(
sqlc.arg(bucket_count)::int - 1,
FLOOR(
(EXTRACT(EPOCH FROM (l.listened_at - s.start_time)) / NULLIF(s.total_seconds, 0))
* sqlc.arg(bucket_count)::int
)::int
) AS bucket_idx
FROM listens l
JOIN tracks t ON t.id = l.track_id
CROSS JOIN stats s
WHERE t.id = $1
AND s.start_time IS NOT NULL
)
SELECT
(s.start_time + (s.bucket_interval * bs.idx))::timestamptz AS bucket_start,
(s.start_time + (s.bucket_interval * (bs.idx + 1)))::timestamptz AS bucket_end,
COUNT(li.bucket_idx) AS listen_count
FROM bucket_series bs
CROSS JOIN stats s
LEFT JOIN listen_indices li ON bs.idx = li.bucket_idx
WHERE s.start_time IS NOT NULL
GROUP BY bs.idx, s.start_time, s.bucket_interval
ORDER BY bs.idx;

View file

@ -8,12 +8,7 @@ SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -25,12 +20,7 @@ SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id JOIN artist_tracks at ON t.id = at.track_id
@ -39,17 +29,22 @@ WHERE at.artist_id = $5
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromArtist :one
SELECT
l.*
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetLastListensFromReleasePaginated :many -- name: GetLastListensFromReleasePaginated :many
SELECT SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -57,17 +52,21 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromRelease :one
SELECT
l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetLastListensFromTrackPaginated :many -- name: GetLastListensFromTrackPaginated :many
SELECT SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -75,6 +74,22 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromTrack :one
SELECT
l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetFirstListen :one
SELECT
*
FROM listens
ORDER BY listened_at ASC
LIMIT 1;
-- name: CountListens :one -- name: CountListens :one
SELECT COUNT(*) AS total_count SELECT COUNT(*) AS total_count
FROM listens l FROM listens l
@ -129,90 +144,51 @@ WHERE l.listened_at BETWEEN $1 AND $2
AND t.id = $3; AND t.id = $3;
-- name: ListenActivity :many -- name: ListenActivity :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
bucketed_listens AS ( FROM listens
SELECT WHERE listened_at >= $2
b.bucket_start, AND listened_at < $3
COUNT(l.listened_at) AS listen_count GROUP BY day
FROM buckets b ORDER BY day;
LEFT JOIN listens l
ON l.listened_at >= b.bucket_start
AND l.listened_at < b.bucket_start + $3::interval
GROUP BY b.bucket_start
ORDER BY b.bucket_start
)
SELECT * FROM bucketed_listens;
-- name: ListenActivityForArtist :many -- name: ListenActivityForArtist :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.* JOIN tracks t ON l.track_id = t.id
FROM listens l JOIN artist_tracks at ON t.id = at.track_id
JOIN artist_tracks t ON l.track_id = t.track_id WHERE l.listened_at >= $2
WHERE t.artist_id = $4 AND l.listened_at < $3
), AND at.artist_id = $4
bucketed_listens AS ( GROUP BY day
SELECT ORDER BY day;
b.bucket_start,
COUNT(l.listened_at) AS listen_count
FROM buckets b
LEFT JOIN filtered_listens l
ON l.listened_at >= b.bucket_start
AND l.listened_at < b.bucket_start + $3::interval
GROUP BY b.bucket_start
ORDER BY b.bucket_start
)
SELECT * FROM bucketed_listens;
-- name: ListenActivityForRelease :many -- name: ListenActivityForRelease :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.* JOIN tracks t ON l.track_id = t.id
FROM listens l WHERE l.listened_at >= $2
JOIN tracks t ON l.track_id = t.id AND l.listened_at < $3
WHERE t.release_id = $4 AND t.release_id = $4
), GROUP BY day
bucketed_listens AS ( ORDER BY day;
SELECT
b.bucket_start,
COUNT(l.listened_at) AS listen_count
FROM buckets b
LEFT JOIN filtered_listens l
ON l.listened_at >= b.bucket_start
AND l.listened_at < b.bucket_start + $3::interval
GROUP BY b.bucket_start
ORDER BY b.bucket_start
)
SELECT * FROM bucketed_listens;
-- name: ListenActivityForTrack :many -- name: ListenActivityForTrack :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.* JOIN tracks t ON l.track_id = t.id
FROM listens l WHERE l.listened_at >= $2
JOIN tracks t ON l.track_id = t.id AND l.listened_at < $3
WHERE t.id = $4 AND t.id = $4
), GROUP BY day
bucketed_listens AS ( ORDER BY day;
SELECT
b.bucket_start,
COUNT(l.listened_at) AS listen_count
FROM buckets b
LEFT JOIN filtered_listens l
ON l.listened_at >= b.bucket_start
AND l.listened_at < b.bucket_start + $3::interval
GROUP BY b.bucket_start
ORDER BY b.bucket_start
)
SELECT * FROM bucketed_listens;
-- name: UpdateTrackIdForListens :exec -- name: UpdateTrackIdForListens :exec
UPDATE listens SET track_id = $2 UPDATE listens SET track_id = $2
@ -220,3 +196,70 @@ WHERE track_id = $1;
-- name: DeleteListen :exec -- name: DeleteListen :exec
DELETE FROM listens WHERE track_id = $1 AND listened_at = $2; DELETE FROM listens WHERE track_id = $1 AND listened_at = $2;
-- name: GetListensExportPage :many
SELECT
l.listened_at,
l.user_id,
l.client,
-- Track info
t.id AS track_id,
t.musicbrainz_id AS track_mbid,
t.duration AS track_duration,
(
SELECT json_agg(json_build_object(
'alias', ta.alias,
'source', ta.source,
'is_primary', ta.is_primary
))
FROM track_aliases ta
WHERE ta.track_id = t.id
) AS track_aliases,
-- Release info
r.id AS release_id,
r.musicbrainz_id AS release_mbid,
r.image AS release_image,
r.image_source AS release_image_source,
r.various_artists,
(
SELECT json_agg(json_build_object(
'alias', ra.alias,
'source', ra.source,
'is_primary', ra.is_primary
))
FROM release_aliases ra
WHERE ra.release_id = r.id
) AS release_aliases,
-- Artists
(
SELECT json_agg(json_build_object(
'id', a.id,
'musicbrainz_id', a.musicbrainz_id,
'image', a.image,
'image_source', a.image_source,
'aliases', (
SELECT json_agg(json_build_object(
'alias', aa.alias,
'source', aa.source,
'is_primary', aa.is_primary
))
FROM artist_aliases aa
WHERE aa.artist_id = a.id
)
))
FROM artist_tracks at
JOIN artists a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id
WHERE l.user_id = @user_id::int
AND (l.listened_at, l.track_id) > (@listened_at::timestamptz, @track_id::int)
ORDER BY l.listened_at, l.track_id
LIMIT $1;

View file

@ -4,7 +4,10 @@ VALUES ($1, $2, $3, $4)
RETURNING *; RETURNING *;
-- name: GetRelease :one -- name: GetRelease :one
SELECT * FROM releases_with_title SELECT
*,
get_artists_for_release(id) AS artists
FROM releases_with_title
WHERE id = $1 LIMIT 1; WHERE id = $1 LIMIT 1;
-- name: GetReleaseByMbzID :one -- name: GetReleaseByMbzID :one
@ -29,44 +32,76 @@ JOIN artist_releases ar ON r.id = ar.release_id
WHERE r.title = ANY ($1::TEXT[]) AND ar.artist_id = $2 WHERE r.title = ANY ($1::TEXT[]) AND ar.artist_id = $2
LIMIT 1; LIMIT 1;
-- name: GetReleaseByArtistAndTitlesNoMbzID :one
SELECT r.*
FROM releases_with_title r
JOIN artist_releases ar ON r.id = ar.release_id
WHERE r.title = ANY ($1::TEXT[])
AND ar.artist_id = $2
AND EXISTS (
SELECT 1
FROM releases r2
WHERE r2.id = r.id
AND r2.musicbrainz_id IS NULL
);
-- name: GetTopReleasesFromArtist :many -- name: GetTopReleasesFromArtist :many
SELECT SELECT
r.*, x.*,
COUNT(*) AS listen_count, get_artists_for_release(x.id) AS artists,
( RANK() OVER (ORDER BY x.listen_count DESC) AS rank
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) FROM (
FROM artists_with_name a SELECT
JOIN artist_releases ar ON ar.artist_id = a.id r.*,
WHERE ar.release_id = r.id COUNT(*) AS listen_count
) AS artists FROM listens l
FROM listens l JOIN tracks t ON l.track_id = t.id
JOIN tracks t ON l.track_id = t.id JOIN releases_with_title r ON t.release_id = r.id
JOIN releases_with_title r ON t.release_id = r.id JOIN artist_releases ar ON r.id = ar.release_id
JOIN artist_releases ar ON r.id = ar.release_id WHERE ar.artist_id = $5
WHERE ar.artist_id = $5 AND l.listened_at BETWEEN $1 AND $2
AND l.listened_at BETWEEN $1 AND $2 GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source ) x
ORDER BY listen_count DESC ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetTopReleasesPaginated :many -- name: GetTopReleasesPaginated :many
SELECT SELECT
r.*, x.*,
COUNT(*) AS listen_count, get_artists_for_release(x.id) AS artists,
( RANK() OVER (ORDER BY x.listen_count DESC) AS rank
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name)) FROM (
FROM artists_with_name a SELECT
JOIN artist_releases ar ON ar.artist_id = a.id r.*,
WHERE ar.release_id = r.id COUNT(*) AS listen_count
) AS artists FROM listens l
FROM listens l JOIN tracks t ON l.track_id = t.id
JOIN tracks t ON l.track_id = t.id JOIN releases_with_title r ON t.release_id = r.id
JOIN releases_with_title r ON t.release_id = r.id WHERE l.listened_at BETWEEN $1 AND $2
WHERE l.listened_at BETWEEN $1 AND $2 GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source ) x
ORDER BY listen_count DESC ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetReleaseAllTimeRank :one
SELECT
release_id,
rank
FROM (
SELECT
x.release_id,
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
FROM (
SELECT
t.release_id,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.track_id = t.id
GROUP BY t.release_id
) x
)
WHERE release_id = $1;
-- name: CountTopReleases :one -- name: CountTopReleases :one
SELECT COUNT(DISTINCT r.id) AS total_count SELECT COUNT(DISTINCT r.id) AS total_count
FROM listens l FROM listens l
@ -80,20 +115,25 @@ FROM releases r
JOIN artist_releases ar ON r.id = ar.release_id JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $1; WHERE ar.artist_id = $1;
-- name: CountNewReleases :one
SELECT COUNT(*) AS total_count
FROM (
SELECT t.release_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
GROUP BY t.release_id
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
) first_appearances;
-- name: AssociateArtistToRelease :exec -- name: AssociateArtistToRelease :exec
INSERT INTO artist_releases (artist_id, release_id) INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: GetReleasesWithoutImages :many -- name: GetReleasesWithoutImages :many
SELECT SELECT
r.*, r.*,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON a.id = ar.artist_id
WHERE ar.release_id = r.id
) AS artists
FROM releases_with_title r FROM releases_with_title r
WHERE r.image IS NULL WHERE r.image IS NULL
AND r.id > $2 AND r.id > $2
@ -104,6 +144,14 @@ LIMIT $1;
UPDATE releases SET musicbrainz_id = $2 UPDATE releases SET musicbrainz_id = $2
WHERE id = $1; WHERE id = $1;
-- name: UpdateReleaseVariousArtists :exec
UPDATE releases SET various_artists = $2
WHERE id = $1;
-- name: UpdateReleasePrimaryArtist :exec
UPDATE artist_releases SET is_primary = $3
WHERE artist_id = $1 AND release_id = $2;
-- name: UpdateReleaseImage :exec -- name: UpdateReleaseImage :exec
UPDATE releases SET image = $2, image_source = $3 UPDATE releases SET image = $2, image_source = $3
WHERE id = $1; WHERE id = $1;

View file

@ -42,12 +42,7 @@ SELECT
ranked.release_id, ranked.release_id,
ranked.image, ranked.image,
ranked.score, ranked.score,
( get_artists_for_track(ranked.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
t.id, t.id,
@ -74,12 +69,7 @@ SELECT
ranked.release_id, ranked.release_id,
ranked.image, ranked.image,
ranked.score, ranked.score,
( get_artists_for_track(ranked.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
t.id, t.id,
@ -106,12 +96,7 @@ SELECT
ranked.image, ranked.image,
ranked.various_artists, ranked.various_artists,
ranked.score, ranked.score,
( get_artists_for_release(ranked.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
r.id, r.id,
@ -137,12 +122,7 @@ SELECT
ranked.image, ranked.image,
ranked.various_artists, ranked.various_artists,
ranked.score, ranked.score,
( get_artists_for_release(ranked.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
r.id, r.id,

View file

@ -4,13 +4,14 @@ VALUES ($1, $2, $3)
RETURNING *; RETURNING *;
-- name: AssociateArtistToTrack :exec -- name: AssociateArtistToTrack :exec
INSERT INTO artist_tracks (artist_id, track_id) INSERT INTO artist_tracks (artist_id, track_id, is_primary)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: GetTrack :one -- name: GetTrack :one
SELECT SELECT
t.*, t.*,
get_artists_for_track(t.id) AS artists,
r.image r.image
FROM tracks_with_title t FROM tracks_with_title t
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -26,83 +27,112 @@ FROM tracks_with_title t
JOIN artist_tracks at ON t.id = at.track_id JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1; WHERE at.artist_id = $1;
-- name: GetTrackByTitleAndArtists :one -- name: GetTrackByTrackInfo :one
SELECT t.* SELECT t.*
FROM tracks_with_title t FROM tracks_with_title t
JOIN artist_tracks at ON at.track_id = t.id JOIN artist_tracks at ON at.track_id = t.id
WHERE t.title = $1 WHERE t.title = $1
AND at.artist_id = ANY($2::int[]) AND at.artist_id = ANY($3::int[])
AND t.release_id = $2
GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id
HAVING COUNT(DISTINCT at.artist_id) = cardinality($2::int[]); HAVING COUNT(DISTINCT at.artist_id) = cardinality($3::int[]);
-- name: GetTopTracksPaginated :many -- name: GetTopTracksPaginated :many
SELECT SELECT
t.id, x.track_id AS id,
t.title, t.title,
t.musicbrainz_id, t.musicbrainz_id,
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, x.listen_count,
( get_artists_for_track(x.track_id) AS artists,
SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) x.rank
FROM artist_tracks at FROM (
JOIN artists_with_name a ON a.id = at.artist_id SELECT
WHERE at.track_id = t.id track_id,
) AS artists COUNT(*) AS listen_count,
FROM listens l RANK() OVER (ORDER BY COUNT(*) DESC) as rank
JOIN tracks_with_title t ON l.track_id = t.id FROM listens
WHERE listened_at BETWEEN $1 AND $2
GROUP BY track_id
ORDER BY listen_count DESC
LIMIT $3 OFFSET $4
) x
JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
WHERE l.listened_at BETWEEN $1 AND $2 ORDER BY x.listen_count DESC, x.track_id;
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
ORDER BY listen_count DESC
LIMIT $3 OFFSET $4;
-- name: GetTopTracksByArtistPaginated :many -- name: GetTopTracksByArtistPaginated :many
SELECT SELECT
t.id, x.track_id AS id,
t.title, t.title,
t.musicbrainz_id, t.musicbrainz_id,
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, x.listen_count,
( get_artists_for_track(x.track_id) AS artists,
SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) x.rank
FROM artist_tracks at2 FROM (
JOIN artists_with_name a ON a.id = at2.artist_id SELECT
WHERE at2.track_id = t.id l.track_id,
) AS artists COUNT(*) AS listen_count,
FROM listens l RANK() OVER (ORDER BY COUNT(*) DESC) as rank
JOIN tracks_with_title t ON l.track_id = t.id FROM listens l
JOIN artist_tracks at ON l.track_id = at.track_id
WHERE l.listened_at BETWEEN $1 AND $2
AND at.artist_id = $5
GROUP BY l.track_id
ORDER BY listen_count DESC
LIMIT $3 OFFSET $4
) x
JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
JOIN artist_tracks at ON at.track_id = t.id ORDER BY x.listen_count DESC, x.track_id;
WHERE l.listened_at BETWEEN $1 AND $2
AND at.artist_id = $5
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
ORDER BY listen_count DESC
LIMIT $3 OFFSET $4;
-- name: GetTopTracksInReleasePaginated :many -- name: GetTopTracksInReleasePaginated :many
SELECT SELECT
t.id, x.track_id AS id,
t.title, t.title,
t.musicbrainz_id, t.musicbrainz_id,
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, x.listen_count,
( get_artists_for_track(x.track_id) AS artists,
SELECT json_agg(json_build_object('id', a.id, 'name', a.name)) x.rank
FROM artist_tracks at2 FROM (
JOIN artists_with_name a ON a.id = at2.artist_id SELECT
WHERE at2.track_id = t.id l.track_id,
) AS artists COUNT(*) AS listen_count,
FROM listens l RANK() OVER (ORDER BY COUNT(*) DESC) as rank
JOIN tracks_with_title t ON l.track_id = t.id FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
AND t.release_id = $5
GROUP BY l.track_id
ORDER BY listen_count DESC
LIMIT $3 OFFSET $4
) x
JOIN tracks_with_title t ON x.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
WHERE l.listened_at BETWEEN $1 AND $2 ORDER BY x.listen_count DESC, x.track_id;
AND t.release_id = $5
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image -- name: GetTrackAllTimeRank :one
ORDER BY listen_count DESC SELECT
LIMIT $3 OFFSET $4; id,
rank
FROM (
SELECT
x.id,
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
FROM (
SELECT
t.id,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
GROUP BY t.id) x
) y
WHERE id = $1;
-- name: CountTopTracks :one -- name: CountTopTracks :one
SELECT COUNT(DISTINCT l.track_id) AS total_count SELECT COUNT(DISTINCT l.track_id) AS total_count
@ -123,6 +153,15 @@ JOIN tracks t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
AND t.release_id = $3; AND t.release_id = $3;
-- name: CountNewTracks :one
SELECT COUNT(*) AS total_count
FROM (
SELECT track_id
FROM listens
GROUP BY track_id
HAVING MIN(listened_at) BETWEEN $1 AND $2
) first_appearances;
-- name: UpdateTrackMbzID :exec -- name: UpdateTrackMbzID :exec
UPDATE tracks SET musicbrainz_id = $2 UPDATE tracks SET musicbrainz_id = $2
WHERE id = $1; WHERE id = $1;
@ -135,5 +174,19 @@ WHERE id = $1;
UPDATE tracks SET release_id = $2 UPDATE tracks SET release_id = $2
WHERE release_id = $1; WHERE release_id = $1;
-- name: UpdateTrackPrimaryArtist :exec
UPDATE artist_tracks SET is_primary = $3
WHERE artist_id = $1 AND track_id = $2;
-- name: DeleteTrack :exec -- name: DeleteTrack :exec
DELETE FROM tracks WHERE id = $1; DELETE FROM tracks WHERE id = $1;
-- name: GetTracksWithNoDurationButHaveMbzID :many
SELECT
*
FROM tracks_with_title
WHERE duration = 0
AND musicbrainz_id IS NOT NULL
AND id > $2
ORDER BY id ASC
LIMIT $1;

374
db/queries/year.sql Normal file
View file

@ -0,0 +1,374 @@
-- name: GetMostReplayedTrackInYear :one
WITH ordered_listens AS (
SELECT
user_id,
track_id,
listened_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY listened_at) AS rn
FROM listens
WHERE EXTRACT(YEAR FROM listened_at) = @year::int
),
streaks AS (
SELECT
user_id,
track_id,
listened_at,
rn,
ROW_NUMBER() OVER (PARTITION BY user_id, track_id ORDER BY listened_at) AS track_rn
FROM ordered_listens
),
grouped_streaks AS (
SELECT
user_id,
track_id,
rn - track_rn AS group_id,
COUNT(*) AS streak_length
FROM streaks
GROUP BY user_id, track_id, rn - track_rn
),
ranked_streaks AS (
SELECT *,
RANK() OVER (PARTITION BY user_id ORDER BY streak_length DESC) AS r
FROM grouped_streaks
)
SELECT
t.*,
get_artists_for_track(t.id) as artists,
streak_length
FROM ranked_streaks rs JOIN tracks_with_title t ON rs.track_id = t.id
WHERE user_id = @user_id::int AND r = 1;
-- name: TracksOnlyPlayedOnceInYear :many
SELECT
t.id AS track_id,
t.title,
get_artists_for_track(t.id) as artists,
COUNT(l.*) AS listen_count
FROM listens l
JOIN tracks_with_title t ON t.id = l.track_id
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int AND l.user_id = @user_id::int
GROUP BY t.id, t.title
HAVING COUNT(*) = 1
LIMIT $1;
-- name: ArtistsOnlyPlayedOnceInYear :many
SELECT
a.id AS artist_id,
a.name,
COUNT(l.*) AS listen_count
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
JOIN artists_with_name a ON a.id = at.artist_id
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int AND l.user_id = @user_id::int
GROUP BY a.id, a.name
HAVING COUNT(*) = 1;
-- GetNewTrackWithMostListensInYear :one
WITH first_plays_in_year AS (
SELECT
l.user_id,
l.track_id,
MIN(l.listened_at) AS first_listen
FROM listens l
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
AND NOT EXISTS (
SELECT 1
FROM listens l2
WHERE l2.user_id = l.user_id
AND l2.track_id = l.track_id
AND l2.listened_at < @first_day_of_year::date
)
GROUP BY l.user_id, l.track_id
),
seven_day_window AS (
SELECT
f.user_id,
f.track_id,
f.first_listen,
COUNT(l.*) AS plays_in_7_days
FROM first_plays_in_year f
JOIN listens l
ON l.user_id = f.user_id
AND l.track_id = f.track_id
AND l.listened_at >= f.first_listen
AND l.listened_at < f.first_listen + INTERVAL '7 days'
GROUP BY f.user_id, f.track_id, f.first_listen
),
ranked AS (
SELECT *,
RANK() OVER (PARTITION BY user_id ORDER BY plays_in_7_days DESC) AS r
FROM seven_day_window
)
SELECT
s.user_id,
s.track_id,
t.title,
get_artists_for_track(t.id) as artists,
s.first_listen,
s.plays_in_7_days
FROM ranked s
JOIN tracks_with_title t ON t.id = s.track_id
WHERE r = 1;
-- GetTopThreeNewArtistsInYear :many
WITH first_artist_plays_in_year AS (
SELECT
l.user_id,
at.artist_id,
MIN(l.listened_at) AS first_listen
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
AND NOT EXISTS (
SELECT 1
FROM listens l2
JOIN artist_tracks at2 ON at2.track_id = l2.track_id
WHERE l2.user_id = l.user_id
AND at2.artist_id = at.artist_id
AND l2.listened_at < @first_day_of_year::date
)
GROUP BY l.user_id, at.artist_id
),
artist_plays_in_year AS (
SELECT
f.user_id,
f.artist_id,
f.first_listen,
COUNT(l.*) AS total_plays_in_year
FROM first_artist_plays_in_year f
JOIN listens l ON l.user_id = f.user_id
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE at.artist_id = f.artist_id
AND EXTRACT(YEAR FROM l.listened_at) = @year::int
GROUP BY f.user_id, f.artist_id, f.first_listen
),
ranked AS (
SELECT *,
RANK() OVER (PARTITION BY user_id ORDER BY total_plays_in_year DESC) AS r
FROM artist_plays_in_year
)
SELECT
a.user_id,
a.artist_id,
awn.name AS artist_name,
a.first_listen,
a.total_plays_in_year
FROM ranked a
JOIN artists_with_name awn ON awn.id = a.artist_id
WHERE r <= 3;
-- name: GetArtistWithLongestGapInYear :one
WITH first_listens AS (
SELECT
l.user_id,
at.artist_id,
MIN(l.listened_at::date) AS first_listen_of_year
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE EXTRACT(YEAR FROM l.listened_at) = @year::int
GROUP BY l.user_id, at.artist_id
),
last_listens AS (
SELECT
l.user_id,
at.artist_id,
MAX(l.listened_at::date) AS last_listen
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE l.listened_at < @first_day_of_year::date
GROUP BY l.user_id, at.artist_id
),
comebacks AS (
SELECT
f.user_id,
f.artist_id,
f.first_listen_of_year,
p.last_listen,
(f.first_listen_of_year - p.last_listen) AS gap_days
FROM first_listens f
JOIN last_listens p
ON f.user_id = p.user_id AND f.artist_id = p.artist_id
),
ranked AS (
SELECT *,
RANK() OVER (PARTITION BY user_id ORDER BY gap_days DESC) AS r
FROM comebacks
)
SELECT
c.user_id,
c.artist_id,
awn.name AS artist_name,
c.last_listen,
c.first_listen_of_year,
c.gap_days
FROM ranked c
JOIN artists_with_name awn ON awn.id = c.artist_id
WHERE r = 1;
-- name: GetFirstListenInYear :one
SELECT
l.*,
t.*,
get_artists_for_track(t.id) as artists
FROM listens l
LEFT JOIN tracks_with_title t ON l.track_id = t.id
WHERE EXTRACT(YEAR FROM l.listened_at) = 2025
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetTracksPlayedAtLeastOncePerMonthInYear :many
WITH monthly_plays AS (
SELECT
l.track_id,
EXTRACT(MONTH FROM l.listened_at) AS month
FROM listens l
WHERE EXTRACT(YEAR FROM l.listened_at) = @user_id::int
GROUP BY l.track_id, EXTRACT(MONTH FROM l.listened_at)
),
monthly_counts AS (
SELECT
track_id,
COUNT(DISTINCT month) AS months_played
FROM monthly_plays
GROUP BY track_id
)
SELECT
t.id AS track_id,
t.title
FROM monthly_counts mc
JOIN tracks_with_title t ON t.id = mc.track_id
WHERE mc.months_played = 12;
-- name: GetWeekWithMostListensInYear :one
SELECT
DATE_TRUNC('week', listened_at + INTERVAL '1 day') - INTERVAL '1 day' AS week_start,
COUNT(*) AS listen_count
FROM listens
WHERE EXTRACT(YEAR FROM listened_at) = @year::int
AND user_id = @user_id::int
GROUP BY week_start
ORDER BY listen_count DESC
LIMIT 1;
-- name: GetPercentageOfTotalListensFromTopTracksInYear :one
WITH user_listens AS (
SELECT
l.track_id,
COUNT(*) AS listen_count
FROM listens l
WHERE l.user_id = @user_id::int
AND EXTRACT(YEAR FROM l.listened_at) = @year::int
GROUP BY l.track_id
),
top_tracks AS (
SELECT
track_id,
listen_count
FROM user_listens
ORDER BY listen_count DESC
LIMIT $1
),
totals AS (
SELECT
(SELECT SUM(listen_count) FROM top_tracks) AS top_tracks_total,
(SELECT SUM(listen_count) FROM user_listens) AS overall_total
)
SELECT
top_tracks_total,
overall_total,
ROUND((top_tracks_total::decimal / overall_total) * 100, 2) AS percent_of_total
FROM totals;
-- name: GetPercentageOfTotalListensFromTopArtistsInYear :one
WITH user_artist_listens AS (
SELECT
at.artist_id,
COUNT(*) AS listen_count
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE l.user_id = @user_id::int
AND EXTRACT(YEAR FROM l.listened_at) = @year::int
GROUP BY at.artist_id
),
top_artists AS (
SELECT
artist_id,
listen_count
FROM user_artist_listens
ORDER BY listen_count DESC
LIMIT $1
),
totals AS (
SELECT
(SELECT SUM(listen_count) FROM top_artists) AS top_artist_total,
(SELECT SUM(listen_count) FROM user_artist_listens) AS overall_total
)
SELECT
top_artist_total,
overall_total,
ROUND((top_artist_total::decimal / overall_total) * 100, 2) AS percent_of_total
FROM totals;
-- name: GetArtistsWithOnlyOnePlayInYear :many
WITH first_artist_plays_in_year AS (
SELECT
l.user_id,
at.artist_id,
MIN(l.listened_at) AS first_listen
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE EXTRACT(YEAR FROM l.listened_at) = 2024
AND NOT EXISTS (
SELECT 1
FROM listens l2
JOIN artist_tracks at2 ON at2.track_id = l2.track_id
WHERE l2.user_id = l.user_id
AND at2.artist_id = at.artist_id
AND l2.listened_at < DATE '2024-01-01'
)
GROUP BY l.user_id, at.artist_id
)
SELECT
f.user_id,
f.artist_id,
f.first_listen, a.name,
COUNT(l.*) AS total_plays_in_year
FROM first_artist_plays_in_year f
JOIN listens l ON l.user_id = f.user_id
JOIN artist_tracks at ON at.track_id = l.track_id JOIN artists_with_name a ON at.artist_id = a.id
WHERE at.artist_id = f.artist_id
AND EXTRACT(YEAR FROM l.listened_at) = 2024
GROUP BY f.user_id, f.artist_id, f.first_listen, a.name HAVING COUNT(*) = 1;
-- name: GetArtistCountInYear :one
SELECT
COUNT(DISTINCT at.artist_id) AS artist_count
FROM listens l
JOIN artist_tracks at ON at.track_id = l.track_id
WHERE l.user_id = @user_id::int
AND EXTRACT(YEAR FROM l.listened_at) = @year::int;
-- name: GetListenPercentageInTimeWindowInYear :one
WITH user_listens_in_year AS (
SELECT
listened_at
FROM listens
WHERE user_id = @user_id::int
AND EXTRACT(YEAR FROM listened_at) = @year::int
),
windowed AS (
SELECT
COUNT(*) AS in_window
FROM user_listens_in_year
WHERE EXTRACT(HOUR FROM listened_at) >= @hour_window_start::int
AND EXTRACT(HOUR FROM listened_at) < @hour_window_end::int
),
total AS (
SELECT COUNT(*) AS total_listens
FROM user_listens_in_year
)
SELECT
w.in_window,
t.total_listens,
ROUND((w.in_window::decimal / t.total_listens) * 100, 2) AS percent_of_total
FROM windowed w, total t;

View file

@ -1,53 +1,65 @@
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from "astro/config";
import starlight from '@astrojs/starlight'; import starlight from "@astrojs/starlight";
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [ integrations: [
starlight({ starlight({
head: [ head: [
{ {
tag: 'script', tag: "script",
attrs: { attrs: {
src: 'https://static.cloudflareinsights.com/beacon.min.js', src: "https://static.cloudflareinsights.com/beacon.min.js",
'data-cf-beacon': '{"token": "1948caaaba10463fa1d310ee02b0951c"}', "data-cf-beacon": '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
defer: true, defer: true,
}
}
],
title: 'Koito',
logo: {
src: './src/assets/logo_text.png',
replacesTitle: true,
}, },
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gabehf/koito' }], },
sidebar: [ ],
{ title: "Koito",
label: 'Guides', logo: {
items: [ src: "./src/assets/logo_text.png",
// Each item here is one entry in the navigation menu. replacesTitle: true,
{ label: 'Installation', slug: 'guides/installation' }, },
{ label: 'Importing Data', slug: 'guides/importing' }, social: [
{ label: 'Setting up the Scrobbler', slug: 'guides/scrobbler' }, {
{ label: 'Editing Data', slug: 'guides/editing' }, icon: "github",
], label: "GitHub",
}, href: "https://github.com/gabehf/koito",
{ },
label: 'Reference', ],
items: [ sidebar: [
{ label: 'Configuration Options', slug: 'reference/configuration' }, {
] label: "Guides",
}, items: [
// Each item here is one entry in the navigation menu.
{ label: "Installation", slug: "guides/installation" },
{ label: "Importing Data", slug: "guides/importing" },
{ label: "Setting up the Scrobbler", slug: "guides/scrobbler" },
{ label: "Editing Data", slug: "guides/editing" },
], ],
customCss: [ },
// Path to your Tailwind base styles: {
'./src/styles/global.css', label: "Quickstart",
], items: [
}), { label: "Setup with Navidrome", slug: "quickstart/navidrome" },
], ],
},
{
label: "Reference",
items: [
{ label: "Configuration Options", slug: "reference/configuration" },
],
},
],
customCss: [
// Path to your Tailwind base styles:
"./src/styles/global.css",
],
}),
],
site: "https://koito.io", site: "https://koito.io",

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View file

@ -60,6 +60,8 @@ Once merged, we can see that all of the listen activity for Tsumugu has been asi
![an activity heatmap showing more listens than were previously there](../../../assets/merged_activity.png) ![an activity heatmap showing more listens than were previously there](../../../assets/merged_activity.png)
You can also search for items when merging by their ID using the format `id:1234`.
#### Deleting Items #### Deleting Items
To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children

View file

@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from:
:::note :::note
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
means that artist aliases will not be automatically fetched for imported artists. This also means that artists will not be associated with their MusicBrainz IDs internally, means that artist aliases will not be automatically fetched for imported artists. You can also use
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and [your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster. [disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
::: :::

View file

@ -3,7 +3,7 @@ title: Setting up the Scrobber
description: How to relay listens submitted to Koito to another ListenBrainz compatible server. description: How to relay listens submitted to Koito to another ListenBrainz compatible server.
--- ---
To use the ListenBrainz API, you need to get your generated api key from the UI. To use the ListenBrainz API, you need to get your generated API key from the UI. The API key is what you will use as the ListenBrainz token.
First, open the settings in your Koito instance by clicking on the settings icon or pressing `\`. First, open the settings in your Koito instance by clicking on the settings icon or pressing `\`.
@ -20,7 +20,7 @@ After logging in, open the settings menu again and find the `API Keys` tab. On t
If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it. If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it.
::: :::
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` and provide the api key from the UI as the token. Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` (or `{your_koito_address}/apis/listenbrainz` for some applications) and provide the API key from the UI as the token.
## Set up a relay ## Set up a relay
@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::note :::note
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`. Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
::: :::

View file

@ -28,12 +28,12 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
Koito can be connected to any music server or client that allows for custom ListenBrainz URLs. Koito can be connected to any music server or client that allows for custom ListenBrainz URLs.
</Card> </Card>
<Card title="Scrobbler relay" icon="rocket"> <Card title="Scrobbler relay" icon="rocket">
Automatically relay listens submitted to your Koito instance to other ListenBrainz compatble servers. Automatically relay listens submitted to your Koito instance to other ListenBrainz compatible servers.
</Card> </Card>
<Card title="Automatic data fetching" icon="download"> <Card title="Automatic data fetching" icon="download">
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server. Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
</Card> </Card>
<Card title="Themeable" icon="seti:css"> <Card title="Themeable" icon="seti:css">
Koito ships with twelve different themes, with custom theme options to be added soon™. Koito ships with twelve different themes, now with support for custom themes!
</Card> </Card>
</CardGrid> </CardGrid>

View file

@ -0,0 +1,68 @@
---
title: Navidrome Quickstart
description: How to set up Koito to work with your Navidrome instance.
---
## Configure Koito
This quickstart assumes you are using Docker compose. Below is an example file, adjusted from the actual file I use personally.
```yaml title="compose.yaml"
services:
koito:
image: gabehf/koito:latest
container_name: koito
depends_on:
- db
user: 1000:1000
environment:
- KOITO_DATABASE_URL=postgres://postgres:<a_super_random_string>@db:5432/koitodb
- KOITO_ALLOWED_HOSTS=koito.mydomain.com,192.168.1.100
- KOITO_SUBSONIC_URL=https://navidrome.mydomain.com # the url to your navidrome instance
- KOITO_SUBSONIC_PARAMS=u=<navidrome_username>&t=<navidrome_token>&s=<navidrome_salt>
- KOITO_DEFAULT_THEME=black # i like this theme, use whatever you want
ports:
- "4110:4110"
volumes:
- ./koito-data:/etc/koito
restart: unless-stopped
db:
user: 1000:1000
image: postgres:16
container_name: psql
restart: unless-stopped
environment:
POSTGRES_DB: koitodb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: <a_super_random_string>
volumes:
- ./db-data:/var/lib/postgresql/data
```
### How do I get the Subsonic params?
The easiest way to get your Subsonic parameters to open your browser and sign into Navidrome, then press F12 to get to
the developer options and navigate to the **Network** tab. Find a `getCoverArt` request (there should be a lot on the home
page) and look for the part of the URL that looks like `u=<username>&t=<random_string>&s=<small_random_string>`. This
is what you need to copy and provide to Koito.
:::note
If you don't want to use Navidrome to provide images to Koito, you can skip the `KOITO_SUBSONIC_URL` and `KOITO_SUBSONIC_PARAMS`
variables entirely.
:::
## Configure Navidrome
You have to provide Navidrome with the environment variables `ND_LISTENBRAINZ_ENABLED=true` and
`ND_LISTENBRAINZ_BASEURL=<your_koito_url>/apis/listenbrainz/1`. The place where you edit these environment variables will change
depending on how you have chosen to deploy Navidrome.
## Enable ListenBrainz in Navidrome
In Navidome, click on **Settings** in the top right, then click **Personal**.
Here, you will see that **Scrobble to ListenBrainz** is turned off. Flip that switch on.
![navidrome listenbrainz switch screenshot](../../../assets/navidrome_lbz_switch.png)
When you flip it on, Navidrome will prompt you for a ListenBrainz token. To get this token, open your Koito page and sign in.
Press the settings button (or hit `\`) and go to the **API Keys** tab. Copy the autogenerated API key by either clicking the
copy button, or clicking on the key itself and copying with ctrl+c.
After hitting **Save** in Navidrome, your listen activity will start being sent to Koito as you listen to tracks.
Happy scrobbling!

View file

@ -5,6 +5,12 @@ description: The available configuration options when setting up Koito.
Koito is configured using **environment variables**. This is the full list of configuration options supported by Koito. Koito is configured using **environment variables**. This is the full list of configuration options supported by Koito.
The suffix `_FILE` is also supported for every environment variable. This allows the use of Docker secrets, for example: `KOITO_DATABASE_URL_FILE=/run/secrets/database-url` will load the content of the file at `/run/secrets/database-url` for the environment variable `KOITO_DATABASE_URL`.
:::caution
If the environment variable is defined without **and** with the suffix at the same time, the content of the environment variable without the `_FILE` suffix will have the higher priority.
:::
##### KOITO_DATABASE_URL ##### KOITO_DATABASE_URL
- Required: `true` - Required: `true`
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information. - Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
@ -17,6 +23,12 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_DEFAULT_PASSWORD ##### KOITO_DEFAULT_PASSWORD
- Default: `changeme` - Default: `changeme`
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time. - Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
##### KOITO_DEFAULT_THEME
- Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
##### KOITO_LOGIN_GATE
- Default: `false`
- Description: When `true`, Koito will not show any statistics unless the user is logged in.
##### KOITO_BIND_ADDR ##### KOITO_BIND_ADDR
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`. - Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT ##### KOITO_LISTEN_PORT
@ -31,6 +43,9 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_LOG_LEVEL ##### KOITO_LOG_LEVEL
- Default: `info` - Default: `info`
- Description: One of `debug | info | warn | error | fatal` - Description: One of `debug | info | warn | error | fatal`
##### KOITO_ARTIST_SEPARATORS_REGEX
- Default: `\s+·\s+`
- Description: The list of regex patterns Koito will use to separate artist strings, separated by two semicolons (`;;`).
##### KOITO_MUSICBRAINZ_URL ##### KOITO_MUSICBRAINZ_URL
- Default: `https://musicbrainz.org` - Default: `https://musicbrainz.org`
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror. - Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
@ -49,6 +64,8 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_CONFIG_DIR ##### KOITO_CONFIG_DIR
- Default: `/etc/koito` - Default: `/etc/koito`
- Description: The location where import folders and image caches are stored. - Description: The location where import folders and image caches are stored.
##### KOITO_FORCE_TZ
- Description: A canonical IANA database time zone name (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) that Koito will use to serve all clients. Overrides any timezones requested via a `tz` cookie or `tz` query parameter. Koito will fail to start if this value is invalid.
##### KOITO_DISABLE_DEEZER ##### KOITO_DISABLE_DEEZER
- Default: `false` - Default: `false`
- Description: Disables Deezer as a source for finding artist and album images. - Description: Disables Deezer as a source for finding artist and album images.
@ -57,6 +74,19 @@ Koito is configured using **environment variables**. This is the full list of co
- Description: Disables Cover Art Archive as a source for finding album images. - Description: Disables Cover Art Archive as a source for finding album images.
##### KOITO_DISABLE_MUSICBRAINZ ##### KOITO_DISABLE_MUSICBRAINZ
- Default: `false` - Default: `false`
##### KOITO_SUBSONIC_URL
- Required: `true` if KOITO_SUBSONIC_PARAMS is set
- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`.
##### KOITO_SUBSONIC_PARAMS
- Required: `true` if KOITO_SUBSONIC_URL is set
- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request.
:::caution
If Koito is unable to validate your Subsonic configuration, it will fail to start. If you notice your container isn't running after
changing these parameters, check the logs!
:::
##### KOITO_LASTFM_API_KEY
- Required: `false`
- Description: Your LastFM API key, which will be used for fetching images if provided. You can get an API key [here](https://www.last.fm/api/authentication),
##### KOITO_SKIP_IMPORT ##### KOITO_SKIP_IMPORT
- Default: `false` - Default: `false`
- Description: Skips running the importer on startup. - Description: Skips running the importer on startup.
@ -70,6 +100,9 @@ Koito is configured using **environment variables**. This is the full list of co
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded. - Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
##### KOITO_IMPORT_AFTER_UNIX ##### KOITO_IMPORT_AFTER_UNIX
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded. - Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
##### KOITO_FETCH_IMAGES_DURING_IMPORT
- Default: `false`
- Description: When true, images will be downloaded and cached during imports.
##### KOITO_CORS_ALLOWED_ORIGINS ##### KOITO_CORS_ALLOWED_ORIGINS
- Default: No CORS policy - Default: No CORS policy
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. - Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.

View file

@ -2,6 +2,7 @@ package engine
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -95,6 +96,10 @@ func Run(
defer store.Close(ctx) defer store.Close(ctx)
l.Info().Msg("Engine: Database connection established") l.Info().Msg("Engine: Database connection established")
if cfg.ForceTZ() != nil {
l.Debug().Msgf("Engine: Forcing the use of timezone '%s'", cfg.ForceTZ().String())
}
l.Debug().Msg("Engine: Initializing MusicBrainz client") l.Debug().Msg("Engine: Initializing MusicBrainz client")
var mbzC mbz.MusicBrainzCaller var mbzC mbz.MusicBrainzCaller
if !cfg.MusicBrainzDisabled() { if !cfg.MusicBrainzDisabled() {
@ -105,11 +110,39 @@ func Run(
l.Warn().Msg("Engine: MusicBrainz client disabled") l.Warn().Msg("Engine: MusicBrainz client disabled")
} }
if cfg.SubsonicEnabled() {
l.Debug().Msg("Engine: Checking Subsonic configuration")
pingURL := cfg.SubsonicUrl() + "/rest/ping.view?" + cfg.SubsonicParams() + "&f=json&v=1&c=koito"
resp, err := http.Get(pingURL)
if err != nil {
l.Fatal().Err(err).Msg("Engine: Failed to contact Subsonic server! Ensure the provided URL is correct")
} else {
defer resp.Body.Close()
var result struct {
Response struct {
Status string `json:"status"`
} `json:"subsonic-response"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
l.Fatal().Err(err).Msg("Engine: Failed to parse Subsonic response")
} else if result.Response.Status != "ok" {
l.Fatal().Msg("Engine: Provided Subsonic credentials are invalid")
} else {
l.Info().Msg("Engine: Subsonic credentials validated successfully")
}
}
}
l.Debug().Msg("Engine: Initializing image sources") l.Debug().Msg("Engine: Initializing image sources")
images.Initialize(images.ImageSourceOpts{ images.Initialize(images.ImageSourceOpts{
UserAgent: cfg.UserAgent(), UserAgent: cfg.UserAgent(),
EnableCAA: !cfg.CoverArtArchiveDisabled(), EnableCAA: !cfg.CoverArtArchiveDisabled(),
EnableDeezer: !cfg.DeezerDisabled(), EnableDeezer: !cfg.DeezerDisabled(),
EnableSubsonic: cfg.SubsonicEnabled(),
EnableLastFM: cfg.LastFMApiKey() != "",
}) })
l.Info().Msg("Engine: Image sources initialized") l.Info().Msg("Engine: Image sources initialized")
@ -183,6 +216,8 @@ func Run(
} }
}() }()
l.Info().Msg("Engine: Beginning startup tasks...")
l.Debug().Msg("Engine: Checking import configuration") l.Debug().Msg("Engine: Checking import configuration")
if !cfg.SkipImport() { if !cfg.SkipImport() {
go func() { go func() {
@ -192,6 +227,12 @@ func Run(
l.Info().Msg("Engine: Pruning orphaned images") l.Info().Msg("Engine: Pruning orphaned images")
go catalog.PruneOrphanedImages(logger.NewContext(l), store) go catalog.PruneOrphanedImages(logger.NewContext(l), store)
l.Info().Msg("Engine: Running duration backfill task")
go catalog.BackfillTrackDurationsFromMusicBrainz(ctx, store, mbzC)
l.Info().Msg("Engine: Attempting to fetch missing artist images")
go catalog.FetchMissingArtistImages(ctx, store)
l.Info().Msg("Engine: Attempting to fetch missing album images")
go catalog.FetchMissingAlbumImages(ctx, store)
l.Info().Msg("Engine: Initialization finished") l.Info().Msg("Engine: Initialization finished")
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
@ -212,19 +253,19 @@ func Run(
} }
func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) { func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
l.Debug().Msg("Checking for import files...") l.Debug().Msg("Importer: Checking for import files...")
files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import")) files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import"))
if err != nil { if err != nil {
l.Err(err).Msg("Failed to read files from import dir") l.Err(err).Msg("Importer: Failed to read files from import dir")
} }
if len(files) > 0 { if len(files) > 0 {
l.Info().Msg("Files found in import directory. Attempting to import...") l.Info().Msg("Importer: Files found in import directory. Attempting to import...")
} else { } else {
return return
} }
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
l.Error().Interface("recover", r).Msg("Panic when importing files") l.Error().Interface("recover", r).Msg("Importer: Panic when importing files")
} }
}() }()
for _, file := range files { for _, file := range files {
@ -232,31 +273,37 @@ func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
continue continue
} }
if strings.Contains(file.Name(), "Streaming_History_Audio") { if strings.Contains(file.Name(), "Streaming_History_Audio") {
l.Info().Msgf("Import file %s detecting as being Spotify export", file.Name()) l.Info().Msgf("Importer: Import file %s detecting as being Spotify export", file.Name())
err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name()) err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name())
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to import file: %s", file.Name()) l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
} }
} else if strings.Contains(file.Name(), "maloja") { } else if strings.Contains(file.Name(), "maloja") {
l.Info().Msgf("Import file %s detecting as being Maloja export", file.Name()) l.Info().Msgf("Importer: Import file %s detecting as being Maloja export", file.Name())
err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name()) err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name())
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to import file: %s", file.Name()) l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
} }
} else if strings.Contains(file.Name(), "recenttracks") { } else if strings.Contains(file.Name(), "recenttracks") {
l.Info().Msgf("Import file %s detecting as being ghan.nl LastFM export", file.Name()) l.Info().Msgf("Importer: Import file %s detecting as being ghan.nl LastFM export", file.Name())
err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name()) err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name())
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to import file: %s", file.Name()) l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
} }
} else if strings.Contains(file.Name(), "listenbrainz") { } else if strings.Contains(file.Name(), "listenbrainz") {
l.Info().Msgf("Import file %s detecting as being ListenBrainz export", file.Name()) l.Info().Msgf("Importer: Import file %s detecting as being ListenBrainz export", file.Name())
err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name()) err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name())
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to import file: %s", file.Name()) l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
}
} else if strings.Contains(file.Name(), "koito") {
l.Info().Msgf("Importer: Import file %s detecting as being Koito export", file.Name())
err := importer.ImportKoitoFile(logger.NewContext(l), store, file.Name())
if err != nil {
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
} }
} else { } else {
l.Warn().Msgf("File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name()) l.Warn().Msgf("Importer: File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
} }
} }
} }

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