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:
push:
branches: [main]
tags:
- "v*"
paths:
- 'docs/**'
- '.github/workflows/**'
- "docs/**"
- ".github/workflows/**"
workflow_dispatch:
permissions:
contents: read
@ -21,9 +24,9 @@ jobs:
- name: Install, build, and upload your site output
uses: withastro/action@v4
with:
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)
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)
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)
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:
needs: build
@ -34,4 +37,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v4

View file

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

View file

@ -1,3 +1,8 @@
ifneq (,$(wildcard ./.env))
include .env
export
endif
.PHONY: all test clean client
postgres.schemadump:
@ -10,7 +15,10 @@ postgres.schemadump:
-v --dbname="koitodb" -f "/tmp/dump/schema.sql"
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:
docker start koito-db
@ -18,8 +26,17 @@ postgres.start:
postgres.stop:
docker stop koito-db
api.debug:
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
postgres.remove:
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:
go test ./... -timeout 60s
@ -33,7 +50,7 @@ client.dev:
docs.dev:
cd docs && yarn dev
client.deps:
client.deps:
cd client && yarn install
client.build: client.deps
@ -41,4 +58,4 @@ client.build: client.deps
test: api.test
build: api.build client.build
build: api.build client.build

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.
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
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).
@ -23,8 +35,9 @@ You can view my public instance with my listening data at https://koito.mnrva.de
## Screenshots
![screenshot one](assets/screenshot1.png)
![screenshot two](assets/screenshot2.png)
![screenshot three](assets/screenshot3.png)
<img width="2021" height="1330" alt="image" src="https://github.com/user-attachments/assets/956748ff-f61f-4102-94b2-50783d9ee72b" />
<img width="1505" height="1018" alt="image" src="https://github.com/user-attachments/assets/5f7e1162-f723-4e4b-a528-06cf26d1d870" />
## 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.
## 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
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
- 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 {
limit: number,
period: string,
page: number,
artist_id?: number,
album_id?: number,
track_id?: number
limit: number;
period: string;
page: number;
artist_id?: number;
album_id?: number;
track_id?: number;
}
interface getActivityArgs {
step: string
range: number
month: number
year: number
artist_id: number
album_id: number
track_id: number
step: string;
range: number;
month: number;
year: number;
artist_id: number;
album_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>> {
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>>)
async function handleJson<T>(r: Response): Promise<T> {
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>> {
if (args.artist_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
} else if (args.album_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
} else {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
}
async function getTopTracks(
args: getItemsArgs
): Promise<PaginatedResponse<Ranked<Track>>> {
let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
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>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
} else {
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
}
async function getTopAlbums(
args: getItemsArgs
): Promise<PaginatedResponse<Ranked<Album>>> {
let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Album>>>(r);
}
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
async function getTopArtists(
args: getItemsArgs
): 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[]> {
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[]>)
async function getActivity(
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> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
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> {
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) {
if (!id) {
id = 'default'
}
return `/images/${size}/${id}`
if (!id) {
id = "default";
}
return `/images/${size}/${id}`;
}
function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, {
method: "POST",
body: form,
})
return fetch(`/apis/web/v1/replace-image`, {
method: "POST",
body: form,
});
}
function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST",
})
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST",
});
}
function mergeAlbums(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, {
method: "POST",
})
function mergeAlbums(
from: number,
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> {
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, {
method: "POST",
})
function mergeArtists(
from: number,
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> {
return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, {
method: "POST",
})
function login(
username: string,
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> {
return fetch(`/apis/web/v1/logout`, {
method: "POST",
})
return fetch(`/apis/web/v1/logout`, {
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[]> {
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 r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, {
method: "POST"
});
if (!r.ok) {
let errorMessage = `error: ${r.status}`;
try {
const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === 'string') {
errorMessage = errorData.error;
}
} catch (e) {
console.error("unexpected api error:", e);
}
throw new Error(errorMessage);
const form = new URLSearchParams();
form.append("label", label);
const r = await fetch(`/apis/web/v1/user/apikeys`, {
method: "POST",
body: form,
});
if (!r.ok) {
let errorMessage = `error: ${r.status}`;
try {
const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === "string") {
errorMessage = errorData.error;
}
} catch (e) {
console.error("unexpected api error:", e);
}
const data: ApiKey = await r.json();
return data;
throw new Error(errorMessage);
}
const data: ApiKey = await r.json();
return data;
};
function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE"
})
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE",
});
}
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, {
method: "PATCH"
})
const form = new URLSearchParams();
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> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE"
})
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE",
});
}
function updateUser(username: string, password: string) {
return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, {
method: "PATCH"
})
const form = new URLSearchParams();
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[]> {
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> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
method: 'POST'
})
function createAlias(
type: string,
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> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
method: "DELETE"
})
function deleteAlias(
type: string,
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> {
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, {
method: "POST"
})
function setPrimaryAlias(
type: string,
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> {
const ms = new Date(listen.time).getTime()
const unix= Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE"
})
const ms = new Date(listen.time).getTime();
const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE",
});
}
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 {
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
deleteListen,
}
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getInterest,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
getCfg,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
updateMbzId,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
deleteListen,
getAlbum,
getExport,
submitListen,
getNowPlaying,
getRewindStats,
};
type Track = {
id: number
title: string
artists: SimpleArtists[]
listen_count: number
image: string
album_id: number
musicbrainz_id: string
}
id: number;
title: string;
artists: SimpleArtists[];
listen_count: number;
image: string;
album_id: number;
musicbrainz_id: string;
time_listened: number;
first_listen: number;
all_time_rank: number;
};
type Artist = {
id: number
name: string
image: string,
aliases: string[]
listen_count: number
musicbrainz_id: string
}
id: number;
name: string;
image: string;
aliases: string[];
listen_count: number;
musicbrainz_id: string;
time_listened: number;
first_listen: number;
is_primary: boolean;
all_time_rank: number;
};
type Album = {
id: number,
title: string
image: string
listen_count: number
is_various_artists: boolean
artists: SimpleArtists[]
musicbrainz_id: string
}
id: number;
title: string;
image: string;
listen_count: number;
is_various_artists: boolean;
artists: SimpleArtists[];
musicbrainz_id: string;
time_listened: number;
first_listen: number;
all_time_rank: number;
};
type Alias = {
id: number
alias: string
source: string
is_primary: boolean
}
id: number;
alias: string;
source: string;
is_primary: boolean;
};
type Listen = {
time: string,
track: Track,
}
time: string;
track: Track;
};
type PaginatedResponse<T> = {
items: T[],
total_record_count: number,
has_next_page: boolean,
current_page: number,
items_per_page: number,
}
items: T[];
total_record_count: number;
has_next_page: boolean;
current_page: number;
items_per_page: number;
};
type Ranked<T> = {
item: T;
rank: number;
};
type ListenActivityItem = {
start_time: Date,
listens: number
}
start_time: Date;
listens: number;
};
type InterestBucket = {
bucket_start: Date;
bucket_end: Date;
listen_count: number;
};
type SimpleArtists = {
name: string
id: number
}
name: string;
id: number;
};
type Stats = {
listen_count: number
track_count: number
album_count: number
artist_count: number
hours_listened: number
}
listen_count: number;
track_count: number;
album_count: number;
artist_count: number;
minutes_listened: number;
};
type SearchResponse = {
albums: Album[]
artists: Artist[]
tracks: Track[]
}
albums: Album[];
artists: Artist[];
tracks: Track[];
};
type User = {
id: number
username: string
role: 'user' | 'admin'
}
id: number;
username: string;
role: "user" | "admin";
};
type ApiKey = {
id: number
key: string
label: string
created_at: Date
}
id: number;
key: string;
label: string;
created_at: Date;
};
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 {
getItemsArgs,
getActivityArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
ListenActivityItem,
User,
Alias,
ApiKey,
ApiError
}
getItemsArgs,
getActivityArgs,
getInterestArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
Ranked,
ListenActivityItem,
InterestBucket,
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";
@theme {
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
@keyframes fade-in-scale {
0% {
opacity: 0;
transform: scale(0.95);
}
@keyframes fade-out-scale {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
--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-scale {
0% {
opacity: 1;
transform: scale(1);
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
100% {
opacity: 0;
transform: scale(0.95);
}
}
--animate-fade-in: fade-in 0.1s ease forwards;
--animate-fade-out: fade-out 0.1s ease forwards;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}
:root {
--header-xl: 36px;
--header-lg: 28px;
@ -61,20 +58,21 @@
--header-sm: 16px;
--header-xl-weight: 600;
--header-weight: 600;
--header-line-height: 3rem;
}
@media (min-width: 60rem) {
:root {
--header-xl: 78px;
--header-lg: 28px;
--header-lg: 36px;
--header-md: 22px;
--header-sm: 16px;
--header-xl-weight: 600;
--header-weight: 600;
--header-line-height: 1.3em;
}
}
html,
body {
background-color: var(--color-bg);
@ -102,21 +100,24 @@ h1 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-xl);
line-height: var(--header-line-height);
}
h2 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-lg);
}
h3 {
font-family: "League Spartan";
font-weight: var(--header-weight);
font-size: var(--header-md);
margin-bottom: 0.5em;
}
h3 {
h4 {
font-family: "League Spartan";
font-size: var(--header-sm);
font-weight: var(--header-weight);
}
h4 {
font-size: var(--header-md);
}
.header-font {
font-family: "League Spartan";
}
@ -132,23 +133,21 @@ h4 {
text-decoration: underline;
}
input[type="text"] {
input[type="text"],
input[type="password"],
textarea {
border: 1px solid var(--color-bg);
}
input[type="text"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
input[type="checkbox"] {
height: fit-content;
}
input[type="password"] {
border: 1px solid var(--color-bg);
}
input[type="password"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
}
input[type="checkbox"]:focus {
outline: none;
border: 1px solid var(--color-fg-tertiary);
input:focus-visible,
button:focus-visible,
a:focus-visible,
select:focus-visible,
textarea:focus-visible {
border-color: transparent;
outline: 2px solid var(--color-fg-tertiary);
}
button:hover {
@ -190,4 +189,4 @@ button.default[disabled]:hover {
}
button.default:hover {
color: var(--color-fg-secondary);
}
}

View file

@ -1,186 +1,196 @@
import { useQuery } from "@tanstack/react-query"
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
import Popup from "./Popup"
import { useEffect, useState } from "react"
import { useTheme } from "~/hooks/useTheme"
import ActivityOptsSelector from "./ActivityOptsSelector"
import { useQuery } from "@tanstack/react-query";
import {
getActivity,
type getActivityArgs,
type ListenActivityItem,
} 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 {
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary')
.trim();
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("");
}
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;
return value;
}
interface Props {
step?: string
range?: number
month?: number
year?: number
artistId?: number
albumId?: number
trackId?: number
configurable?: boolean
autoAdjust?: boolean
step?: string;
range?: number;
month?: number;
year?: number;
artistId?: number;
albumId?: number;
trackId?: number;
configurable?: boolean;
autoAdjust?: boolean;
}
export default function ActivityGrid({
step = 'day',
range = 182,
month = 0,
year = 0,
artistId = 0,
albumId = 0,
trackId = 0,
configurable = false,
autoAdjust = false,
}: Props) {
step = "day",
range = 182,
month = 0,
year = 0,
artistId = 0,
albumId = 0,
trackId = 0,
configurable = false,
}: Props) {
const [stepState, setStep] = useState(step);
const [rangeState, setRange] = useState(range);
const [color, setColor] = useState(getPrimaryColor())
const [stepState, setStep] = useState(step)
const [rangeState, setRange] = useState(range)
const { isPending, isError, data, error } = useQuery({
queryKey: [
'listen-activity',
{
step: stepState,
range: rangeState,
month: month,
year: year,
artist_id: artistId,
album_id: albumId,
track_id: trackId
},
],
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
});
const { isPending, isError, data, error } = useQuery({
queryKey: [
"listen-activity",
{
step: stepState,
range: rangeState,
month: month,
year: year,
artist_id: artistId,
album_id: albumId,
track_id: trackId,
},
],
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
});
const { theme } = useTheme();
const color = getPrimaryColor(theme);
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 {
stepSetter: (value: string) => void;
@ -15,18 +16,15 @@ export default function ActivityOptsSelector({
currentRange,
disableCache = false,
}: Props) {
const stepPeriods = ['day', 'week', 'month', 'year'];
const rangePeriods = [105, 182, 365];
const stepPeriods = ['day', 'week', 'month'];
const rangePeriods = [105, 182, 364];
const [collapsed, setCollapsed] = useState(true);
const stepDisplay = (str: string): string => {
return str.split('_').map(w =>
w.split('').map((char, index) =>
index === 0 ? char.toUpperCase() : char).join('')
).join(' ');
};
const rangeDisplay = (r: number): string => {
return `${r}`
const setMenuOpen = (val: boolean) => {
setCollapsed(val)
if (!disableCache) {
localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val));
}
}
const setStep = (val: string) => {
@ -42,56 +40,67 @@ export default function ActivityOptsSelector({
localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val));
}
};
useEffect(() => {
if (!disableCache) {
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
if (cachedRange) {
rangeSetter(cachedRange);
}
// TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default.
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182');
if (cachedRange) rangeSetter(cachedRange);
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
if (cachedStep) {
stepSetter(cachedStep);
}
if (cachedStep) stepSetter(cachedStep);
const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]);
if (cachedStep) setMenuOpen(cachedConfiguring !== "true");
}
}, []);
}, []);
return (
<div className="flex flex-col">
<div className="flex gap-2 items-center">
<p>Step:</p>
{stepPeriods.map((p, i) => (
<div key={`step_selector_${p}`}>
<button
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
onClick={() => setStep(p)}
disabled={p === currentStep}
>
{stepDisplay(p)}
</button>
<span className="color-fg-secondary">
{i !== stepPeriods.length - 1 ? '|' : ''}
</span>
</div>
))}
</div>
<div className="relative w-full">
<button
onClick={() => setMenuOpen(!collapsed)}
className="absolute left-[75px] -top-9 text-muted hover:color-fg transition"
title="Toggle options"
>
{collapsed ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
</button>
<div className="flex gap-2 items-center">
<p>Range:</p>
{rangePeriods.map((r, i) => (
<div key={`range_selector_${r}`}>
<button
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
onClick={() => setRange(r)}
disabled={r === currentRange}
>
{rangeDisplay(r)}
</button>
<span className="color-fg-secondary">
{i !== rangePeriods.length - 1 ? '|' : ''}
</span>
<div
className={`overflow-hidden transition-[max-height,opacity] duration-250 ease ${
collapsed ? 'max-h-0 opacity-0' : 'max-h-[100px] opacity-100'
}`}
>
<div className="flex flex-wrap gap-4 mt-1 text-sm">
<div className="flex items-center gap-1">
<span className="text-muted">Step:</span>
{stepPeriods.map((p) => (
<button
key={p}
className={`px-1 rounded transition ${
p === currentStep ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
}`}
onClick={() => setStep(p)}
disabled={p === currentStep}
>
{p}
</button>
))}
</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>
);

View file

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

View file

@ -1,45 +1,58 @@
import { useQuery } from "@tanstack/react-query"
import { getStats } from "api/api"
import { useQuery } from "@tanstack/react-query";
import { getStats, type Stats, type ApiError } from "api/api";
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({
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'
const header = "All time stats";
if (isPending) {
return (
<div>
<h3>{header}</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<>
<div>
<h2>All Time Stats</h2>
<div>
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
</div>
<div>
<span className={numberClasses}>{data.listen_count}</span> Plays
</div>
<div>
<span className={numberClasses}>{data.artist_count}</span> Artists
</div>
<div>
<span className={numberClasses}>{data.album_count}</span> Albums
</div>
<div>
<span className={numberClasses}>{data.track_count}</span> Tracks
</div>
<h3>{header}</h3>
<p className="error">Error: {error.message}</p>
</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 { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
import { Link } from "react-router";
interface Props {
artistId: number
name: string
period: string
artistId: number;
name: string;
period: string;
}
export default function ArtistAlbums({artistId, name, period}: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div>
<h2>Albums From This Artist</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return (
<div>
<h2>Albums From This Artist</h2>
<p className="error">Error:{error.message}</p>
</div>
)
}
export default function ArtistAlbums({ artistId, name }: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"top-albums",
{ limit: 99, period: "all_time", artist_id: artistId },
],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
});
if (isPending) {
return (
<div>
<h2>Albums featuring {name}</h2>
<div className="flex flex-wrap gap-8">
{data.items.map((item) => (
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
<div className="w-[180px] flex flex-col items-start gap-1">
<p>{item.title}</p>
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
</div>
</Link>
))}
</div>
</div>
)
}
<div>
<h3>Albums From This Artist</h3>
<p>Loading...</p>
</div>
);
}
if (isError) {
return (
<div>
<h3>Albums From This Artist</h3>
<p className="error">Error:{error.message}</p>
</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 {
itemType: string,
id: number,
onComplete: Function
}
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
export default function ImageDropHandler({ itemType, onComplete }: Props) {
useEffect(() => {
const handleDragOver = (e: DragEvent) => {
console.log('dragover!!')
@ -25,7 +24,11 @@ export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
const formData = new FormData();
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) => {
if (r.status >= 200 && r.status < 300) {
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 { useQuery } from "@tanstack/react-query"
import { timeSince } from "~/utils/utils"
import ArtistLinks from "./ArtistLinks"
import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api"
import { Link } from "react-router"
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { timeSince } from "~/utils/utils";
import ArtistLinks from "./ArtistLinks";
import {
deleteListen,
getLastListens,
getNowPlaying,
type getItemsArgs,
type Listen,
type Track,
} from "api/api";
import { Link } from "react-router";
import { useAppContext } from "~/providers/AppProvider";
interface Props {
limit: number
artistId?: Number
albumId?: Number
trackId?: number
hideArtists?: boolean
limit: number;
artistId?: Number;
albumId?: Number;
trackId?: number;
hideArtists?: boolean;
showNowPlaying?: boolean;
}
export default function LastPlays(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: ['last-listens', {
limit: props.limit,
period: 'all_time',
artist_id: props.artistId,
album_id: props.albumId,
track_id: props.trackId
}],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
})
const { user } = useAppContext();
const { isPending, isError, data, error } = useQuery({
queryKey: [
"last-listens",
{
limit: props.limit,
period: "all_time",
artist_id: props.artistId,
album_id: props.albumId,
track_id: props.trackId,
},
],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
});
const { data: npData } = useQuery({
queryKey: ["now-playing"],
queryFn: () => getNowPlaying(),
});
const [items, setItems] = useState<Listen[] | null>(null)
const header = "Last played";
const handleDelete = async (listen: Listen) => {
if (!data) return
try {
const res = await deleteListen(listen)
if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
} else {
console.error("Failed to delete listen:", res.status)
}
} catch (err) {
console.error("Error deleting listen:", err)
}
const [items, setItems] = useState<Listen[] | null>(null);
const handleDelete = async (listen: Listen) => {
if (!data) return;
try {
const res = await deleteListen(listen);
if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) =>
(prev ?? data.items).filter((i) => i.time !== listen.time)
);
} else {
console.error("Failed to delete listen:", res.status);
}
} catch (err) {
console.error("Error deleting listen:", err);
}
};
if (isPending) {
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}` : ''
if (isPending) {
return (
<div className="text-sm sm:text-[16px]">
<h2 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2>
<table className="-ml-4">
<tbody>
{listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[1px] pr-2 align-middle">
<button
onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete"
>
×
</button>
</td>
<td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()}
>
{timeSince(new Date(item.time))}
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{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>
)
<div className="w-[300px] sm:w-[500px]">
<h3>{header}</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
return (
<div className="w-[300px] sm:w-[500px]">
<h3>{header}</h3>
<p className="error">Error: {error.message}</p>
</div>
);
}
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 (
<div className="text-sm sm:text-[16px]">
<h3 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>{header}</Link>
</h3>
<table className="-ml-4">
<tbody>
{props.showNowPlaying && npData && npData.currently_playing && (
<tr className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle"></td>
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
Now Playing
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : (
<>
<ArtistLinks artists={npData.track.artists} /> {" "}
</>
)}
<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) => {
if (selected === id) {
setSelected(0)
onSelect({id: id, title: title})
onSelect({id: 0, title: ''})
} else {
setSelected(id)
onSelect({id: id, title: title})
}
}
if (data === undefined) {
if (!data) {
return <></>
}
return (
<div className="w-full">
{ data.artists.length > 0 &&
{ data.artists && data.artists.length > 0 &&
<>
<h3 className={hClasses}>Artists</h3>
<div className={classes}>
@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
</div>
</>
}
{ data.albums.length > 0 &&
{ data.albums && data.albums.length > 0 &&
<>
<h3 className={hClasses}>Albums</h3>
<div className={classes}>
@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
</div>
</>
}
{ data.tracks.length > 0 &&
{ data.tracks && data.tracks.length > 0 &&
<>
<h3 className={hClasses}>Tracks</h3>
<div className={classes}>

View file

@ -1,42 +1,68 @@
import { useQuery } from "@tanstack/react-query"
import ArtistLinks from "./ArtistLinks"
import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import TopListSkeleton from "./skeletons/TopListSkeleton"
import TopItemList from "./TopItemList"
import { useQuery } from "@tanstack/react-query";
import ArtistLinks from "./ArtistLinks";
import {
getTopAlbums,
getTopTracks,
imageUrl,
type getItemsArgs,
} from "api/api";
import { Link } from "react-router";
import TopListSkeleton from "./skeletons/TopListSkeleton";
import TopItemList from "./TopItemList";
interface Props {
limit: number,
period: string,
artistId?: Number
limit: number;
period: string;
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({
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>
}
const header = "Top albums";
if (isPending) {
return (
<div>
<h2 className="hover:underline"><Link to={`/chart/top-albums?period=${props.period}${props.artistId ? `&artist_id=${props.artistId}` : ''}`}>Top Albums</Link></h2>
<div className="max-w-[300px]">
<TopItemList type="album" data={data} />
{data.items.length < 1 ? 'Nothing to show' : ''}
</div>
</div>
)
}
<div className="w-[300px]">
<h3>{header}</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
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 ArtistLinks from "./ArtistLinks"
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import TopListSkeleton from "./skeletons/TopListSkeleton"
import TopItemList from "./TopItemList"
import { useQuery } from "@tanstack/react-query";
import ArtistLinks from "./ArtistLinks";
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api";
import { Link } from "react-router";
import TopListSkeleton from "./skeletons/TopListSkeleton";
import TopItemList from "./TopItemList";
interface Props {
limit: number,
period: string,
artistId?: Number
albumId?: Number
limit: number;
period: string;
artistId?: 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({
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>
}
const header = "Top artists";
if (isPending) {
return (
<div>
<h2 className="hover:underline"><Link to={`/chart/top-artists?period=${props.period}`}>Top Artists</Link></h2>
<div className="max-w-[300px]">
<TopItemList type="artist" data={data} />
{data.items.length < 1 ? 'Nothing to show' : ''}
</div>
</div>
)
}
<div className="w-[300px]">
<h3>{header}</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
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 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;
interface Props<T extends Item> {
data: PaginatedResponse<T>
separators?: ConstrainBoolean
type: "album" | "track" | "artist";
className?: string,
interface Props<T extends Ranked<Item>> {
data: PaginatedResponse<T>;
separators?: ConstrainBoolean;
ranked?: boolean;
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 (
<div className={`flex flex-col gap-1 ${className} min-w-[300px]`}>
{data.items.map((item, index) => {
const key = `${type}-${item.id}`;
return (
<div
key={key}
style={{ fontSize: 12 }}
className={`${
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
}`}
>
<ItemCard item={item} type={type} key={type+item.id} />
</div>
);
})}
function ItemCard({
item,
type,
rank,
ranked,
}: {
item: Item;
type: "album" | "track" | "artist";
rank: number;
ranked?: boolean;
}) {
const itemClasses = `flex items-center gap-2`;
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>
);
}
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) {
case "album": {
const album = item as Album;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("album", album.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("album", album.id)}
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View album: ${album.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(album.image, "small")} alt={album.title} />
<div>
<span style={{fontSize: 14}}>{album.title}</span>
<br />
{album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span>
:
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div>
}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</div>
</div>
</div>
);
}
case "track": {
const track = item as Track;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("track", track.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("track", track.id)}
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View track: ${track.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(track.image, "small")} alt={track.title} />
<div>
<span style={{fontSize: 14}}>{track.title}</span>
<br />
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div>
<div className="color-fg-secondary">{track.listen_count} plays</div>
</div>
</div>
</div>
);
}
case "artist": {
const artist = item as Artist;
return (
<div style={{fontSize: 12}}>
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
<div>
<span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div>
</div>
</Link>
</div>
);
}
return (
<div style={{ fontSize: 12 }} className={itemClasses}>
{ranked && <div className="w-7 text-end">{rank}</div>}
<Link to={`/track/${track.id}`}>
<img
loading="lazy"
src={imageUrl(track.image, "small")}
alt={track.title}
className="min-w-[48px]"
/>
</Link>
<div>
<Link
to={`/track/${track.id}`}
className="hover:text-(--color-fg-secondary)"
>
<span style={{ fontSize: 14 }}>{track.title}</span>
</Link>
<br />
<div>
<ArtistLinks
artists={track.artists || [{ id: 0, Name: "Unknown Artist" }]}
/>
</div>
<div className="color-fg-secondary">{track.listen_count} plays</div>
</div>
</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 { getTopAlbums, type getItemsArgs } from "api/api"
import AlbumDisplay from "./AlbumDisplay"
import { useQuery } from "@tanstack/react-query";
import { getTopAlbums, type getItemsArgs } from "api/api";
import AlbumDisplay from "./AlbumDisplay";
interface Props {
period: string
artistId?: Number
vert?: boolean
hideTitle?: boolean
period: string;
artistId?: Number;
vert?: boolean;
hideTitle?: boolean;
}
export default function TopThreeAlbums(props: Props) {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"top-albums",
{ limit: 3, period: props.period, artist_id: props.artistId, page: 0 },
],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
});
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
})
if (isPending) {
return <p>Loading...</p>;
}
if (isError) {
return <p className="error">Error:{error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
console.log(data);
console.log(data)
return (
<div>
{!props.hideTitle && <h2>Top Three Albums</h2>}
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
{data.items.map((item, index) => (
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
))}
</div>
</div>
)
}
return (
<div>
{!props.hideTitle && <h3>Top Three Albums</h3>}
<div
className={`flex ${props.vert ? "flex-col" : ""}`}
style={{ gap: 15 }}
>
{data.items.map((item, index) => (
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
))}
</div>
</div>
);
}

View file

@ -1,50 +1,69 @@
import { useQuery } from "@tanstack/react-query"
import ArtistLinks from "./ArtistLinks"
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
import { Link } from "react-router"
import TopListSkeleton from "./skeletons/TopListSkeleton"
import { useEffect } from "react"
import TopItemList from "./TopItemList"
import { useQuery } from "@tanstack/react-query";
import ArtistLinks from "./ArtistLinks";
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api";
import { Link } from "react-router";
import TopListSkeleton from "./skeletons/TopListSkeleton";
import { useEffect } from "react";
import TopItemList from "./TopItemList";
interface Props {
limit: number,
period: string,
artistId?: Number
albumId?: Number
limit: number;
period: string;
artistId?: Number;
albumId?: Number;
}
const TopTracks = (props: Props) => {
const { isPending, isError, data, error } = useQuery({
queryKey: [
"top-tracks",
{
limit: props.limit,
period: props.period,
artist_id: props.artistId,
album_id: props.albumId,
page: 0,
},
],
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
});
const { isPending, isError, data, error } = useQuery({
queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
})
if (isPending) {
return (
<div className="w-[300px]">
<h2>Top Tracks</h2>
<p>Loading...</p>
</div>
)
}
if (isError) {
return <p className="error">Error:{error.message}</p>
}
let params = ''
params += props.artistId ? `&artist_id=${props.artistId}` : ''
params += props.albumId ? `&album_id=${props.albumId}` : ''
const header = "Top tracks";
if (isPending) {
return (
<div>
<h2 className="hover:underline"><Link to={`/chart/top-tracks?period=${props.period}${params}`}>Top Tracks</Link></h2>
<div className="max-w-[300px]">
<TopItemList type="track" data={data}/>
{data.items.length < 1 ? 'Nothing to show' : ''}
</div>
</div>
)
}
<div className="w-[300px]">
<h3>{header}</h3>
<p>Loading...</p>
</div>
);
} else if (isError) {
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 { useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { useAppContext } from "~/providers/AppProvider"
import { logout, updateUser } from "api/api";
import { useState } from "react";
import { AsyncButton } from "../AsyncButton";
import { useAppContext } from "~/providers/AppProvider";
export default function Account() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { user, setUsername: setCtxUsername } = useAppContext()
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const { user, setUsername: setCtxUsername } = useAppContext();
const logoutHandler = () => {
setLoading(true)
logout()
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
setError('')
setSuccess('')
if (password != "" && confirmPw === "") {
setError("confirm your new password before submitting")
return
const logoutHandler = () => {
setLoading(true);
logout()
.then((r) => {
if (r.ok) {
window.location.reload();
} else {
r.json().then((r) => setError(r.error));
}
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)
})
.catch((err) => setError(err));
setLoading(false);
};
const updateHandler = () => {
setError("");
setSuccess("");
if (password != "" && confirmPw === "") {
setError("confirm your new password before submitting");
return;
}
setError("");
setSuccess("");
setLoading(true);
updateUser(username, password)
.then((r) => {
if (r.ok) {
setSuccess("sucessfully updated user");
if (username != "") {
setCtxUsername(username);
}
setUsername("");
setPassword("");
setConfirmPw("");
} else {
r.json().then((r) => setError(r.error));
}
})
.catch((err) => setError(err));
setLoading(false);
};
return (
<>
<h2>Account</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div>
<h2>Update User</h2>
<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>}
return (
<>
<h3>Account</h3>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>
You're logged in as <strong>{user?.username}</strong>
</p>
<AsyncButton loading={loading} onClick={logoutHandler}>
Logout
</AsyncButton>
</div>
</>
)
}
<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";
type CopiedState = {
x: number;
y: number;
visible: boolean;
x: number;
y: number;
visible: boolean;
};
export default function ApiKeysModal() {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<ApiKey[]>([])
const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => {
setExpandedKey(key);
setTimeout(() => {
const el = textRefs.current[key];
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}, 0);
};
const { isPending, isError, data, error } = useQuery({
queryKey: [
'api-keys'
],
queryFn: () => {
return getApiKeys();
},
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [err, setError] = useState<string>();
const [displayData, setDisplayData] = useState<ApiKey[]>([]);
const [copied, setCopied] = useState<CopiedState | null>(null);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleRevealAndSelect = (key: string) => {
setExpandedKey(key);
setTimeout(() => {
const el = textRefs.current[key];
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}, 0);
};
const { isPending, isError, data, error } = useQuery({
queryKey: ["api-keys"],
queryFn: () => {
return getApiKeys();
},
});
useEffect(() => {
if (data) {
setDisplayData(data);
}
}, [data]);
if (isError) {
return <p className="error">Error: {error.message}</p>;
}
if (isPending) {
return <p>Loading...</p>;
}
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
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(() => {
if (data) {
setDisplayData(data)
}
}, [data])
setTimeout(() => setCopied(null), 1500);
};
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
const fallbackCopy = (text: string) => {
const textarea = document.createElement("textarea");
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) {
return (
<p>Loading...</p>
)
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 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,
});
setTimeout(() => setCopied(null), 1500);
};
const fallbackCopy = (text: string) => {
const textarea = document.createElement("textarea");
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);
}
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);
};
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>
return (
<div className="">
<h3>API Keys</h3>
<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>
{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>
)}
<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>}
{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 { AsyncButton } from "../AsyncButton"
import { Modal } from "./Modal"
import { useNavigate } from "react-router"
import { useState } from "react"
import { deleteItem } from "api/api";
import { AsyncButton } from "../AsyncButton";
import { Modal } from "./Modal";
import { useNavigate } from "react-router";
import { useState } from "react";
interface Props {
open: boolean
setOpen: Function
title: string,
id: number,
type: string
open: boolean;
setOpen: Function;
title: string;
id: number;
type: string;
}
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const doDelete = () => {
setLoading(true)
deleteItem(type.toLowerCase(), id)
.then(r => {
if (r.ok) {
navigate('/')
} else {
console.log(r)
}
})
}
const doDelete = () => {
setLoading(true);
deleteItem(type.toLowerCase(), id).then((r) => {
if (r.ok) {
navigate(-1);
} else {
console.log(r);
}
});
};
return (
<Modal isOpen={open} onClose={() => setOpen(false)}>
<h2>Delete "{title}"?</h2>
<p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
</div>
</Modal>
)
}
return (
<Modal isOpen={open} onClose={() => setOpen(false)}>
<h3>Delete "{title}"?</h3>
<p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>
Yes, Delete It
</AsyncButton>
</div>
</Modal>
);
}

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

View file

@ -1,59 +1,74 @@
import { login } from "api/api"
import { useEffect, useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { login } from "api/api";
import { useEffect, useState } from "react";
import { AsyncButton } from "../AsyncButton";
export default function LoginForm() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const loginHandler = () => {
if (username && password) {
setLoading(true)
login(username, password, remember)
.then(r => {
if (r.status >= 200 && r.status < 300) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} else if (username || password) {
setError("username and password are required")
}
const loginHandler = () => {
if (username && password) {
setLoading(true);
login(username, password, remember)
.then((r) => {
if (r.status >= 200 && r.status < 300) {
window.location.reload();
} else {
r.json().then((r) => setError(r.error));
}
})
.catch((err) => setError(err));
setLoading(false);
} else if (username || password) {
setError("username and password are required");
}
};
return (
<>
<h2>Log In</h2>
<div className="flex flex-col items-center gap-4 w-full">
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="flex gap-2">
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
<label htmlFor="kotio-remember">Remember me</label>
</div>
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
</form>
<p className="error">{error}</p>
</div>
</>
)
}
return (
<>
<h3>Log In</h3>
<div className="flex flex-col items-center gap-4 w-full">
<p>
Logging in gives you access to <strong>admin tools</strong>, such as
updating images, merging items, deleting items, and more.
</p>
<form
action="#"
className="flex flex-col items-center gap-4 w-3/4"
onSubmit={(e) => e.preventDefault()}
>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="flex gap-2 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 { search, type SearchResponse } from "api/api";
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";
interface Props {
open: boolean
setOpen: Function
type: string
currentId: number
currentTitle: string
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
open: boolean;
setOpen: Function;
type: string;
currentId: number;
currentTitle: string;
mergeFunc: MergeFunc;
mergeCleanerFunc: MergeSearchCleanerFunc;
}
export default function MergeModal(props: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const navigate = useNavigate()
const [query, setQuery] = useState(props.currentTitle);
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
{ title: "", id: 0 }
);
const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
const [replaceImage, setReplaceImage] = useState(false);
const navigate = useNavigate();
const closeMergeModal = () => {
props.setOpen(false);
setQuery("");
setData(undefined);
setMergeOrderReversed(false);
setMergeTarget({ title: "", id: 0 });
};
const closeMergeModal = () => {
props.setOpen(false)
setQuery('')
setData(undefined)
setMergeOrderReversed(false)
setMergeTarget({title: '', id: 0})
const toggleSelect = ({ title, id }: { title: string; id: number }) => {
setMergeTarget({ title: title, id: id });
};
useEffect(() => {
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;
}
const toggleSelect = ({title, id}: {title: string, id: number}) => {
if (mergeTarget.id === 0) {
setMergeTarget({title: title, id: id})
props
.mergeFunc(from.id, to.id, replaceImage)
.then((r) => {
if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
closeMergeModal();
} else {
window.location.reload();
}
} 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(() => {
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 (
return (
<Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
{ mergeTarget.id !== 0 ?
<>
{mergeOrderReversed ?
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
:
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
}
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
<div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label>
<h3>Merge {props.type}s</h3>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
defaultValue={props.currentTitle}
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${props.type.toLowerCase()[0] === "a" ? "n" : ""
} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
className="w-full mx-auto fg bg rounded p-2"
onFocus={(e) => { setQuery(e.target.value); e.target.select()}}
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect} />
{mergeTarget.id !== 0 ? (
<>
{mergeOrderReversed ? (
<p className="mt-5">
<strong>{props.currentTitle}</strong> will be merged into{" "}
<strong>{mergeTarget.title}</strong>
</p>
) : (
<p className="mt-5">
<strong>{mergeTarget.title}</strong> will be merged into{" "}
<strong>{props.currentTitle}</strong>
</p>
)}
<button
className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)"
onClick={doMerge}
>
Merge Items
</button>
<div className="flex 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 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>
)
);
}

View file

@ -32,10 +32,34 @@ export function Modal({
}
}, [isOpen, shouldRender]);
// Close on Escape key
// Handle keyboard events
useEffect(() => {
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);
return () => document.removeEventListener('keydown', handleKeyDown);
@ -70,13 +94,13 @@ export function Modal({
}`}
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
>
{children}
<button
onClick={onClose}
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
>
🞪
</button>
{children}
</div>
</div>,
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";
interface Props {
open: boolean
setOpen: Function
open: boolean;
setOpen: Function;
}
export default function SearchModal({ open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [query, setQuery] = useState("");
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const closeSearchModal = () => {
setOpen(false)
setQuery('')
setData(undefined)
const closeSearchModal = () => {
setOpen(false);
setQuery("");
setData(undefined);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === "") {
setData(undefined);
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
}
}, [debouncedQuery]);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h2>Search</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
placeholder="Search for an artist, album, or track"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal}/>
</div>
</div>
</Modal>
)
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h3>Search</h3>
<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 { useAppContext } from "~/providers/AppProvider";
import ApiKeysModal from "./ApiKeysModal";
import { AsyncButton } from "../AsyncButton";
import ExportModal from "./ExportModal";
interface Props {
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"
return (
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Modal h={700} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Tabs
defaultValue="Appearance"
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="Account">Account</TabsTrigger>
{user && (
<TabsTrigger className={triggerClasses} value="API Keys">
API Keys
</TabsTrigger>
<>
<TabsTrigger className={triggerClasses} value="API Keys">
API Keys
</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Export">Export</TabsTrigger>
</>
)}
</TabsList>
@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) {
<TabsContent value="API Keys" className={contentClasses}>
<ApiKeysModal />
</TabsContent>
<TabsContent value="Export" className={contentClasses}>
<ExportModal />
</TabsContent>
</Tabs>
</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,55 +1,73 @@
import { ExternalLink, Home, Info } from "lucide-react";
import { ExternalLink, History, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings";
import { getRewindParams, getRewindYear } from "~/utils/utils";
export default function Sidebar() {
const iconSize = 20;
const iconSize = 20;
return (
<div className="
z-50
flex
sm:flex-col
justify-between
sm:fixed
sm:top-0
sm:left-0
sm:h-screen
h-auto
sm:w-auto
w-full
border-b
sm:border-b-0
sm:border-r
border-(--color-bg-tertiary)
pt-2
sm:py-10
sm:px-1
px-4
return (
<div
className="
z-50
flex
sm:flex-col
justify-between
sm:fixed
sm:top-0
sm:left-0
sm:h-screen
h-auto
sm:w-auto
w-full
border-b
sm:border-b-0
sm:border-r
border-(--color-bg-tertiary)
pt-2
sm:py-10
sm:px-1
px-4
bg-(--color-bg)
">
<div className="flex gap-4 sm:flex-col">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
</div>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
);
"
>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
space={10}
to="/"
name="Home"
onClick={() => {}}
modal={<></>}
>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
<SidebarItem
space={10}
to="/rewind"
name="Rewind"
onClick={() => {}}
modal={<></>}
>
<History size={iconSize} />
</SidebarItem>
</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 {
theme: Theme
setTheme: Function
theme: Theme;
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 s.charAt(0).toUpperCase() + s.slice(1);
}
return (
<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}}>
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
</div>
)
}
return (
<div
onClick={() => setTheme(themeName)}
className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-3 items-center border-2 justify-between"
style={{
background: theme.bg,
color: theme.fg,
borderColor: theme.bgSecondary,
}}
>
<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 { useEffect } from 'react';
import { useTheme } from '../../hooks/useTheme';
import { themes } from '~/providers/ThemeProvider';
import ThemeOption from './ThemeOption';
import { useState } from "react";
import { useTheme } from "../../hooks/useTheme";
import themes from "~/styles/themes.css";
import ThemeOption from "./ThemeOption";
import { AsyncButton } from "../AsyncButton";
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 saved = localStorage.getItem('theme');
if (saved && saved !== theme) {
setTheme(saved);
} else if (!saved) {
localStorage.setItem('theme', theme)
}
}, []);
const handleCustomTheme = () => {
console.log(custom);
try {
const themeData = JSON.parse(custom);
setCustomTheme(themeData);
setCustom(JSON.stringify(themeData, null, " "));
console.log(themeData);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
if (theme) {
localStorage.setItem('theme', theme)
}
}, [theme]);
return (
<>
<h2>Select Theme</h2>
<div className="grid grid-cols-2 items-center gap-2">
{themes.map((t) => (
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
))}
return (
<div className="flex flex-col gap-10">
<div>
<div className="flex items-center gap-3">
<h3>Select Theme</h3>
<div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</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";
interface AppContextType {
user: User | null | undefined;
configurableHomeActivity: boolean;
homeItems: number;
defaultTheme: string;
setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void;
setUsername: (value: string) => void;
@ -22,15 +23,19 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
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 setUsername = (value: string) => {
if (!user) {
return
return;
}
setUser({...user, username: value})
}
setUser({ ...user, username: value });
};
useEffect(() => {
fetch("/apis/web/v1/user/me")
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true);
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;
}
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user,
configurableHomeActivity,
homeItems,
defaultTheme,
setConfigurableHomeActivity,
setHomeItems,
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';
// a fair number of colors aren't actually used, but i'm keeping
// them so that I don't have to worry about colors when adding new ui elements
export type Theme = {
name: string,
bg: string
bgSecondary: string
bgTertiary: string
fg: string
fgSecondary: string
fgTertiary: string
primary: string
primaryDim: string
accent: string
accentDim: string
error: string
warning: string
info: string
success: string
}
export const themes: Theme[] = [
{
name: "yuu",
bg: "#161312",
bgSecondary: "#272120",
bgTertiary: "#382F2E",
fg: "#faf5f4",
fgSecondary: "#CCC7C6",
fgTertiary: "#B0A3A1",
primary: "#ff826d",
primaryDim: "#CE6654",
accent: "#464DAE",
accentDim: "#393D74",
error: "#FF6247",
warning: "#FFC107",
success: "#3ECE5F",
info: "#41C4D8",
},
{
name: "varia",
bg: "rgb(25, 25, 29)",
bgSecondary: "#222222",
bgTertiary: "#333333",
fg: "#eeeeee",
fgSecondary: "#aaaaaa",
fgTertiary: "#888888",
primary: "rgb(203, 110, 240)",
primaryDim: "#c28379",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
{
name: "midnight",
bg: "rgb(8, 15, 24)",
bgSecondary: "rgb(15, 27, 46)",
bgTertiary: "rgb(15, 41, 70)",
fg: "#dbdfe7",
fgSecondary: "#9ea3a8",
fgTertiary: "#74787c",
primary: "#1a97eb",
primaryDim: "#2680aa",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
{
name: "catppuccin",
bg: "#1e1e2e",
bgSecondary: "#181825",
bgTertiary: "#11111b",
fg: "#cdd6f4",
fgSecondary: "#a6adc8",
fgTertiary: "#9399b2",
primary: "#89b4fa",
primaryDim: "#739df0",
accent: "#f38ba8",
accentDim: "#d67b94",
error: "#f38ba8",
warning: "#f9e2af",
success: "#a6e3a1",
info: "#89dceb",
},
{
name: "autumn",
bg: "rgb(44, 25, 18)",
bgSecondary: "rgb(70, 40, 18)",
bgTertiary: "#4b2f1c",
fg: "#fef9f3",
fgSecondary: "#dbc6b0",
fgTertiary: "#a3917a",
primary: "#d97706",
primaryDim: "#b45309",
accent: "#8c4c28",
accentDim: "#6b3b1f",
error: "#d1433f",
warning: "#e38b29",
success: "#6b8e23",
info: "#c084fc",
},
{
name: "black",
bg: "#000000",
bgSecondary: "#1a1a1a",
bgTertiary: "#2a2a2a",
fg: "#dddddd",
fgSecondary: "#aaaaaa",
fgTertiary: "#888888",
primary: "#08c08c",
primaryDim: "#08c08c",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
{
name: "wine",
bg: "#23181E",
bgSecondary: "#2C1C25",
bgTertiary: "#422A37",
fg: "#FCE0B3",
fgSecondary: "#C7AC81",
fgTertiary: "#A78E64",
primary: "#EA8A64",
primaryDim: "#BD7255",
accent: "#FAE99B",
accentDim: "#C6B464",
error: "#fca5a5",
warning: "#fde68a",
success: "#bbf7d0",
info: "#bae6fd",
},
{
name: "pearl",
bg: "#FFFFFF",
bgSecondary: "#EEEEEE",
bgTertiary: "#E0E0E0",
fg: "#333333",
fgSecondary: "#555555",
fgTertiary: "#777777",
primary: "#007BFF",
primaryDim: "#0056B3",
accent: "#28A745",
accentDim: "#1E7E34",
error: "#DC3545",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
{
name: "asuka",
bg: "#3B1212",
bgSecondary: "#471B1B",
bgTertiary: "#020202",
fg: "#F1E9E6",
fgSecondary: "#CCB6AE",
fgTertiary: "#9F8176",
primary: "#F1E9E6",
primaryDim: "#CCB6AE",
accent: "#41CE41",
accentDim: "#3BA03B",
error: "#DC143C",
warning: "#FFD700",
success: "#32CD32",
info: "#1E90FF",
},
{
name: "urim",
bg: "#101713",
bgSecondary: "#1B2921",
bgTertiary: "#273B30",
fg: "#D2E79E",
fgSecondary: "#B4DA55",
fgTertiary: "#7E9F2A",
primary: "#ead500",
primaryDim: "#C1B210",
accent: "#28A745",
accentDim: "#1E7E34",
error: "#EE5237",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
{
name: "match",
bg: "#071014",
bgSecondary: "#0A181E",
bgTertiary: "#112A34",
fg: "#ebeaeb",
fgSecondary: "#BDBDBD",
fgTertiary: "#A2A2A2",
primary: "#fda827",
primaryDim: "#C78420",
accent: "#277CFD",
accentDim: "#1F60C1",
error: "#F14426",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
{
name: "lemon",
bg: "#1a171a",
bgSecondary: "#2E272E",
bgTertiary: "#443844",
fg: "#E6E2DC",
fgSecondary: "#B2ACA1",
fgTertiary: "#968F82",
primary: "#f5c737",
primaryDim: "#C29D2F",
accent: "#277CFD",
accentDim: "#1F60C1",
error: "#F14426",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
];
import {
createContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import { type Theme, themes } from "~/styles/themes.css";
import { themeVars } from "~/styles/vars.css";
import { useAppContext } from "./AppProvider";
interface ThemeContextValue {
theme: string;
themeName: string;
theme: Theme;
setTheme: (theme: string) => void;
resetTheme: () => void;
setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({
theme: initialTheme,
children,
}: {
theme: string;
children: ReactNode;
}) {
const [theme, setTheme] = useState(initialTheme);
function toKebabCase(str: string) {
return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
}
function applyCustomThemeVars(theme: Theme) {
const root = document.documentElement;
for (const [key, value] of Object.entries(theme)) {
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(() => {
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
if (themeName === "custom") {
applyCustomThemeVars(currentTheme);
} else {
clearCustomThemeVars();
}
}, [theme]);
}, [themeName, currentTheme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<ThemeContext.Provider
value={{
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
export { ThemeContext }
export { ThemeContext };

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
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) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page)
url.searchParams.set("page", page);
const res = await fetch(
`/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 });
}
const top_tracks: PaginatedResponse<Album> = await res.json();
const top_tracks: PaginatedResponse<Track> = await res.json();
return { top_tracks };
}
export default function TrackChart() {
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
const { top_tracks: initialData } = useLoaderData<{
top_tracks: PaginatedResponse<Ranked<Track>>;
}>();
return (
<ChartLayout
@ -28,26 +30,35 @@ export default function TrackChart() {
initialData={initialData}
endpoint="chart/top-tracks"
render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<div className="flex flex-col gap-5 w-full">
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}>
Prev
</button>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>
<TopItemList
ranked
separators
data={data}
className="w-[400px] sm:w-[600px]"
className="w-11/12 sm:w-[600px]"
type="track"
/>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
<button
className="default"
onClick={onNext}
disabled={!data.has_next_page}
>
Next
</button>
</div>

View file

@ -10,30 +10,30 @@ import PeriodSelector from "~/components/PeriodSelector";
import { useAppContext } from "~/providers/AppProvider";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Koito" },
{ name: "description", content: "Koito" },
];
return [{ title: "Koito" }, { name: "description", content: "Koito" }];
}
export default function Home() {
const [period, setPeriod] = useState('week')
const [period, setPeriod] = useState("week");
const { homeItems } = useAppContext();
return (
<main className="flex flex-grow justify-center pb-4">
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
<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 sm:mt-20 mt-10">
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
<AllTimeStats />
<ActivityGrid />
<ActivityGrid configurable />
</div>
<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">
<TopArtists period={period} limit={homeItems} />
<TopAlbums 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>
</main>

View file

@ -6,6 +6,8 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
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() {
const album = useLoaderData() as Album;
const [period, setPeriod] = useState('week')
const [period, setPeriod] = useState("week");
console.log(album)
console.log(album);
return (
<MediaLayout type="Album"
title={album.title}
img={album.image}
id={album.id}
musicbrainzId={album.musicbrainz_id}
imgItemId={album.id}
mergeFunc={mergeAlbums}
mergeCleanerFunc={(r, id) => {
r.artists = []
r.tracks = []
for (let i = 0; i < r.albums.length; i ++) {
if (r.albums[i].id === id) {
delete r.albums[i]
}
}
return r
}}
subContent={<>
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
</>}
<MediaLayout
type="Album"
title={album.title}
img={album.image}
id={album.id}
rank={album.all_time_rank}
musicbrainzId={album.musicbrainz_id}
imgItemId={album.id}
mergeFunc={mergeAlbums}
mergeCleanerFunc={(r, id) => {
r.artists = [];
r.tracks = [];
for (let i = 0; i < r.albums.length; i++) {
if (r.albums[i].id === id) {
delete r.albums[i];
}
}
return r;
}}
subContent={
<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">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} />
<ActivityGrid autoAdjust configurable albumId={album.id} />
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} />
<div className="flex flex-col items-start gap-4">
<ActivityGrid configurable albumId={album.id} />
<InterestGraph albumId={album.id} />
</div>
</div>
</MediaLayout>
);
}

View file

@ -7,6 +7,8 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
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() {
const artist = useLoaderData() as Artist;
const [period, setPeriod] = useState('week')
const [period, setPeriod] = useState("week");
// remove canonical name from alias list
console.log(artist.aliases)
console.log(artist.aliases);
let index = artist.aliases.indexOf(artist.name);
if (index !== -1) {
artist.aliases.splice(index, 1);
}
return (
<MediaLayout type="Artist"
title={artist.name}
img={artist.image}
id={artist.id}
musicbrainzId={artist.musicbrainz_id}
imgItemId={artist.id}
mergeFunc={mergeArtists}
mergeCleanerFunc={(r, id) => {
r.albums = []
r.tracks = []
for (let i = 0; i < r.artists.length; i ++) {
if (r.artists[i].id === id) {
delete r.artists[i]
}
}
return r
}}
subContent={<>
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
</>}
<MediaLayout
type="Artist"
title={artist.name}
img={artist.image}
id={artist.id}
rank={artist.all_time_rank}
musicbrainzId={artist.musicbrainz_id}
imgItemId={artist.id}
mergeFunc={mergeArtists}
mergeCleanerFunc={(r, id) => {
r.albums = [];
r.tracks = [];
for (let i = 0; i < r.artists.length; i++) {
if (r.artists[i].id === id) {
delete r.artists[i];
}
}
return r;
}}
subContent={
<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">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-col gap-20">
<div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} />
<ActivityGrid configurable autoAdjust artistId={artist.id} />
</div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-col gap-20">
<div className="flex gap-15 mt-10 flex-wrap">
<LastPlays limit={20} artistId={artist.id} />
<TopTracks limit={8} period={period} artistId={artist.id} />
<div className="flex flex-col items-start gap-4">
<ActivityGrid configurable artistId={artist.id} />
<InterestGraph artistId={artist.id} />
</div>
</div>
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
</div>
</MediaLayout>
);
}

View file

@ -2,96 +2,208 @@ import React, { useEffect, useState } from "react";
import { average } from "color.js";
import { imageUrl, type SearchResponse } from "api/api";
import ImageDropHandler from "~/components/ImageDropHandler";
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/RenameModal";
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 MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
export type MergeFunc = (
from: number,
to: number,
replaceImage: boolean
) => Promise<Response>;
export type MergeSearchCleanerFunc = (
r: SearchResponse,
id: number
) => SearchResponse;
interface Props {
type: "Track" | "Album" | "Artist"
title: string
img: string
id: number
musicbrainzId: string
imgItemId: number
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
children: React.ReactNode
subContent: React.ReactNode
type: "Track" | "Album" | "Artist";
title: string;
img: string;
id: number;
rank: number;
musicbrainzId: string;
imgItemId: number;
mergeFunc: MergeFunc;
mergeCleanerFunc: MergeSearchCleanerFunc;
children: React.ReactNode;
subContent: React.ReactNode;
}
export default function MediaLayout(props: Props) {
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const { user } = useAppContext();
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
const { user } = useAppContext();
useEffect(() => {
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
});
}, [props.img]);
useEffect(() => {
average(imageUrl(props.img, "small"), { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
});
}, [props.img]);
const replaceImageCallback = () => {
window.location.reload()
}
const replaceImageCallback = () => {
window.location.reload();
};
const title = `${props.title} - Koito`
const title = `${props.title} - Koito`;
const mobileIconSize = 22
const normalIconSize = 30
const mobileIconSize = 22;
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 (
<main
className="w-full flex flex-col flex-grow"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
transition: '1000',
}}
>
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
<title>{title}</title>
<meta property="og:title" content={title} />
<meta
name="description"
content={title}
/>
<div className="w-19/20 mx-auto pt-12">
<div className="flex gap-8 flex-wrap relative">
<div className="flex flex-col justify-around">
<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>
<div className="flex flex-col items-start">
<h3>{props.type}</h3>
<h1>{props.title}</h1>
{props.subContent}
</div>
{ user &&
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
<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>
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
</div>
}
</div>
{props.children}
console.log("MBZ:", props.musicbrainzId);
return (
<main
className="w-full flex flex-col flex-grow"
style={{
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 700px)`,
transition: "1000",
}}
>
<ImageDropHandler
itemType={props.type.toLowerCase() === "artist" ? "artist" : "album"}
onComplete={replaceImageCallback}
/>
<title>{title}</title>
<meta property="og:title" content={title} />
<meta name="description" content={title} />
<div className="w-19/20 mx-auto pt-12">
<div className="flex gap-8 flex-wrap md:flex-nowrap relative">
<div className="flex flex-col justify-around">
<img
style={{ zIndex: 5 }}
src={imageUrl(props.img, "large")}
alt={props.title}
className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg"
/>
</div>
<div className="flex flex-col items-start">
<h3>{props.type}</h3>
<div className="flex">
<h1>
{props.title}
<span className="text-xl font-medium text-(--color-fg-secondary)">
{" "}
#{props.rank}
</span>
</h1>
</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 MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
if (!res.ok) {
throw new Response("Failed to load track", { status: res.status });
}
const track: Track = await res.json();
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
if (!res.ok) {
throw new Response("Failed to load album for track", { status: res.status })
}
const album: Album = await res.json()
return {track: track, album: album};
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
if (!res.ok) {
throw new Response("Failed to load track", { status: res.status });
}
const track: Track = await res.json();
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
if (!res.ok) {
throw new Response("Failed to load album for track", {
status: res.status,
});
}
const album: Album = await res.json();
return { track: track, album: album };
}
export default function Track() {
const { track, album } = useLoaderData();
const [period, setPeriod] = useState('week')
const { track, album } = useLoaderData();
const [period, setPeriod] = useState("week");
return (
<MediaLayout type="Track"
title={track.title}
img={track.image}
id={track.id}
musicbrainzId={album.musicbrainz_id}
imgItemId={track.album_id}
mergeFunc={mergeTracks}
mergeCleanerFunc={(r, id) => {
r.albums = []
r.artists = []
for (let i = 0; i < r.tracks.length; i ++) {
if (r.tracks[i].id === id) {
delete r.tracks[i]
}
}
return r
}}
subContent={<div className="flex flex-col gap-4 items-start">
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
</div>}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={20} trackId={track.id}/>
<ActivityGrid trackId={track.id} configurable autoAdjust />
</div>
</MediaLayout>
)
return (
<MediaLayout
type="Track"
title={track.title}
img={track.image}
id={track.id}
rank={track.all_time_rank}
musicbrainzId={track.musicbrainz_id}
imgItemId={track.album_id}
mergeFunc={mergeTracks}
mergeCleanerFunc={(r, id) => {
r.albums = [];
r.artists = [];
for (let i = 0; i < r.tracks.length; i++) {
if (r.tracks[i].id === id) {
delete r.tracks[i];
}
}
return r;
}}
subContent={
<div className="flex flex-col gap-2 items-start">
<p>
Appears on{" "}
<Link className="hover:underline" to={`/album/${track.album_id}`}>
{album.title}
</Link>
</p>
{track.listen_count !== 0 && (
<p>
{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 TopArtists from "~/components/TopArtists"
import TopTracks from "~/components/TopTracks"
import { useTheme } from "~/hooks/useTheme"
import { themes, type Theme } from "~/styles/themes.css"
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
@ -24,43 +56,49 @@ export default function ThemeHelper() {
<TopTracks period="all_time" limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.5)} />
</div>
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>Example User</strong></p>
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
<div className="flex gap-10">
<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) w-[300px] p-5 h-full rounded-md" value={custom} />
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
/>
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
<div className="flex flex-col gap-4 items-center">
<p>You"re logged in as <strong>Example User</strong></p>
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
/>
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm password"
className="w-full mx-auto fg bg rounded p-2"
/>
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
</div>
<div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
<label htmlFor="reverse-merge-order">Example checkbox</label>
</div>
<p className="success">successfully displayed example text</p>
<p className="error">this is an example of error text</p>
<p className="info">here is an informational example</p>
<p className="warning">heed this warning, traveller</p>
</div>
<div 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>
)

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

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 => {
switch (timeframe) {
case Timeframe.Day:
return "1 day"
case Timeframe.Week:
return "1 week"
case Timeframe.Month:
return "1 month"
case Timeframe.Year:
return "1 year"
case Timeframe.AllTime:
return "99 years"
}
}
function timeSince(date: Date) {
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
const intervals = [
{ label: 'year', seconds: 31536000 },
{ label: 'month', seconds: 2592000 },
{ label: 'week', seconds: 604800 },
{ label: 'day', seconds: 86400 },
{ label: 'hour', seconds: 3600 },
{ label: 'minute', seconds: 60 },
{ label: 'second', seconds: 1 },
];
for (const interval of intervals) {
const count = Math.floor(seconds / interval.seconds);
if (count >= 1) {
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
}
}
return 'just now';
}
export { timeSince }
type hsl = {
h: number,
s: number,
l: number,
}
const hexToHSL = (hex: string): hsl => {
let r = 0, g = 0, b = 0;
hex = hex.replace('#', '');
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16);
} else if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
}
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
case g: h = ((b - r) / d + 2); break;
case b: h = ((r - g) / d + 4); break;
}
h /= 6;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
switch (timeframe) {
case Timeframe.Day:
return "1 day";
case Timeframe.Week:
return "1 week";
case Timeframe.Month:
return "1 month";
case Timeframe.Year:
return "1 year";
case Timeframe.AllTime:
return "99 years";
}
};
export {hexToHSL}
export type {hsl}
const getRewindYear = (): number => {
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",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"@recharts/devtools": "^0.0.7",
"@tanstack/react-query": "^5.80.6",
"@vanilla-extract/css": "^1.17.4",
"color.js": "^1.2.0",
"isbot": "^5.1.27",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.5.3"
"react-is": "^19.2.3",
"react-router": "^7.5.3",
"recharts": "^3.6.0"
},
"devDependencies": {
"@react-router/dev": "^7.5.3",
@ -27,6 +31,7 @@
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vanilla-extract/vite-plugin": "^5.0.6",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.3",

View file

@ -1,6 +1,6 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"name": "Koito",
"short_name": "Koito",
"icons": [
{
"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 { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
const isDocker = process.env.BUILD_TARGET === 'docker';
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), vanillaExtractPlugin()],
server: {
proxy: {
'/apis': {

View file

@ -24,7 +24,7 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82"
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"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
@ -185,7 +185,7 @@
dependencies:
"@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"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
@ -222,6 +222,11 @@
"@babel/plugin-transform-modules-commonjs" "^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":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
@ -274,6 +279,11 @@
dependencies:
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":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
@ -679,6 +689,23 @@
morgan "^1.10.0"
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":
version "4.42.0"
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"
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":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
@ -908,11 +945,69 @@
dependencies:
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":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
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":
version "20.19.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
@ -932,6 +1027,75 @@
dependencies:
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:
version "1.3.8"
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"
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:
version "5.0.1"
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"
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:
version "2.0.1"
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"
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:
version "0.5.4"
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"
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"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
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:
version "2.6.9"
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:
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:
version "1.6.0"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
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:
version "2.0.0"
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:
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"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
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"
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:
version "2.2.1"
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"
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:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@ -1519,11 +1820,26 @@ iconv-lite@0.4.24:
dependencies:
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:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
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:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@ -1560,6 +1876,11 @@ jackspeak@^3.1.2:
optionalDependencies:
"@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:
version "2.4.2"
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-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:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lru-cache@^10.2.0:
lru-cache@^10.2.0, lru-cache@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
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"
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:
version "0.3.0"
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"
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:
version "1.10.0"
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"
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:
version "1.0.1"
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"
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:
version "3.1.1"
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"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
pathe@^2.0.3:
pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
picocolors@^1.1.1:
picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
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"
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:
version "8.5.4"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
@ -1991,6 +2369,19 @@ react-dom@^19.1.0:
dependencies:
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:
version "0.14.2"
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"
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:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
@ -2298,6 +2726,11 @@ tar@^7.4.3:
mkdirp "^3.0.1"
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:
version "0.2.14"
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"
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:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
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:
version "6.21.3"
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"
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:
version "1.0.1"
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"
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"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
@ -2410,7 +2878,7 @@ vite-tsconfig-paths@^5.1.4:
globrex "^0.1.2"
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"
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
@ -2465,3 +2933,8 @@ yallist@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
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 (
"fmt"
"os"
"strings"
"log"
"github.com/gabehf/koito/engine"
)
@ -11,7 +13,7 @@ var Version = "dev"
func main() {
if err := engine.Run(
os.Getenv,
readEnvOrFile,
os.Stdout,
Version,
); err != nil {
@ -19,3 +21,23 @@ func main() {
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

@ -4,7 +4,7 @@ VALUES ($1, $2, $3)
RETURNING *;
-- name: GetArtist :one
SELECT
SELECT
a.*,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@ -13,27 +13,29 @@ WHERE a.id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
-- name: GetTrackArtists :many
SELECT
a.*
SELECT
a.*,
at.is_primary as is_primary
FROM artists_with_name a
LEFT JOIN artist_tracks at ON a.id = at.artist_id
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
SELECT * FROM artists WHERE image = $1 LIMIT 1;
-- name: GetReleaseArtists :many
SELECT
a.*
SELECT
a.*,
ar.is_primary as is_primary
FROM artists_with_name a
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
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
WITH artist_with_aliases AS (
SELECT
SELECT
a.*,
COALESCE(array_agg(aa.alias), '{}')::text[] AS aliases
FROM artists_with_name a
@ -46,7 +48,7 @@ WITH artist_with_aliases AS (
SELECT * FROM artist_with_aliases;
-- name: GetArtistByMbzID :one
SELECT
SELECT
a.*,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@ -54,28 +56,77 @@ LEFT JOIN artist_aliases aa ON a.id = aa.artist_id
WHERE a.musicbrainz_id = $1
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
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.name,
a.musicbrainz_id,
a.image,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.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
WHERE l.listened_at BETWEEN $1 AND $2
GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
ORDER BY listen_count DESC
FROM listens l
JOIN tracks t ON l.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
WHERE l.listened_at BETWEEN $1 AND $2
GROUP BY a.id, a.name, a.musicbrainz_id, a.image
) x
ORDER BY x.listen_count DESC, x.id
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
SELECT COUNT(DISTINCT at.artist_id) AS total_count
FROM listens l
JOIN artist_tracks at ON l.track_id = at.track_id
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
UPDATE artists SET musicbrainz_id = $2
WHERE id = $1;
@ -109,4 +160,4 @@ SET artist_id = $2
WHERE artist_id = $1;
-- name: DeleteArtist :exec
DELETE FROM artists WHERE id = $1;
DELETE FROM artists WHERE id = $1;

View file

@ -3,7 +3,13 @@ DO $$
BEGIN
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 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 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 $$;

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

@ -4,16 +4,11 @@ VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING;
-- name: GetLastListensPaginated :many
SELECT
SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
@ -21,35 +16,35 @@ ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
-- name: GetLastListensFromArtistPaginated :many
SELECT
SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
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
WHERE at.artist_id = $5
AND l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
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
SELECT
SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
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
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
SELECT
SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
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
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
SELECT COUNT(*) AS total_count
FROM listens l
@ -129,94 +144,122 @@ WHERE l.listened_at BETWEEN $1 AND $2
AND t.id = $3;
-- name: ListenActivity :many
WITH buckets AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
),
bucketed_listens AS (
SELECT
b.bucket_start,
COUNT(l.listened_at) AS listen_count
FROM buckets b
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;
SELECT
(listened_at AT TIME ZONE $1::text)::date as day,
COUNT(*) AS listen_count
FROM listens
WHERE listened_at >= $2
AND listened_at < $3
GROUP BY day
ORDER BY day;
-- name: ListenActivityForArtist :many
WITH buckets AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
),
filtered_listens AS (
SELECT l.*
FROM listens l
JOIN artist_tracks t ON l.track_id = t.track_id
WHERE t.artist_id = $4
),
bucketed_listens AS (
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;
SELECT
(listened_at AT TIME ZONE $1::text)::date as day,
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
WHERE l.listened_at >= $2
AND l.listened_at < $3
AND at.artist_id = $4
GROUP BY day
ORDER BY day;
-- name: ListenActivityForRelease :many
WITH buckets AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
),
filtered_listens AS (
SELECT l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $4
),
bucketed_listens AS (
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;
SELECT
(listened_at AT TIME ZONE $1::text)::date as day,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE l.listened_at >= $2
AND l.listened_at < $3
AND t.release_id = $4
GROUP BY day
ORDER BY day;
-- name: ListenActivityForTrack :many
WITH buckets AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
),
filtered_listens AS (
SELECT l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $4
),
bucketed_listens AS (
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;
SELECT
(listened_at AT TIME ZONE $1::text)::date as day,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE l.listened_at >= $2
AND l.listened_at < $3
AND t.id = $4
GROUP BY day
ORDER BY day;
-- name: UpdateTrackIdForListens :exec
UPDATE listens SET track_id = $2
WHERE track_id = $1;
-- 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 *;
-- 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;
-- 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
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
SELECT
r.*,
COUNT(*) AS listen_count,
(
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 = r.id
) AS artists
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id
JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $5
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
ORDER BY listen_count DESC
x.*,
get_artists_for_release(x.id) AS artists,
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
FROM (
SELECT
r.*,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id
JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $5
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
) x
ORDER BY listen_count DESC, x.id
LIMIT $3 OFFSET $4;
-- name: GetTopReleasesPaginated :many
SELECT
r.*,
COUNT(*) AS listen_count,
(
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 = r.id
) AS artists
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id
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
ORDER BY listen_count DESC
x.*,
get_artists_for_release(x.id) AS artists,
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
FROM (
SELECT
r.*,
COUNT(*) AS listen_count
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id
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
) x
ORDER BY listen_count DESC, x.id
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
SELECT COUNT(DISTINCT r.id) AS total_count
FROM listens l
@ -76,26 +111,31 @@ WHERE l.listened_at BETWEEN $1 AND $2;
-- name: CountReleasesFromArtist :one
SELECT COUNT(*)
FROM releases r
FROM releases r
JOIN artist_releases ar ON r.id = ar.release_id
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
INSERT INTO artist_releases (artist_id, release_id)
VALUES ($1, $2)
INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
-- name: GetReleasesWithoutImages :many
SELECT
r.*,
(
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
WHERE r.image IS NULL
get_artists_for_release(r.id) AS artists
FROM releases_with_title r
WHERE r.image IS NULL
AND r.id > $2
ORDER BY r.id ASC
LIMIT $1;
@ -104,6 +144,14 @@ LIMIT $1;
UPDATE releases SET musicbrainz_id = $2
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
UPDATE releases SET image = $2, image_source = $3
WHERE id = $1;
@ -111,8 +159,8 @@ WHERE id = $1;
-- name: DeleteRelease :exec
DELETE FROM releases WHERE id = $1;
-- name: DeleteReleasesFromArtist :exec
-- name: DeleteReleasesFromArtist :exec
DELETE FROM releases r
USING artist_releases ar
WHERE ar.release_id = r.id
AND ar.artist_id = $1;
AND ar.artist_id = $1;

View file

@ -42,12 +42,7 @@ SELECT
ranked.release_id,
ranked.image,
ranked.score,
(
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
get_artists_for_track(ranked.id) AS artists
FROM (
SELECT
t.id,
@ -74,12 +69,7 @@ SELECT
ranked.release_id,
ranked.image,
ranked.score,
(
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
get_artists_for_track(ranked.id) AS artists
FROM (
SELECT
t.id,
@ -106,12 +96,7 @@ SELECT
ranked.image,
ranked.various_artists,
ranked.score,
(
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
get_artists_for_release(ranked.id) AS artists
FROM (
SELECT
r.id,
@ -137,12 +122,7 @@ SELECT
ranked.image,
ranked.various_artists,
ranked.score,
(
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
get_artists_for_release(ranked.id) AS artists
FROM (
SELECT
r.id,

View file

@ -4,13 +4,14 @@ VALUES ($1, $2, $3)
RETURNING *;
-- name: AssociateArtistToTrack :exec
INSERT INTO artist_tracks (artist_id, track_id)
VALUES ($1, $2)
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING;
-- name: GetTrack :one
SELECT
SELECT
t.*,
get_artists_for_track(t.id) AS artists,
r.image
FROM tracks_with_title t
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
WHERE at.artist_id = $1;
-- name: GetTrackByTitleAndArtists :one
-- name: GetTrackByTrackInfo :one
SELECT t.*
FROM tracks_with_title t
JOIN artist_tracks at ON at.track_id = t.id
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
HAVING COUNT(DISTINCT at.artist_id) = cardinality($2::int[]);
HAVING COUNT(DISTINCT at.artist_id) = cardinality($3::int[]);
-- name: GetTopTracksPaginated :many
SELECT
t.id,
x.track_id AS id,
t.title,
t.musicbrainz_id,
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
JOIN tracks_with_title t ON l.track_id = t.id
x.listen_count,
get_artists_for_track(x.track_id) AS artists,
x.rank
FROM (
SELECT
track_id,
COUNT(*) AS listen_count,
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
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
WHERE l.listened_at BETWEEN $1 AND $2
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
ORDER BY listen_count DESC
LIMIT $3 OFFSET $4;
ORDER BY x.listen_count DESC, x.track_id;
-- name: GetTopTracksByArtistPaginated :many
SELECT
t.id,
x.track_id AS id,
t.title,
t.musicbrainz_id,
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at2
JOIN artists_with_name a ON a.id = at2.artist_id
WHERE at2.track_id = t.id
) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
x.listen_count,
get_artists_for_track(x.track_id) AS artists,
x.rank
FROM (
SELECT
l.track_id,
COUNT(*) AS listen_count,
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
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 artist_tracks at ON at.track_id = t.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;
ORDER BY x.listen_count DESC, x.track_id;
-- name: GetTopTracksInReleasePaginated :many
SELECT
t.id,
x.track_id AS id,
t.title,
t.musicbrainz_id,
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at2
JOIN artists_with_name a ON a.id = at2.artist_id
WHERE at2.track_id = t.id
) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
x.listen_count,
get_artists_for_track(x.track_id) AS artists,
x.rank
FROM (
SELECT
l.track_id,
COUNT(*) AS listen_count,
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
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
WHERE l.listened_at BETWEEN $1 AND $2
AND t.release_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;
ORDER BY x.listen_count DESC, x.track_id;
-- name: GetTrackAllTimeRank :one
SELECT
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
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
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
UPDATE tracks SET musicbrainz_id = $2
WHERE id = $1;
@ -135,5 +174,19 @@ WHERE id = $1;
UPDATE tracks SET release_id = $2
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
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,57 +1,69 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import tailwindcss from '@tailwindcss/vite';
import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config
export default defineConfig({
integrations: [
starlight({
head: [
{
tag: 'script',
attrs: {
src: 'https://static.cloudflareinsights.com/beacon.min.js',
'data-cf-beacon': '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
defer: true,
}
}
],
title: 'Koito',
logo: {
src: './src/assets/logo_text.png',
replacesTitle: true,
starlight({
head: [
{
tag: "script",
attrs: {
src: "https://static.cloudflareinsights.com/beacon.min.js",
"data-cf-beacon": '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
defer: true,
},
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gabehf/koito' }],
sidebar: [
{
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' },
],
},
{
label: 'Reference',
items: [
{ label: 'Configuration Options', slug: 'reference/configuration' },
]
},
},
],
title: "Koito",
logo: {
src: "./src/assets/logo_text.png",
replacesTitle: true,
},
social: [
{
icon: "github",
label: "GitHub",
href: "https://github.com/gabehf/koito",
},
],
sidebar: [
{
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",
vite: {
plugins: [tailwindcss()],
},
});
});

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)
You can also search for items when merging by their ID using the format `id:1234`.
#### 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

View file

@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from:
:::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
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,
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
means that artist aliases will not be automatically fetched for imported artists. You can also use
[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.
:::

View file

@ -3,7 +3,7 @@ title: Setting up the Scrobber
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 `\`.
@ -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.
:::
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
@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::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`.
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.
</Card>
<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 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.
</Card>
<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>
</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.
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
- Required: `true`
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
@ -14,9 +20,15 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_DEFAULT_USERNAME
- Default: `admin`
- Description: The username for the user that is created on first startup. Only applies when running Koito for the first time.
##### KOITO_DEFAULT_PASSWORD
##### KOITO_DEFAULT_PASSWORD
- Default: `changeme`
- 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
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT
@ -31,6 +43,9 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_LOG_LEVEL
- Default: `info`
- 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
- Default: `https://musicbrainz.org`
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
@ -42,21 +57,36 @@ Koito is configured using **environment variables**. This is the full list of co
- Description: Set to `true` if you want to relay requests from the ListenBrainz endpoints on your Koito server to another ListenBrainz compatible server.
##### KOITO_LBZ_RELAY_URL
- Required: `true` if relays are enabled.
- Description: The URL to which relayed requests will be sent to.
- Description: The URL to which relayed requests will be sent to.
##### KOITO_LBZ_RELAY_TOKEN
- Required: `true` if relays are enabled.
- Required: `true` if relays are enabled.
- Description: The user token to send with the relayed ListenBrainz requests.
##### KOITO_CONFIG_DIR
- Default: `/etc/koito`
- Description: The location where import folders and image caches are stored.
##### KOITO_DISABLE_DEEZER
##### 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
- Default: `false`
- Description: Disables Deezer as a source for finding artist and album images.
##### KOITO_DISABLE_COVER_ART_ARCHIVE
##### KOITO_DISABLE_COVER_ART_ARCHIVE
- Default: `false`
- Description: Disables Cover Art Archive as a source for finding album images.
##### KOITO_DISABLE_MUSICBRAINZ
- 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
- Default: `false`
- 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.
##### KOITO_IMPORT_AFTER_UNIX
- 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
- 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 (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@ -95,6 +96,10 @@ func Run(
defer store.Close(ctx)
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")
var mbzC mbz.MusicBrainzCaller
if !cfg.MusicBrainzDisabled() {
@ -105,11 +110,39 @@ func Run(
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")
images.Initialize(images.ImageSourceOpts{
UserAgent: cfg.UserAgent(),
EnableCAA: !cfg.CoverArtArchiveDisabled(),
EnableDeezer: !cfg.DeezerDisabled(),
UserAgent: cfg.UserAgent(),
EnableCAA: !cfg.CoverArtArchiveDisabled(),
EnableDeezer: !cfg.DeezerDisabled(),
EnableSubsonic: cfg.SubsonicEnabled(),
EnableLastFM: cfg.LastFMApiKey() != "",
})
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")
if !cfg.SkipImport() {
go func() {
@ -192,6 +227,12 @@ func Run(
l.Info().Msg("Engine: Pruning orphaned images")
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")
quit := make(chan os.Signal, 1)
@ -212,19 +253,19 @@ func Run(
}
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"))
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 {
l.Info().Msg("Files found in import directory. Attempting to import...")
l.Info().Msg("Importer: Files found in import directory. Attempting to import...")
} else {
return
}
defer func() {
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 {
@ -232,31 +273,37 @@ func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
continue
}
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())
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") {
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())
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") {
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())
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") {
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())
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 {
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