mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-09 15:38:17 -07:00
Compare commits
87 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec7b458cc | ||
|
|
531c72899c | ||
|
|
b06685c1af | ||
|
|
64236c99c9 | ||
|
|
42b32c7920 | ||
|
|
bf1c03e9fd | ||
|
|
35e104c97e | ||
|
|
c8a11ef018 | ||
|
|
937f9062b5 | ||
|
|
1ed055d098 | ||
|
|
08fc9eed86 | ||
|
|
cb4d177875 | ||
|
|
16cee8cfca | ||
|
|
c59c6c3baa | ||
|
|
e7ba34710c | ||
|
|
56ac73d12b | ||
|
|
1a8099e902 | ||
|
|
5e294b839c | ||
| d08e05220f | |||
| c0de721a7c | |||
|
|
d2d6924e05 | ||
|
|
aa7fddd518 | ||
|
|
1eb1cd0fd5 | ||
|
|
92648167f0 | ||
|
|
9dbdfe5e41 | ||
|
|
94108953ec | ||
|
|
d87ed2eb97 | ||
|
|
3305ad269e | ||
|
|
20bbf62254 | ||
|
|
a94584da23 | ||
|
|
8223a29be6 | ||
| 231e751be3 | |||
| feef66da12 | |||
|
|
25d7bb41c1 | ||
|
|
df59605418 | ||
|
|
288d04d714 | ||
|
|
c2a0987946 | ||
| 6e7b4e0522 | |||
|
|
62267652ba | ||
|
|
ddb0becc0f | ||
|
|
231eb1b0fb | ||
|
|
e45099c71a | ||
|
|
97cd378535 | ||
|
|
7cf7cd3a10 | ||
|
|
d61e814306 | ||
|
|
f51771bc34 | ||
| d3faa9728e | |||
|
|
f48dd6c039 | ||
| 2925425750 | |||
|
|
c346c7cb31 | ||
|
|
d327729bff | ||
| ad3c51a70e | |||
|
|
d4ac96f780 | ||
| c0a8c64243 | |||
| 456b84c4ca | |||
| e69ef0cb01 | |||
| 682e543aa5 | |||
| 20bc343fd8 | |||
| 1bceeeb2f6 | |||
| fda416fe75 | |||
| 383be25bfc | |||
| 63d953b192 | |||
| fdaea6284e | |||
| fed2c5b956 | |||
|
|
daa1bb2456 | ||
|
|
c77481fd59 | ||
|
|
620e3b65cb | ||
| 6a53fca8f3 | |||
|
|
36f984a1a2 | ||
|
|
bf0ec68cfe | ||
| b980026f1f | |||
|
|
5419178012 | ||
| 5537b6fb89 | |||
| b32c5d3735 | |||
|
|
c16b557c21 | ||
| 486f5d0269 | |||
| 31d57fd79a | |||
| 80b6f4deaa | |||
| 00e7782be2 | |||
| 5a8b999f73 | |||
| ef064cd9bd | |||
| b1bac4feb5 | |||
| 2981ec4e8a | |||
| 57cc60534d | |||
| dc5dcbd474 | |||
| bf9b84a171 | |||
| 242a82ad8c |
212 changed files with 15620 additions and 5495 deletions
5
.env.example
Normal file
5
.env.example
Normal 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
3
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
ko_fi: gabehf
|
||||||
30
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal 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.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
||||||
15
.github/workflows/astro.yml
vendored
15
.github/workflows/astro.yml
vendored
|
|
@ -2,10 +2,13 @@ name: Deploy to GitHub Pages
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
tags:
|
||||||
|
- "v*"
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- "docs/**"
|
||||||
- '.github/workflows/**'
|
- ".github/workflows/**"
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -21,9 +24,9 @@ jobs:
|
||||||
- name: Install, build, and upload your site output
|
- name: Install, build, and upload your site output
|
||||||
uses: withastro/action@v4
|
uses: withastro/action@v4
|
||||||
with:
|
with:
|
||||||
path: ./docs # The root location of your Astro project inside the repository. (optional)
|
path: ./docs # The root location of your Astro project inside the repository. (optional)
|
||||||
node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional)
|
node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional)
|
||||||
package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
|
package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
|
|
|
||||||
98
.github/workflows/docker.yml
vendored
98
.github/workflows/docker.yml
vendored
|
|
@ -12,66 +12,64 @@ name: Publish Docker image
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "README.md"
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Go Test
|
name: Go Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Install libvips
|
- name: Install libvips
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libvips-dev
|
sudo apt-get install -y libvips-dev
|
||||||
|
|
||||||
- name: Verify libvips install
|
- name: Verify libvips install
|
||||||
run: vips --version
|
run: vips --version
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: robherley/go-test-action@v0
|
uses: robherley/go-test-action@v0
|
||||||
|
|
||||||
push_to_registry:
|
push_to_registry:
|
||||||
name: Push Docker image to Docker Hub
|
name: Push Docker image to Docker Hub (release)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Set up Docker Buildx
|
||||||
id: meta
|
uses: docker/setup-buildx-action@v3
|
||||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
|
||||||
with:
|
|
||||||
images: gabehf/koito
|
|
||||||
|
|
||||||
- name: Extract tag version
|
- name: Extract tag version
|
||||||
id: extract_version
|
|
||||||
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push release image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|
@ -81,10 +79,34 @@ jobs:
|
||||||
gabehf/koito:${{ env.KOITO_VERSION }}
|
gabehf/koito:${{ env.KOITO_VERSION }}
|
||||||
build-args: |
|
build-args: |
|
||||||
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
push_dev:
|
||||||
uses: actions/attest-build-provenance@v2
|
name: Push Docker image (dev branch)
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
subject-name: index.docker.io/gabehf/koito
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
push-to-registry: true
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push dev image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gabehf/koito:dev
|
||||||
|
gabehf/koito:dev-${{ github.sha }}
|
||||||
|
build-args: |
|
||||||
|
KOITO_VERSION=dev
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
|
||||||
32
.github/workflows/test.yml
vendored
Normal file
32
.github/workflows/test.yml
vendored
Normal 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
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
test_config_dir
|
||||||
|
.env
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# v0.0.4
|
|
||||||
## Enhancements
|
|
||||||
- Re-download images missing from cache on request
|
|
||||||
|
|
@ -11,7 +11,7 @@ COPY ./client .
|
||||||
|
|
||||||
RUN yarn run build
|
RUN yarn run build
|
||||||
|
|
||||||
FROM golang:1.23 AS backend
|
FROM golang:1.24 AS backend
|
||||||
|
|
||||||
ARG KOITO_VERSION
|
ARG KOITO_VERSION
|
||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=1
|
||||||
|
|
|
||||||
23
Makefile
23
Makefile
|
|
@ -1,3 +1,8 @@
|
||||||
|
ifneq (,$(wildcard ./.env))
|
||||||
|
include .env
|
||||||
|
export
|
||||||
|
endif
|
||||||
|
|
||||||
.PHONY: all test clean client
|
.PHONY: all test clean client
|
||||||
|
|
||||||
postgres.schemadump:
|
postgres.schemadump:
|
||||||
|
|
@ -10,7 +15,10 @@ postgres.schemadump:
|
||||||
-v --dbname="koitodb" -f "/tmp/dump/schema.sql"
|
-v --dbname="koitodb" -f "/tmp/dump/schema.sql"
|
||||||
|
|
||||||
postgres.run:
|
postgres.run:
|
||||||
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
docker run --name koito-db -p 5432:5432 -v koito_dev_db:/var/lib/postgresql -e POSTGRES_PASSWORD=secret -d postgres
|
||||||
|
|
||||||
|
postgres.run-scratch:
|
||||||
|
docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
||||||
|
|
||||||
postgres.start:
|
postgres.start:
|
||||||
docker start koito-db
|
docker start koito-db
|
||||||
|
|
@ -18,8 +26,17 @@ postgres.start:
|
||||||
postgres.stop:
|
postgres.stop:
|
||||||
docker stop koito-db
|
docker stop koito-db
|
||||||
|
|
||||||
api.debug:
|
postgres.remove:
|
||||||
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go
|
docker stop koito-db && docker rm koito-db
|
||||||
|
|
||||||
|
postgres.remove-scratch:
|
||||||
|
docker stop koito-scratch && docker rm koito-scratch
|
||||||
|
|
||||||
|
api.debug: postgres.start
|
||||||
|
go run cmd/api/main.go
|
||||||
|
|
||||||
|
api.scratch: postgres.run-scratch
|
||||||
|
KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go
|
||||||
|
|
||||||
api.test:
|
api.test:
|
||||||
go test ./... -timeout 60s
|
go test ./... -timeout 60s
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -1,9 +1,21 @@
|
||||||
# Koito
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Koito (小糸) is a Japanese surname. It is also homophonous with the words 恋と (koi to), meaning "and/with love".*
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://ko-fi.com/gabehf)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
Koito is a modern, themeable ListenBrainz-compatible scrobbler for self-hosters who want control over their data and insights into their listening habits.
|
Koito is a modern, themeable ListenBrainz-compatible scrobbler for self-hosters who want control over their data and insights into their listening habits.
|
||||||
It supports relaying to other compatible scrobblers, so you can try it safely without replacing your current setup.
|
It supports relaying to other compatible scrobblers, so you can try it safely without replacing your current setup.
|
||||||
|
|
||||||
> This project is currently pre-release, and therefore you can expect rapid development and some bugs. If you don't want to replace your current scrobbler
|
> This project is under active development and still considered "unstable", and therefore you can expect some bugs. If you don't want to replace your current scrobbler
|
||||||
with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible
|
with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible
|
||||||
scrobbler. This is what I've been doing for the entire development of this app and it hasn't failed me once. Or, you can always use something
|
scrobbler. This is what I've been doing for the entire development of this app and it hasn't failed me once. Or, you can always use something
|
||||||
like [multi-scrobbler](https://github.com/FoxxMD/multi-scrobbler).
|
like [multi-scrobbler](https://github.com/FoxxMD/multi-scrobbler).
|
||||||
|
|
@ -23,8 +35,9 @@ You can view my public instance with my listening data at https://koito.mnrva.de
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|
<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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -75,6 +88,16 @@ There are currently some known issues that I am actively working on, in addition
|
||||||
|
|
||||||
If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next.
|
If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/#gabehf/koito&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=gabehf/koito&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=gabehf/koito&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=gabehf/koito&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
## Albums that fueled development + notes
|
## Albums that fueled development + notes
|
||||||
|
|
||||||
More relevant here than any of my other projects...
|
More relevant here than any of my other projects...
|
||||||
|
|
@ -84,5 +107,4 @@ Not just during development, you can see my complete listening data on my [live
|
||||||
#### Random notes
|
#### Random notes
|
||||||
|
|
||||||
- I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to...
|
- I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to...
|
||||||
- It's funny how you can see the days in my listening history when I was just working on this project because they have way more listens than other days.
|
|
||||||
- About 50% of the reason I built this was minor/not-so-minor greivances with Maloja. Could I have just contributed to Maloja? Maybe, but I like building stuff and I like Koito's UI a lot more anyways.
|
- About 50% of the reason I built this was minor/not-so-minor greivances with Maloja. Could I have just contributed to Maloja? Maybe, but I like building stuff and I like Koito's UI a lot more anyways.
|
||||||
BIN
assets/Jost-Regular.ttf
Normal file
BIN
assets/Jost-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/LeagueSpartan-Medium.ttf
Normal file
BIN
assets/LeagueSpartan-Medium.ttf
Normal file
Binary file not shown.
|
|
@ -1,287 +1,501 @@
|
||||||
interface getItemsArgs {
|
interface getItemsArgs {
|
||||||
limit: number,
|
limit: number;
|
||||||
period: string,
|
period: string;
|
||||||
page: number,
|
page: number;
|
||||||
artist_id?: number,
|
artist_id?: number;
|
||||||
album_id?: number,
|
album_id?: number;
|
||||||
track_id?: number
|
track_id?: number;
|
||||||
}
|
}
|
||||||
interface getActivityArgs {
|
interface getActivityArgs {
|
||||||
step: string
|
step: string;
|
||||||
range: number
|
range: number;
|
||||||
month: number
|
month: number;
|
||||||
year: number
|
year: number;
|
||||||
artist_id: number
|
artist_id: number;
|
||||||
album_id: number
|
album_id: number;
|
||||||
track_id: number
|
track_id: number;
|
||||||
|
}
|
||||||
|
interface timeframe {
|
||||||
|
week?: number;
|
||||||
|
month?: number;
|
||||||
|
year?: number;
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
period?: string;
|
||||||
|
}
|
||||||
|
interface getInterestArgs {
|
||||||
|
buckets: number;
|
||||||
|
artist_id: number;
|
||||||
|
album_id: number;
|
||||||
|
track_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
|
async function handleJson<T>(r: Response): Promise<T> {
|
||||||
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>)
|
if (!r.ok) {
|
||||||
|
const err = await r.json();
|
||||||
|
throw Error(err.error);
|
||||||
|
}
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
async function getLastListens(
|
||||||
|
args: getItemsArgs
|
||||||
|
): Promise<PaginatedResponse<Listen>> {
|
||||||
|
const r = await fetch(
|
||||||
|
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
|
||||||
|
);
|
||||||
|
return handleJson<PaginatedResponse<Listen>>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
async function getTopTracks(
|
||||||
if (args.artist_id) {
|
args: getItemsArgs
|
||||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
): Promise<PaginatedResponse<Ranked<Track>>> {
|
||||||
} else if (args.album_id) {
|
let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
|
||||||
} else {
|
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
|
||||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
else if (args.album_id) url += `&album_id=${args.album_id}`;
|
||||||
}
|
|
||||||
|
const r = await fetch(url);
|
||||||
|
return handleJson<PaginatedResponse<Ranked<Track>>>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
async function getTopAlbums(
|
||||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
args: getItemsArgs
|
||||||
if (args.artist_id) {
|
): Promise<PaginatedResponse<Ranked<Album>>> {
|
||||||
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||||
} else {
|
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
|
||||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
|
||||||
}
|
const r = await fetch(url);
|
||||||
|
return handleJson<PaginatedResponse<Ranked<Album>>>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
async function getTopArtists(
|
||||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
args: getItemsArgs
|
||||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
|
): Promise<PaginatedResponse<Ranked<Artist>>> {
|
||||||
|
const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
return handleJson<PaginatedResponse<Ranked<Artist>>>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
async function getActivity(
|
||||||
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>)
|
args: getActivityArgs
|
||||||
|
): Promise<ListenActivityItem[]> {
|
||||||
|
const r = await fetch(
|
||||||
|
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
|
||||||
|
);
|
||||||
|
return handleJson<ListenActivityItem[]>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStats(period: string): Promise<Stats> {
|
async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
|
||||||
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
|
const r = await fetch(
|
||||||
|
`/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
|
||||||
|
);
|
||||||
|
return handleJson<InterestBucket[]>(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStats(period: string): Promise<Stats> {
|
||||||
|
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
|
||||||
|
|
||||||
|
return handleJson<Stats>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(q: string): Promise<SearchResponse> {
|
function search(q: string): Promise<SearchResponse> {
|
||||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
q = encodeURIComponent(q);
|
||||||
|
return fetch(`/apis/web/v1/search?q=${q}`).then(
|
||||||
|
(r) => r.json() as Promise<SearchResponse>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrl(id: string, size: string) {
|
function imageUrl(id: string, size: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = 'default'
|
id = "default";
|
||||||
}
|
}
|
||||||
return `/images/${size}/${id}`
|
return `/images/${size}/${id}`;
|
||||||
}
|
}
|
||||||
function replaceImage(form: FormData): Promise<Response> {
|
function replaceImage(form: FormData): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/replace-image`, {
|
return fetch(`/apis/web/v1/replace-image`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeTracks(from: number, to: number): Promise<Response> {
|
function mergeTracks(from: number, to: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function mergeAlbums(from: number, to: number): Promise<Response> {
|
function mergeAlbums(
|
||||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, {
|
from: number,
|
||||||
method: "POST",
|
to: number,
|
||||||
})
|
replaceImage: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(
|
||||||
|
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function mergeArtists(from: number, to: number): Promise<Response> {
|
function mergeArtists(
|
||||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, {
|
from: number,
|
||||||
method: "POST",
|
to: number,
|
||||||
})
|
replaceImage: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(
|
||||||
|
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function login(username: string, password: string, remember: boolean): Promise<Response> {
|
function login(
|
||||||
return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, {
|
username: string,
|
||||||
method: "POST",
|
password: string,
|
||||||
})
|
remember: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append("username", username);
|
||||||
|
form.append("password", password);
|
||||||
|
form.append("remember_me", String(remember));
|
||||||
|
return fetch(`/apis/web/v1/login`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function logout(): Promise<Response> {
|
function logout(): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/logout`, {
|
return fetch(`/apis/web/v1/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCfg(): Promise<Config> {
|
||||||
|
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append("track_id", id);
|
||||||
|
const ms = new Date(ts).getTime();
|
||||||
|
const unix = Math.floor(ms / 1000);
|
||||||
|
form.append("unix", unix.toString());
|
||||||
|
return fetch(`/apis/web/v1/listen`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getApiKeys(): Promise<ApiKey[]> {
|
function getApiKeys(): Promise<ApiKey[]> {
|
||||||
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
|
return fetch(`/apis/web/v1/user/apikeys`).then(
|
||||||
|
(r) => r.json() as Promise<ApiKey[]>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const createApiKey = async (label: string): Promise<ApiKey> => {
|
const createApiKey = async (label: string): Promise<ApiKey> => {
|
||||||
const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, {
|
const form = new URLSearchParams();
|
||||||
method: "POST"
|
form.append("label", label);
|
||||||
});
|
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
||||||
if (!r.ok) {
|
method: "POST",
|
||||||
let errorMessage = `error: ${r.status}`;
|
body: form,
|
||||||
try {
|
});
|
||||||
const errorData: ApiError = await r.json();
|
if (!r.ok) {
|
||||||
if (errorData && typeof errorData.error === 'string') {
|
let errorMessage = `error: ${r.status}`;
|
||||||
errorMessage = errorData.error;
|
try {
|
||||||
}
|
const errorData: ApiError = await r.json();
|
||||||
} catch (e) {
|
if (errorData && typeof errorData.error === "string") {
|
||||||
console.error("unexpected api error:", e);
|
errorMessage = errorData.error;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
} catch (e) {
|
||||||
|
console.error("unexpected api error:", e);
|
||||||
}
|
}
|
||||||
const data: ApiKey = await r.json();
|
throw new Error(errorMessage);
|
||||||
return data;
|
}
|
||||||
|
const data: ApiKey = await r.json();
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
function deleteApiKey(id: number): Promise<Response> {
|
function deleteApiKey(id: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, {
|
const form = new URLSearchParams();
|
||||||
method: "PATCH"
|
form.append("id", String(id));
|
||||||
})
|
form.append("label", label);
|
||||||
|
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteItem(itemType: string, id: number): Promise<Response> {
|
function deleteItem(itemType: string, id: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function updateUser(username: string, password: string) {
|
function updateUser(username: string, password: string) {
|
||||||
return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, {
|
const form = new URLSearchParams();
|
||||||
method: "PATCH"
|
form.append("username", username);
|
||||||
})
|
form.append("password", password);
|
||||||
|
return fetch(`/apis/web/v1/user`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function getAliases(type: string, id: number): Promise<Alias[]> {
|
function getAliases(type: string, id: number): Promise<Alias[]> {
|
||||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
|
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
|
||||||
|
(r) => r.json() as Promise<Alias[]>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function createAlias(type: string, id: number, alias: string): Promise<Response> {
|
function createAlias(
|
||||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
type: string,
|
||||||
method: 'POST'
|
id: number,
|
||||||
})
|
alias: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append(`${type}_id`, String(id));
|
||||||
|
form.append("alias", alias);
|
||||||
|
return fetch(`/apis/web/v1/aliases`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
|
function deleteAlias(
|
||||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
type: string,
|
||||||
method: "DELETE"
|
id: number,
|
||||||
})
|
alias: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append(`${type}_id`, String(id));
|
||||||
|
form.append("alias", alias);
|
||||||
|
return fetch(`/apis/web/v1/aliases/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
|
function setPrimaryAlias(
|
||||||
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, {
|
type: string,
|
||||||
method: "POST"
|
id: number,
|
||||||
})
|
alias: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append(`${type}_id`, String(id));
|
||||||
|
form.append("alias", alias);
|
||||||
|
return fetch(`/apis/web/v1/aliases/primary`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateMbzId(
|
||||||
|
type: string,
|
||||||
|
id: number,
|
||||||
|
mbzid: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append(`${type}_id`, String(id));
|
||||||
|
form.append("mbz_id", mbzid);
|
||||||
|
return fetch(`/apis/web/v1/mbzid`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getAlbum(id: number): Promise<Album> {
|
||||||
|
return fetch(`/apis/web/v1/album?id=${id}`).then(
|
||||||
|
(r) => r.json() as Promise<Album>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteListen(listen: Listen): Promise<Response> {
|
function deleteListen(listen: Listen): Promise<Response> {
|
||||||
const ms = new Date(listen.time).getTime()
|
const ms = new Date(listen.time).getTime();
|
||||||
const unix= Math.floor(ms / 1000);
|
const unix = Math.floor(ms / 1000);
|
||||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
function getExport() {}
|
||||||
|
|
||||||
|
function getNowPlaying(): Promise<NowPlaying> {
|
||||||
|
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRewindStats(args: timeframe): Promise<RewindStats> {
|
||||||
|
const r = await fetch(
|
||||||
|
`/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}`
|
||||||
|
);
|
||||||
|
return handleJson<RewindStats>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getLastListens,
|
getLastListens,
|
||||||
getTopTracks,
|
getTopTracks,
|
||||||
getTopAlbums,
|
getTopAlbums,
|
||||||
getTopArtists,
|
getTopArtists,
|
||||||
getActivity,
|
getActivity,
|
||||||
getStats,
|
getInterest,
|
||||||
search,
|
getStats,
|
||||||
replaceImage,
|
search,
|
||||||
mergeTracks,
|
replaceImage,
|
||||||
mergeAlbums,
|
mergeTracks,
|
||||||
mergeArtists,
|
mergeAlbums,
|
||||||
imageUrl,
|
mergeArtists,
|
||||||
login,
|
imageUrl,
|
||||||
logout,
|
login,
|
||||||
deleteItem,
|
logout,
|
||||||
updateUser,
|
getCfg,
|
||||||
getAliases,
|
deleteItem,
|
||||||
createAlias,
|
updateUser,
|
||||||
deleteAlias,
|
getAliases,
|
||||||
setPrimaryAlias,
|
createAlias,
|
||||||
getApiKeys,
|
deleteAlias,
|
||||||
createApiKey,
|
setPrimaryAlias,
|
||||||
deleteApiKey,
|
updateMbzId,
|
||||||
updateApiKeyLabel,
|
getApiKeys,
|
||||||
deleteListen,
|
createApiKey,
|
||||||
}
|
deleteApiKey,
|
||||||
|
updateApiKeyLabel,
|
||||||
|
deleteListen,
|
||||||
|
getAlbum,
|
||||||
|
getExport,
|
||||||
|
submitListen,
|
||||||
|
getNowPlaying,
|
||||||
|
getRewindStats,
|
||||||
|
};
|
||||||
type Track = {
|
type Track = {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[];
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
image: string
|
image: string;
|
||||||
album_id: number
|
album_id: number;
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
}
|
time_listened: number;
|
||||||
|
first_listen: number;
|
||||||
|
all_time_rank: number;
|
||||||
|
};
|
||||||
type Artist = {
|
type Artist = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
image: string,
|
image: string;
|
||||||
aliases: string[]
|
aliases: string[];
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
}
|
time_listened: number;
|
||||||
|
first_listen: number;
|
||||||
|
is_primary: boolean;
|
||||||
|
all_time_rank: number;
|
||||||
|
};
|
||||||
type Album = {
|
type Album = {
|
||||||
id: number,
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
image: string
|
image: string;
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
is_various_artists: boolean
|
is_various_artists: boolean;
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[];
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
}
|
time_listened: number;
|
||||||
|
first_listen: number;
|
||||||
|
all_time_rank: number;
|
||||||
|
};
|
||||||
type Alias = {
|
type Alias = {
|
||||||
id: number
|
id: number;
|
||||||
alias: string
|
alias: string;
|
||||||
source: string
|
source: string;
|
||||||
is_primary: boolean
|
is_primary: boolean;
|
||||||
}
|
};
|
||||||
type Listen = {
|
type Listen = {
|
||||||
time: string,
|
time: string;
|
||||||
track: Track,
|
track: Track;
|
||||||
}
|
};
|
||||||
type PaginatedResponse<T> = {
|
type PaginatedResponse<T> = {
|
||||||
items: T[],
|
items: T[];
|
||||||
total_record_count: number,
|
total_record_count: number;
|
||||||
has_next_page: boolean,
|
has_next_page: boolean;
|
||||||
current_page: number,
|
current_page: number;
|
||||||
items_per_page: number,
|
items_per_page: number;
|
||||||
}
|
};
|
||||||
|
type Ranked<T> = {
|
||||||
|
item: T;
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
type ListenActivityItem = {
|
type ListenActivityItem = {
|
||||||
start_time: Date,
|
start_time: Date;
|
||||||
listens: number
|
listens: number;
|
||||||
}
|
};
|
||||||
|
type InterestBucket = {
|
||||||
|
bucket_start: Date;
|
||||||
|
bucket_end: Date;
|
||||||
|
listen_count: number;
|
||||||
|
};
|
||||||
type SimpleArtists = {
|
type SimpleArtists = {
|
||||||
name: string
|
name: string;
|
||||||
id: number
|
id: number;
|
||||||
}
|
};
|
||||||
type Stats = {
|
type Stats = {
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
track_count: number
|
track_count: number;
|
||||||
album_count: number
|
album_count: number;
|
||||||
artist_count: number
|
artist_count: number;
|
||||||
hours_listened: number
|
minutes_listened: number;
|
||||||
}
|
};
|
||||||
type SearchResponse = {
|
type SearchResponse = {
|
||||||
albums: Album[]
|
albums: Album[];
|
||||||
artists: Artist[]
|
artists: Artist[];
|
||||||
tracks: Track[]
|
tracks: Track[];
|
||||||
}
|
};
|
||||||
type User = {
|
type User = {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
role: 'user' | 'admin'
|
role: "user" | "admin";
|
||||||
}
|
};
|
||||||
type ApiKey = {
|
type ApiKey = {
|
||||||
id: number
|
id: number;
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
created_at: Date
|
created_at: Date;
|
||||||
}
|
};
|
||||||
type ApiError = {
|
type ApiError = {
|
||||||
error: string
|
error: string;
|
||||||
}
|
};
|
||||||
|
type Config = {
|
||||||
|
default_theme: string;
|
||||||
|
};
|
||||||
|
type NowPlaying = {
|
||||||
|
currently_playing: boolean;
|
||||||
|
track: Track;
|
||||||
|
};
|
||||||
|
type RewindStats = {
|
||||||
|
title: string;
|
||||||
|
top_artists: Ranked<Artist>[];
|
||||||
|
top_albums: Ranked<Album>[];
|
||||||
|
top_tracks: Ranked<Track>[];
|
||||||
|
minutes_listened: number;
|
||||||
|
avg_minutes_listened_per_day: number;
|
||||||
|
plays: number;
|
||||||
|
avg_plays_per_day: number;
|
||||||
|
unique_tracks: number;
|
||||||
|
unique_albums: number;
|
||||||
|
unique_artists: number;
|
||||||
|
new_tracks: number;
|
||||||
|
new_albums: number;
|
||||||
|
new_artists: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
getItemsArgs,
|
getItemsArgs,
|
||||||
getActivityArgs,
|
getActivityArgs,
|
||||||
Track,
|
getInterestArgs,
|
||||||
Artist,
|
Track,
|
||||||
Album,
|
Artist,
|
||||||
Listen,
|
Album,
|
||||||
SearchResponse,
|
Listen,
|
||||||
PaginatedResponse,
|
SearchResponse,
|
||||||
ListenActivityItem,
|
PaginatedResponse,
|
||||||
User,
|
Ranked,
|
||||||
Alias,
|
ListenActivityItem,
|
||||||
ApiKey,
|
InterestBucket,
|
||||||
ApiError
|
User,
|
||||||
}
|
Alias,
|
||||||
|
ApiKey,
|
||||||
|
ApiError,
|
||||||
|
Config,
|
||||||
|
NowPlaying,
|
||||||
|
Stats,
|
||||||
|
RewindStats,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,56 @@
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap');
|
@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap");
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
|
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
|
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
|
||||||
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
|
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
|
||||||
|
|
||||||
@keyframes fade-in-scale {
|
@keyframes fade-in-scale {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
100% {
|
||||||
@keyframes fade-out-scale {
|
opacity: 1;
|
||||||
0% {
|
transform: scale(1);
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
--animate-fade-in: fade-in 0.1s ease forwards;
|
@keyframes fade-out-scale {
|
||||||
--animate-fade-out: fade-out 0.1s ease forwards;
|
0% {
|
||||||
|
opacity: 1;
|
||||||
@keyframes fade-in {
|
transform: scale(1);
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
100% {
|
||||||
@keyframes fade-out {
|
opacity: 0;
|
||||||
0% {
|
transform: scale(0.95);
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
--animate-fade-in: fade-in 0.1s ease forwards;
|
||||||
|
--animate-fade-out: fade-out 0.1s ease forwards;
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--header-xl: 36px;
|
--header-xl: 36px;
|
||||||
--header-lg: 28px;
|
--header-lg: 28px;
|
||||||
|
|
@ -61,20 +58,21 @@
|
||||||
--header-sm: 16px;
|
--header-sm: 16px;
|
||||||
--header-xl-weight: 600;
|
--header-xl-weight: 600;
|
||||||
--header-weight: 600;
|
--header-weight: 600;
|
||||||
|
--header-line-height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 60rem) {
|
@media (min-width: 60rem) {
|
||||||
:root {
|
:root {
|
||||||
--header-xl: 78px;
|
--header-xl: 78px;
|
||||||
--header-lg: 28px;
|
--header-lg: 36px;
|
||||||
--header-md: 22px;
|
--header-md: 22px;
|
||||||
--header-sm: 16px;
|
--header-sm: 16px;
|
||||||
--header-xl-weight: 600;
|
--header-xl-weight: 600;
|
||||||
--header-weight: 600;
|
--header-weight: 600;
|
||||||
|
--header-line-height: 1.3em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
|
|
@ -102,21 +100,24 @@ h1 {
|
||||||
font-family: "League Spartan";
|
font-family: "League Spartan";
|
||||||
font-weight: var(--header-weight);
|
font-weight: var(--header-weight);
|
||||||
font-size: var(--header-xl);
|
font-size: var(--header-xl);
|
||||||
|
line-height: var(--header-line-height);
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
|
font-family: "League Spartan";
|
||||||
|
font-weight: var(--header-weight);
|
||||||
|
font-size: var(--header-lg);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
font-family: "League Spartan";
|
font-family: "League Spartan";
|
||||||
font-weight: var(--header-weight);
|
font-weight: var(--header-weight);
|
||||||
font-size: var(--header-md);
|
font-size: var(--header-md);
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
h3 {
|
h4 {
|
||||||
font-family: "League Spartan";
|
font-family: "League Spartan";
|
||||||
font-size: var(--header-sm);
|
font-size: var(--header-sm);
|
||||||
font-weight: var(--header-weight);
|
font-weight: var(--header-weight);
|
||||||
}
|
}
|
||||||
h4 {
|
|
||||||
font-size: var(--header-md);
|
|
||||||
}
|
|
||||||
.header-font {
|
.header-font {
|
||||||
font-family: "League Spartan";
|
font-family: "League Spartan";
|
||||||
}
|
}
|
||||||
|
|
@ -132,23 +133,21 @@ h4 {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea {
|
||||||
border: 1px solid var(--color-bg);
|
border: 1px solid var(--color-bg);
|
||||||
}
|
}
|
||||||
input[type="text"]:focus {
|
input[type="checkbox"] {
|
||||||
outline: none;
|
height: fit-content;
|
||||||
border: 1px solid var(--color-fg-tertiary);
|
|
||||||
}
|
}
|
||||||
input[type="password"] {
|
input:focus-visible,
|
||||||
border: 1px solid var(--color-bg);
|
button:focus-visible,
|
||||||
}
|
a:focus-visible,
|
||||||
input[type="password"]:focus {
|
select:focus-visible,
|
||||||
outline: none;
|
textarea:focus-visible {
|
||||||
border: 1px solid var(--color-fg-tertiary);
|
border-color: transparent;
|
||||||
}
|
outline: 2px solid var(--color-fg-tertiary);
|
||||||
input[type="checkbox"]:focus {
|
|
||||||
outline: none;
|
|
||||||
border: 1px solid var(--color-fg-tertiary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
|
|
|
||||||
|
|
@ -1,186 +1,196 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
import {
|
||||||
import Popup from "./Popup"
|
getActivity,
|
||||||
import { useEffect, useState } from "react"
|
type getActivityArgs,
|
||||||
import { useTheme } from "~/hooks/useTheme"
|
type ListenActivityItem,
|
||||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
} from "api/api";
|
||||||
|
import Popup from "./Popup";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTheme } from "~/hooks/useTheme";
|
||||||
|
import ActivityOptsSelector from "./ActivityOptsSelector";
|
||||||
|
import type { Theme } from "~/styles/themes.css";
|
||||||
|
|
||||||
function getPrimaryColor(): string {
|
function getPrimaryColor(theme: Theme): string {
|
||||||
const value = getComputedStyle(document.documentElement)
|
const value = theme.primary;
|
||||||
.getPropertyValue('--color-primary')
|
const rgbMatch = value.match(
|
||||||
.trim();
|
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||||
|
);
|
||||||
|
if (rgbMatch) {
|
||||||
|
const [, r, g, b] = rgbMatch.map(Number);
|
||||||
|
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
return value;
|
||||||
if (rgbMatch) {
|
|
||||||
const [, r, g, b] = rgbMatch.map(Number);
|
|
||||||
return (
|
|
||||||
'#' +
|
|
||||||
[r, g, b]
|
|
||||||
.map((n) => n.toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
step?: string
|
step?: string;
|
||||||
range?: number
|
range?: number;
|
||||||
month?: number
|
month?: number;
|
||||||
year?: number
|
year?: number;
|
||||||
artistId?: number
|
artistId?: number;
|
||||||
albumId?: number
|
albumId?: number;
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
configurable?: boolean
|
configurable?: boolean;
|
||||||
autoAdjust?: boolean
|
autoAdjust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityGrid({
|
export default function ActivityGrid({
|
||||||
step = 'day',
|
step = "day",
|
||||||
range = 182,
|
range = 182,
|
||||||
month = 0,
|
month = 0,
|
||||||
year = 0,
|
year = 0,
|
||||||
artistId = 0,
|
artistId = 0,
|
||||||
albumId = 0,
|
albumId = 0,
|
||||||
trackId = 0,
|
trackId = 0,
|
||||||
configurable = false,
|
configurable = false,
|
||||||
autoAdjust = false,
|
}: Props) {
|
||||||
}: Props) {
|
const [stepState, setStep] = useState(step);
|
||||||
|
const [rangeState, setRange] = useState(range);
|
||||||
|
|
||||||
const [color, setColor] = useState(getPrimaryColor())
|
const { isPending, isError, data, error } = useQuery({
|
||||||
const [stepState, setStep] = useState(step)
|
queryKey: [
|
||||||
const [rangeState, setRange] = useState(range)
|
"listen-activity",
|
||||||
|
{
|
||||||
|
step: stepState,
|
||||||
|
range: rangeState,
|
||||||
|
month: month,
|
||||||
|
year: year,
|
||||||
|
artist_id: artistId,
|
||||||
|
album_id: albumId,
|
||||||
|
track_id: trackId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||||
|
});
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const { theme } = useTheme();
|
||||||
queryKey: [
|
const color = getPrimaryColor(theme);
|
||||||
'listen-activity',
|
|
||||||
{
|
|
||||||
step: stepState,
|
|
||||||
range: rangeState,
|
|
||||||
month: month,
|
|
||||||
year: year,
|
|
||||||
artist_id: artistId,
|
|
||||||
album_id: albumId,
|
|
||||||
track_id: trackId
|
|
||||||
},
|
|
||||||
],
|
|
||||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { theme } = useTheme();
|
|
||||||
useEffect(() => {
|
|
||||||
const raf = requestAnimationFrame(() => {
|
|
||||||
const color = getPrimaryColor()
|
|
||||||
setColor(color);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => cancelAnimationFrame(raf);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-[500px]">
|
|
||||||
<h2>Activity</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) return <p className="error">Error:{error.message}</p>
|
|
||||||
|
|
||||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
|
||||||
function LightenDarkenColor(hex: string, lum: number) {
|
|
||||||
// validate hex string
|
|
||||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
|
||||||
if (hex.length < 6) {
|
|
||||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
|
||||||
}
|
|
||||||
lum = lum || 0;
|
|
||||||
|
|
||||||
// convert to decimal and change luminosity
|
|
||||||
var rgb = "#", c, i;
|
|
||||||
for (i = 0; i < 3; i++) {
|
|
||||||
c = parseInt(hex.substring(i*2,(i*2)+2), 16);
|
|
||||||
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
|
|
||||||
rgb += ("00"+c).substring(c.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rgb;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDarkenAmount = (v: number, t: number): number => {
|
|
||||||
|
|
||||||
if (autoAdjust) {
|
|
||||||
// automatically adjust the target value based on step
|
|
||||||
// the smartest way to do this would be to have the api return the
|
|
||||||
// highest value in the range. too bad im not smart
|
|
||||||
switch (stepState) {
|
|
||||||
case 'day':
|
|
||||||
t = 10
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
t = 20
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
t = 50
|
|
||||||
break;
|
|
||||||
case 'year':
|
|
||||||
t = 100
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
v = Math.min(v, t)
|
|
||||||
if (theme === "pearl") {
|
|
||||||
// special case for the only light theme lol
|
|
||||||
// could be generalized by pragmatically comparing the
|
|
||||||
// lightness of the bg vs the primary but eh
|
|
||||||
return ((t-v) / t)
|
|
||||||
} else {
|
|
||||||
return ((v-t) / t) * .8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (<div className="flex flex-col items-start">
|
|
||||||
<h2>Activity</h2>
|
|
||||||
{configurable ? (
|
|
||||||
<ActivityOptsSelector
|
|
||||||
rangeSetter={setRange}
|
|
||||||
currentRange={rangeState}
|
|
||||||
stepSetter={setStep}
|
|
||||||
currentStep={stepState}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
<div className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px]">
|
|
||||||
{data.map((item) => (
|
|
||||||
<div
|
|
||||||
key={new Date(item.start_time).toString()}
|
|
||||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
|
||||||
>
|
|
||||||
<Popup
|
|
||||||
position="top"
|
|
||||||
space={12}
|
|
||||||
extraClasses="left-2"
|
|
||||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
background:
|
|
||||||
item.listens > 0
|
|
||||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
|
||||||
: 'var(--color-bg-secondary)',
|
|
||||||
}}
|
|
||||||
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
|
|
||||||
></div>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="w-[350px]">
|
||||||
|
<h3>Activity</h3>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
} else if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="w-[350px]">
|
||||||
|
<h3>Activity</h3>
|
||||||
|
<p className="error">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||||
|
function LightenDarkenColor(hex: string, lum: number) {
|
||||||
|
// validate hex string
|
||||||
|
hex = String(hex).replace(/[^0-9a-f]/gi, "");
|
||||||
|
if (hex.length < 6) {
|
||||||
|
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||||
|
}
|
||||||
|
lum = lum || 0;
|
||||||
|
|
||||||
|
// convert to decimal and change luminosity
|
||||||
|
var rgb = "#",
|
||||||
|
c,
|
||||||
|
i;
|
||||||
|
for (i = 0; i < 3; i++) {
|
||||||
|
c = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16);
|
||||||
|
rgb += ("00" + c).substring(c.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDarkenAmount = (v: number, t: number): number => {
|
||||||
|
// really ugly way to just check if this is for all items and not a specific item.
|
||||||
|
// is it jsut better to just pass the target in as a var? probably.
|
||||||
|
const adjustment =
|
||||||
|
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1;
|
||||||
|
|
||||||
|
// automatically adjust the target value based on step
|
||||||
|
// the smartest way to do this would be to have the api return the
|
||||||
|
// highest value in the range. too bad im not smart
|
||||||
|
switch (stepState) {
|
||||||
|
case "day":
|
||||||
|
t = 10 * adjustment;
|
||||||
|
break;
|
||||||
|
case "week":
|
||||||
|
t = 20 * adjustment;
|
||||||
|
break;
|
||||||
|
case "month":
|
||||||
|
t = 50 * adjustment;
|
||||||
|
break;
|
||||||
|
case "year":
|
||||||
|
t = 100 * adjustment;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
v = Math.min(v, t);
|
||||||
|
return ((v - t) / t) * 0.8;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 26 * 7;
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||||
|
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<h3>Activity</h3>
|
||||||
|
{configurable ? (
|
||||||
|
<ActivityOptsSelector
|
||||||
|
rangeSetter={setRange}
|
||||||
|
currentRange={rangeState}
|
||||||
|
stepSetter={setStep}
|
||||||
|
currentStep={stepState}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
||||||
|
>
|
||||||
|
{chunk.map((item) => (
|
||||||
|
<div
|
||||||
|
key={new Date(item.start_time).toString()}
|
||||||
|
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||||
|
>
|
||||||
|
<Popup
|
||||||
|
position="top"
|
||||||
|
space={12}
|
||||||
|
extraClasses="left-2"
|
||||||
|
inner={`${new Date(item.start_time).toLocaleDateString()} ${
|
||||||
|
item.listens
|
||||||
|
} plays`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background:
|
||||||
|
item.listens > 0
|
||||||
|
? LightenDarkenColor(
|
||||||
|
color,
|
||||||
|
getDarkenAmount(item.listens, 100)
|
||||||
|
)
|
||||||
|
: "var(--color-bg-secondary)",
|
||||||
|
}}
|
||||||
|
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
||||||
|
item.listens > 0
|
||||||
|
? ""
|
||||||
|
: "border-[0.5px] border-(--color-bg-tertiary)"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</Popup>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect } from "react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stepSetter: (value: string) => void;
|
stepSetter: (value: string) => void;
|
||||||
|
|
@ -15,18 +16,15 @@ export default function ActivityOptsSelector({
|
||||||
currentRange,
|
currentRange,
|
||||||
disableCache = false,
|
disableCache = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const stepPeriods = ['day', 'week', 'month', 'year'];
|
const stepPeriods = ['day', 'week', 'month'];
|
||||||
const rangePeriods = [105, 182, 365];
|
const rangePeriods = [105, 182, 364];
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
|
||||||
const stepDisplay = (str: string): string => {
|
const setMenuOpen = (val: boolean) => {
|
||||||
return str.split('_').map(w =>
|
setCollapsed(val)
|
||||||
w.split('').map((char, index) =>
|
if (!disableCache) {
|
||||||
index === 0 ? char.toUpperCase() : char).join('')
|
localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val));
|
||||||
).join(' ');
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const rangeDisplay = (r: number): string => {
|
|
||||||
return `${r}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setStep = (val: string) => {
|
const setStep = (val: string) => {
|
||||||
|
|
@ -45,53 +43,64 @@ export default function ActivityOptsSelector({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableCache) {
|
if (!disableCache) {
|
||||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
|
// TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default.
|
||||||
if (cachedRange) {
|
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182');
|
||||||
rangeSetter(cachedRange);
|
if (cachedRange) rangeSetter(cachedRange);
|
||||||
}
|
|
||||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||||
if (cachedStep) {
|
if (cachedStep) stepSetter(cachedStep);
|
||||||
stepSetter(cachedStep);
|
const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]);
|
||||||
}
|
if (cachedStep) setMenuOpen(cachedConfiguring !== "true");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="relative w-full">
|
||||||
<div className="flex gap-2 items-center">
|
<button
|
||||||
<p>Step:</p>
|
onClick={() => setMenuOpen(!collapsed)}
|
||||||
{stepPeriods.map((p, i) => (
|
className="absolute left-[75px] -top-9 text-muted hover:color-fg transition"
|
||||||
<div key={`step_selector_${p}`}>
|
title="Toggle options"
|
||||||
<button
|
>
|
||||||
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
|
{collapsed ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
|
||||||
onClick={() => setStep(p)}
|
</button>
|
||||||
disabled={p === currentStep}
|
|
||||||
>
|
|
||||||
{stepDisplay(p)}
|
|
||||||
</button>
|
|
||||||
<span className="color-fg-secondary">
|
|
||||||
{i !== stepPeriods.length - 1 ? '|' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div
|
||||||
<p>Range:</p>
|
className={`overflow-hidden transition-[max-height,opacity] duration-250 ease ${
|
||||||
{rangePeriods.map((r, i) => (
|
collapsed ? 'max-h-0 opacity-0' : 'max-h-[100px] opacity-100'
|
||||||
<div key={`range_selector_${r}`}>
|
}`}
|
||||||
<button
|
>
|
||||||
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
|
<div className="flex flex-wrap gap-4 mt-1 text-sm">
|
||||||
onClick={() => setRange(r)}
|
<div className="flex items-center gap-1">
|
||||||
disabled={r === currentRange}
|
<span className="text-muted">Step:</span>
|
||||||
>
|
{stepPeriods.map((p) => (
|
||||||
{rangeDisplay(r)}
|
<button
|
||||||
</button>
|
key={p}
|
||||||
<span className="color-fg-secondary">
|
className={`px-1 rounded transition ${
|
||||||
{i !== rangePeriods.length - 1 ? '|' : ''}
|
p === currentStep ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
|
||||||
</span>
|
}`}
|
||||||
|
onClick={() => setStep(p)}
|
||||||
|
disabled={p === currentStep}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-muted">Range:</span>
|
||||||
|
{rangePeriods.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
className={`px-1 rounded transition ${
|
||||||
|
r === currentRange ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
|
||||||
|
}`}
|
||||||
|
onClick={() => setRange(r)}
|
||||||
|
disabled={r === currentRange}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,31 @@ import { imageUrl, type Album } from "api/api";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: Album
|
album: Album;
|
||||||
size: number
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumDisplay({ album, size }: Props) {
|
export default function AlbumDisplay({ album, size }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3" key={album.id}>
|
<div className="flex gap-3" key={album.id}>
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/album/${album.id}`}>
|
<Link to={`/album/${album.id}`}>
|
||||||
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/>
|
<img
|
||||||
</Link>
|
src={imageUrl(album.image, "large")}
|
||||||
</div>
|
alt={album.title}
|
||||||
<div className="flex flex-col items-start" style={{width: size}}>
|
style={{ width: size }}
|
||||||
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
|
/>
|
||||||
<h4>{album.title}</h4>
|
</Link>
|
||||||
</Link>
|
</div>
|
||||||
<p className="color-fg-secondary">{album.listen_count} plays</p>
|
<div className="flex flex-col items-start" style={{ width: size }}>
|
||||||
</div>
|
<Link
|
||||||
</div>
|
to={`/album/${album.id}`}
|
||||||
)
|
className="hover:text-(--color-fg-secondary)"
|
||||||
|
>
|
||||||
|
<h4>{album.title}</h4>
|
||||||
|
</Link>
|
||||||
|
<p className="color-fg-secondary">{album.listen_count} plays</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,45 +1,58 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getStats } from "api/api"
|
import { getStats, type Stats, type ApiError } from "api/api";
|
||||||
|
|
||||||
export default function AllTimeStats() {
|
export default function AllTimeStats() {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: ["stats", "all_time"],
|
||||||
|
queryFn: ({ queryKey }) => getStats(queryKey[1]),
|
||||||
|
});
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const header = "All time stats";
|
||||||
queryKey: ['stats', 'all_time'],
|
|
||||||
queryFn: ({ queryKey }) => getStats(queryKey[1]),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-[200px]">
|
|
||||||
<h2>All Time Stats</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error:{error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberClasses = 'header-font font-bold text-xl'
|
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{header}</h3>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<h2>All Time Stats</h2>
|
<h3>{header}</h3>
|
||||||
<div>
|
<p className="error">Error: {error.message}</p>
|
||||||
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={numberClasses}>{data.listen_count}</span> Plays
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={numberClasses}>{data.artist_count}</span> Artists
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={numberClasses}>{data.album_count}</span> Albums
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={numberClasses}>{data.track_count}</span> Tracks
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberClasses = "header-font font-bold text-xl";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{header}</h3>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className={numberClasses}
|
||||||
|
title={Math.floor(data.minutes_listened / 60) + " hours"}
|
||||||
|
>
|
||||||
|
{data.minutes_listened}
|
||||||
|
</span>{" "}
|
||||||
|
Minutes Listened
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={numberClasses}>{data.listen_count}</span> Plays
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={numberClasses}>{data.track_count}</span> Tracks
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={numberClasses}>{data.album_count}</span> Albums
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={numberClasses}>{data.artist_count}</span> Artists
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,51 +1,63 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
|
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
|
||||||
import { Link } from "react-router"
|
import { Link } from "react-router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artistId: number
|
artistId: number;
|
||||||
name: string
|
name: string;
|
||||||
period: string
|
period: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistAlbums({artistId, name, period}: Props) {
|
export default function ArtistAlbums({ artistId, name }: Props) {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
const { isPending, isError, data, error } = useQuery({
|
queryKey: [
|
||||||
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
|
"top-albums",
|
||||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
{ limit: 99, period: "all_time", artist_id: artistId },
|
||||||
})
|
],
|
||||||
|
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||||
if (isPending) {
|
});
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Albums From This Artist</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Albums From This Artist</h2>
|
|
||||||
<p className="error">Error:{error.message}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>Albums featuring {name}</h2>
|
<h3>Albums From This Artist</h3>
|
||||||
<div className="flex flex-wrap gap-8">
|
<p>Loading...</p>
|
||||||
{data.items.map((item) => (
|
</div>
|
||||||
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
|
);
|
||||||
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
|
}
|
||||||
<div className="w-[180px] flex flex-col items-start gap-1">
|
if (isError) {
|
||||||
<p>{item.title}</p>
|
return (
|
||||||
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
|
<div>
|
||||||
</div>
|
<h3>Albums From This Artist</h3>
|
||||||
</Link>
|
<p className="error">Error:{error.message}</p>
|
||||||
))}
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
)
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>Albums featuring {name}</h3>
|
||||||
|
<div className="flex flex-wrap gap-8">
|
||||||
|
{data.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
to={`/album/${item.item.id}`}
|
||||||
|
className="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl(item.item.image, "medium")}
|
||||||
|
alt={item.item.title}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
<div className="w-[180px] flex flex-col items-start gap-1">
|
||||||
|
<p>{item.item.title}</p>
|
||||||
|
<p className="text-sm color-fg-secondary">
|
||||||
|
{item.item.listen_count} play
|
||||||
|
{item.item.listen_count > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3,11 +3,10 @@ import { useEffect } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
itemType: string,
|
itemType: string,
|
||||||
id: number,
|
|
||||||
onComplete: Function
|
onComplete: Function
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
export default function ImageDropHandler({ itemType, onComplete }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDragOver = (e: DragEvent) => {
|
const handleDragOver = (e: DragEvent) => {
|
||||||
console.log('dragover!!')
|
console.log('dragover!!')
|
||||||
|
|
@ -25,7 +24,11 @@ export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', imageFile);
|
formData.append('image', imageFile);
|
||||||
formData.append(itemType.toLowerCase()+'_id', String(id))
|
const pathname = window.location.pathname;
|
||||||
|
const segments = pathname.split('/');
|
||||||
|
const filteredSegments = segments.filter(segment => segment !== '');
|
||||||
|
const lastSegment = filteredSegments[filteredSegments.length - 1];
|
||||||
|
formData.append(itemType.toLowerCase()+'_id', lastSegment)
|
||||||
replaceImage(formData).then((r) => {
|
replaceImage(formData).then((r) => {
|
||||||
if (r.status >= 200 && r.status < 300) {
|
if (r.status >= 200 && r.status < 300) {
|
||||||
onComplete()
|
onComplete()
|
||||||
|
|
|
||||||
112
client/app/components/InterestGraph.tsx
Normal file
112
client/app/components/InterestGraph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,106 +1,156 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { timeSince } from "~/utils/utils"
|
import { timeSince } from "~/utils/utils";
|
||||||
import ArtistLinks from "./ArtistLinks"
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api"
|
import {
|
||||||
import { Link } from "react-router"
|
deleteListen,
|
||||||
|
getLastListens,
|
||||||
|
getNowPlaying,
|
||||||
|
type getItemsArgs,
|
||||||
|
type Listen,
|
||||||
|
type Track,
|
||||||
|
} from "api/api";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
limit: number
|
limit: number;
|
||||||
artistId?: Number
|
artistId?: Number;
|
||||||
albumId?: Number
|
albumId?: Number;
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
hideArtists?: boolean
|
hideArtists?: boolean;
|
||||||
|
showNowPlaying?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LastPlays(props: Props) {
|
export default function LastPlays(props: Props) {
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const { user } = useAppContext();
|
||||||
queryKey: ['last-listens', {
|
const { isPending, isError, data, error } = useQuery({
|
||||||
limit: props.limit,
|
queryKey: [
|
||||||
period: 'all_time',
|
"last-listens",
|
||||||
artist_id: props.artistId,
|
{
|
||||||
album_id: props.albumId,
|
limit: props.limit,
|
||||||
track_id: props.trackId
|
period: "all_time",
|
||||||
}],
|
artist_id: props.artistId,
|
||||||
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
album_id: props.albumId,
|
||||||
})
|
track_id: props.trackId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
||||||
|
});
|
||||||
|
const { data: npData } = useQuery({
|
||||||
|
queryKey: ["now-playing"],
|
||||||
|
queryFn: () => getNowPlaying(),
|
||||||
|
});
|
||||||
|
|
||||||
const [items, setItems] = useState<Listen[] | null>(null)
|
const header = "Last played";
|
||||||
|
|
||||||
const handleDelete = async (listen: Listen) => {
|
const [items, setItems] = useState<Listen[] | null>(null);
|
||||||
if (!data) return
|
|
||||||
try {
|
const handleDelete = async (listen: Listen) => {
|
||||||
const res = await deleteListen(listen)
|
if (!data) return;
|
||||||
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
try {
|
||||||
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
|
const res = await deleteListen(listen);
|
||||||
} else {
|
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
||||||
console.error("Failed to delete listen:", res.status)
|
setItems((prev) =>
|
||||||
}
|
(prev ?? data.items).filter((i) => i.time !== listen.time)
|
||||||
} catch (err) {
|
);
|
||||||
console.error("Error deleting listen:", err)
|
} else {
|
||||||
}
|
console.error("Failed to delete listen:", res.status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting listen:", err);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
|
||||||
<div className="w-[300px] sm:w-[500px]">
|
|
||||||
<h2>Last Played</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error: {error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
const listens = items ?? data.items
|
|
||||||
|
|
||||||
let params = ''
|
|
||||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
|
||||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
|
||||||
params += props.trackId ? `&track_id=${props.trackId}` : ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm sm:text-[16px]">
|
<div className="w-[300px] sm:w-[500px]">
|
||||||
<h2 className="hover:underline">
|
<h3>{header}</h3>
|
||||||
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
<p>Loading...</p>
|
||||||
</h2>
|
</div>
|
||||||
<table className="-ml-4">
|
);
|
||||||
<tbody>
|
} else if (isError) {
|
||||||
{listens.map((item) => (
|
return (
|
||||||
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
<div className="w-[300px] sm:w-[500px]">
|
||||||
<td className="w-[1px] pr-2 align-middle">
|
<h3>{header}</h3>
|
||||||
<button
|
<p className="error">Error: {error.message}</p>
|
||||||
onClick={() => handleDelete(item)}
|
</div>
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
);
|
||||||
aria-label="Delete"
|
}
|
||||||
>
|
|
||||||
×
|
const listens = items ?? data.items;
|
||||||
</button>
|
|
||||||
</td>
|
let params = "";
|
||||||
<td
|
params += props.artistId ? `&artist_id=${props.artistId}` : "";
|
||||||
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
params += props.albumId ? `&album_id=${props.albumId}` : "";
|
||||||
title={new Date(item.time).toString()}
|
params += props.trackId ? `&track_id=${props.trackId}` : "";
|
||||||
>
|
|
||||||
{timeSince(new Date(item.time))}
|
return (
|
||||||
</td>
|
<div className="text-sm sm:text-[16px]">
|
||||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
<h3 className="hover:underline">
|
||||||
{props.hideArtists ? null : (
|
<Link to={`/listens?period=all_time${params}`}>{header}</Link>
|
||||||
<>
|
</h3>
|
||||||
<ArtistLinks artists={item.track.artists} /> –{' '}
|
<table className="-ml-4">
|
||||||
</>
|
<tbody>
|
||||||
)}
|
{props.showNowPlaying && npData && npData.currently_playing && (
|
||||||
<Link
|
<tr className="group hover:bg-[--color-bg-secondary]">
|
||||||
className="hover:text-[--color-fg-secondary]"
|
<td className="w-[18px] pr-2 align-middle"></td>
|
||||||
to={`/track/${item.track.id}`}
|
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
|
||||||
>
|
Now Playing
|
||||||
{item.track.title}
|
</td>
|
||||||
</Link>
|
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||||
</td>
|
{props.hideArtists ? null : (
|
||||||
</tr>
|
<>
|
||||||
))}
|
<ArtistLinks artists={npData.track.artists} /> –{" "}
|
||||||
</tbody>
|
</>
|
||||||
</table>
|
)}
|
||||||
</div>
|
<Link
|
||||||
)
|
className="hover:text-[--color-fg-secondary]"
|
||||||
|
to={`/track/${npData.track.id}`}
|
||||||
|
>
|
||||||
|
{npData.track.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{listens.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={`last_listen_${item.time}`}
|
||||||
|
className="group hover:bg-[--color-bg-secondary]"
|
||||||
|
>
|
||||||
|
<td className="w-[18px] pr-2 align-middle">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||||
|
aria-label="Delete"
|
||||||
|
hidden={user === null || user === undefined}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
||||||
|
title={new Date(item.time).toString()}
|
||||||
|
>
|
||||||
|
{timeSince(new Date(item.time))}
|
||||||
|
</td>
|
||||||
|
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||||
|
{props.hideArtists ? null : (
|
||||||
|
<>
|
||||||
|
<ArtistLinks artists={item.track.artists} /> –{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
className="hover:text-[--color-fg-secondary]"
|
||||||
|
to={`/track/${item.track.id}`}
|
||||||
|
>
|
||||||
|
{item.track.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,19 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||||
const selectItem = (title: string, id: number) => {
|
const selectItem = (title: string, id: number) => {
|
||||||
if (selected === id) {
|
if (selected === id) {
|
||||||
setSelected(0)
|
setSelected(0)
|
||||||
onSelect({id: id, title: title})
|
onSelect({id: 0, title: ''})
|
||||||
} else {
|
} else {
|
||||||
setSelected(id)
|
setSelected(id)
|
||||||
onSelect({id: id, title: title})
|
onSelect({id: id, title: title})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data === undefined) {
|
if (!data) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{ data.artists.length > 0 &&
|
{ data.artists && data.artists.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<h3 className={hClasses}>Artists</h3>
|
<h3 className={hClasses}>Artists</h3>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{ data.albums.length > 0 &&
|
{ data.albums && data.albums.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<h3 className={hClasses}>Albums</h3>
|
<h3 className={hClasses}>Albums</h3>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{ data.tracks.length > 0 &&
|
{ data.tracks && data.tracks.length > 0 &&
|
||||||
<>
|
<>
|
||||||
<h3 className={hClasses}>Tracks</h3>
|
<h3 className={hClasses}>Tracks</h3>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,68 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import ArtistLinks from "./ArtistLinks"
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
import {
|
||||||
import { Link } from "react-router"
|
getTopAlbums,
|
||||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
getTopTracks,
|
||||||
import TopItemList from "./TopItemList"
|
imageUrl,
|
||||||
|
type getItemsArgs,
|
||||||
|
} from "api/api";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import TopListSkeleton from "./skeletons/TopListSkeleton";
|
||||||
|
import TopItemList from "./TopItemList";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
limit: number,
|
limit: number;
|
||||||
period: string,
|
period: string;
|
||||||
artistId?: Number
|
artistId?: Number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopAlbums (props: Props) {
|
export default function TopAlbums(props: Props) {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"top-albums",
|
||||||
|
{
|
||||||
|
limit: props.limit,
|
||||||
|
period: props.period,
|
||||||
|
artistId: props.artistId,
|
||||||
|
page: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||||
|
});
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const header = "Top albums";
|
||||||
queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
|
|
||||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-[300px]">
|
|
||||||
<h2>Top Albums</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error:{error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-[300px]">
|
||||||
<h2 className="hover:underline"><Link to={`/chart/top-albums?period=${props.period}${props.artistId ? `&artist_id=${props.artistId}` : ''}`}>Top Albums</Link></h2>
|
<h3>{header}</h3>
|
||||||
<div className="max-w-[300px]">
|
<p>Loading...</p>
|
||||||
<TopItemList type="album" data={data} />
|
</div>
|
||||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
);
|
||||||
</div>
|
} else if (isError) {
|
||||||
</div>
|
return (
|
||||||
)
|
<div className="w-[300px]">
|
||||||
|
<h3>{header}</h3>
|
||||||
|
<p className="error">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="hover:underline">
|
||||||
|
<Link
|
||||||
|
to={`/chart/top-albums?period=${props.period}${
|
||||||
|
props.artistId ? `&artist_id=${props.artistId}` : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="max-w-[300px]">
|
||||||
|
<TopItemList type="album" data={data} />
|
||||||
|
{data.items.length < 1 ? "Nothing to show" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,43 +1,53 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import ArtistLinks from "./ArtistLinks"
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
|
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api";
|
||||||
import { Link } from "react-router"
|
import { Link } from "react-router";
|
||||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
import TopListSkeleton from "./skeletons/TopListSkeleton";
|
||||||
import TopItemList from "./TopItemList"
|
import TopItemList from "./TopItemList";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
limit: number,
|
limit: number;
|
||||||
period: string,
|
period: string;
|
||||||
artistId?: Number
|
artistId?: Number;
|
||||||
albumId?: Number
|
albumId?: Number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopArtists (props: Props) {
|
export default function TopArtists(props: Props) {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"top-artists",
|
||||||
|
{ limit: props.limit, period: props.period, page: 0 },
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
|
||||||
|
});
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const header = "Top artists";
|
||||||
queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
|
|
||||||
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-[300px]">
|
|
||||||
<h2>Top Artists</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error:{error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-[300px]">
|
||||||
<h2 className="hover:underline"><Link to={`/chart/top-artists?period=${props.period}`}>Top Artists</Link></h2>
|
<h3>{header}</h3>
|
||||||
<div className="max-w-[300px]">
|
<p>Loading...</p>
|
||||||
<TopItemList type="artist" data={data} />
|
</div>
|
||||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
);
|
||||||
</div>
|
} else if (isError) {
|
||||||
</div>
|
return (
|
||||||
)
|
<div className="w-[300px]">
|
||||||
|
<h3>{header}</h3>
|
||||||
|
<p className="error">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="hover:underline">
|
||||||
|
<Link to={`/chart/top-artists?period=${props.period}`}>{header}</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="max-w-[300px]">
|
||||||
|
<TopItemList type="artist" data={data} />
|
||||||
|
{data.items.length < 1 ? "Nothing to show" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,142 +1,171 @@
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import ArtistLinks from "./ArtistLinks";
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
|
import {
|
||||||
|
imageUrl,
|
||||||
|
type Album,
|
||||||
|
type Artist,
|
||||||
|
type Track,
|
||||||
|
type PaginatedResponse,
|
||||||
|
type Ranked,
|
||||||
|
} from "api/api";
|
||||||
|
|
||||||
type Item = Album | Track | Artist;
|
type Item = Album | Track | Artist;
|
||||||
|
|
||||||
interface Props<T extends Item> {
|
interface Props<T extends Ranked<Item>> {
|
||||||
data: PaginatedResponse<T>
|
data: PaginatedResponse<T>;
|
||||||
separators?: ConstrainBoolean
|
separators?: ConstrainBoolean;
|
||||||
type: "album" | "track" | "artist";
|
ranked?: boolean;
|
||||||
className?: string,
|
type: "album" | "track" | "artist";
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopItemList<T extends Item>({ data, separators, type, className }: Props<T>) {
|
export default function TopItemList<T extends Ranked<Item>>({
|
||||||
|
data,
|
||||||
|
separators,
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
ranked,
|
||||||
|
}: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1 ${className} min-w-[200px]`}>
|
||||||
|
{data.items.map((item, index) => {
|
||||||
|
const key = `${type}-${item.item.id}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
className={`${
|
||||||
|
separators && index !== data.items.length - 1
|
||||||
|
? "border-b border-(--color-fg-tertiary) mb-1 pb-2"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ItemCard
|
||||||
|
ranked={ranked}
|
||||||
|
rank={item.rank}
|
||||||
|
item={item.item}
|
||||||
|
type={type}
|
||||||
|
key={type + item.item.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
function ItemCard({
|
||||||
<div className={`flex flex-col gap-1 ${className} min-w-[300px]`}>
|
item,
|
||||||
{data.items.map((item, index) => {
|
type,
|
||||||
const key = `${type}-${item.id}`;
|
rank,
|
||||||
return (
|
ranked,
|
||||||
<div
|
}: {
|
||||||
key={key}
|
item: Item;
|
||||||
style={{ fontSize: 12 }}
|
type: "album" | "track" | "artist";
|
||||||
className={`${
|
rank: number;
|
||||||
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
|
ranked?: boolean;
|
||||||
}`}
|
}) {
|
||||||
>
|
const itemClasses = `flex items-center gap-2`;
|
||||||
<ItemCard item={item} type={type} key={type+item.id} />
|
|
||||||
</div>
|
switch (type) {
|
||||||
);
|
case "album": {
|
||||||
})}
|
const album = item as Album;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||||
|
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||||
|
<Link to={`/album/${album.id}`}>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={imageUrl(album.image, "small")}
|
||||||
|
alt={album.title}
|
||||||
|
className="min-w-[48px]"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to={`/album/${album.id}`}
|
||||||
|
className="hover:text-(--color-fg-secondary)"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>{album.title}</span>
|
||||||
|
</Link>
|
||||||
|
<br />
|
||||||
|
{album.is_various_artists ? (
|
||||||
|
<span className="color-fg-secondary">Various Artists</span>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<ArtistLinks
|
||||||
|
artists={
|
||||||
|
album.artists
|
||||||
|
? [album.artists[0]]
|
||||||
|
: [{ id: 0, name: "Unknown Artist" }]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
|
|
||||||
|
|
||||||
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleItemClick = (type: string, id: number) => {
|
|
||||||
navigate(`/${type.toLowerCase()}/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArtistClick = (event: React.MouseEvent) => {
|
|
||||||
// Stop the click from navigating to the album page
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Also stop keyboard events on the inner links from bubbling up
|
|
||||||
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
}
|
||||||
|
case "track": {
|
||||||
|
const track = item as Track;
|
||||||
|
|
||||||
switch (type) {
|
return (
|
||||||
case "album": {
|
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||||
const album = item as Album;
|
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||||
|
<Link to={`/track/${track.id}`}>
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
<img
|
||||||
if (event.key === 'Enter') {
|
loading="lazy"
|
||||||
handleItemClick("album", album.id);
|
src={imageUrl(track.image, "small")}
|
||||||
}
|
alt={track.title}
|
||||||
};
|
className="min-w-[48px]"
|
||||||
|
/>
|
||||||
return (
|
</Link>
|
||||||
<div style={{fontSize: 12}}>
|
<div>
|
||||||
<div
|
<Link
|
||||||
className={itemClasses}
|
to={`/track/${track.id}`}
|
||||||
onClick={() => handleItemClick("album", album.id)}
|
className="hover:text-(--color-fg-secondary)"
|
||||||
onKeyDown={handleKeyDown}
|
>
|
||||||
role="link"
|
<span style={{ fontSize: 14 }}>{track.title}</span>
|
||||||
tabIndex={0}
|
</Link>
|
||||||
aria-label={`View album: ${album.title}`}
|
<br />
|
||||||
style={{ cursor: 'pointer' }}
|
<div>
|
||||||
>
|
<ArtistLinks
|
||||||
<img src={imageUrl(album.image, "small")} alt={album.title} />
|
artists={track.artists || [{ id: 0, Name: "Unknown Artist" }]}
|
||||||
<div>
|
/>
|
||||||
<span style={{fontSize: 14}}>{album.title}</span>
|
</div>
|
||||||
<br />
|
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||||
{album.is_various_artists ?
|
</div>
|
||||||
<span className="color-fg-secondary">Various Artists</span>
|
</div>
|
||||||
:
|
);
|
||||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
|
||||||
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "track": {
|
|
||||||
const track = item as Track;
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
handleItemClick("track", track.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{fontSize: 12}}>
|
|
||||||
<div
|
|
||||||
className={itemClasses}
|
|
||||||
onClick={() => handleItemClick("track", track.id)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
role="link"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`View track: ${track.title}`}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<img src={imageUrl(track.image, "small")} alt={track.title} />
|
|
||||||
<div>
|
|
||||||
<span style={{fontSize: 14}}>{track.title}</span>
|
|
||||||
<br />
|
|
||||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
|
||||||
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
|
||||||
</div>
|
|
||||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "artist": {
|
|
||||||
const artist = item as Artist;
|
|
||||||
return (
|
|
||||||
<div style={{fontSize: 12}}>
|
|
||||||
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
|
|
||||||
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
|
|
||||||
<div>
|
|
||||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
|
||||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case "artist": {
|
||||||
|
const artist = item as Artist;
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||||
|
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||||
|
<Link
|
||||||
|
className={
|
||||||
|
itemClasses + " mt-1 mb-[6px] hover:text-(--color-fg-secondary)"
|
||||||
|
}
|
||||||
|
to={`/artist/${artist.id}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
src={imageUrl(artist.image, "small")}
|
||||||
|
alt={artist.name}
|
||||||
|
className="min-w-[48px]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 14 }}>{artist.name}</span>
|
||||||
|
<div className="color-fg-secondary">
|
||||||
|
{artist.listen_count} plays
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,43 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getTopAlbums, type getItemsArgs } from "api/api"
|
import { getTopAlbums, type getItemsArgs } from "api/api";
|
||||||
import AlbumDisplay from "./AlbumDisplay"
|
import AlbumDisplay from "./AlbumDisplay";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
period: string
|
period: string;
|
||||||
artistId?: Number
|
artistId?: Number;
|
||||||
vert?: boolean
|
vert?: boolean;
|
||||||
hideTitle?: boolean
|
hideTitle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopThreeAlbums(props: Props) {
|
export default function TopThreeAlbums(props: Props) {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"top-albums",
|
||||||
|
{ limit: 3, period: props.period, artist_id: props.artistId, page: 0 },
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||||
|
});
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
if (isPending) {
|
||||||
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
|
return <p>Loading...</p>;
|
||||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
}
|
||||||
})
|
if (isError) {
|
||||||
|
return <p className="error">Error:{error.message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPending) {
|
console.log(data);
|
||||||
return <p>Loading...</p>
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error:{error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(data)
|
return (
|
||||||
|
<div>
|
||||||
return (
|
{!props.hideTitle && <h3>Top Three Albums</h3>}
|
||||||
<div>
|
<div
|
||||||
{!props.hideTitle && <h2>Top Three Albums</h2>}
|
className={`flex ${props.vert ? "flex-col" : ""}`}
|
||||||
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
|
style={{ gap: 15 }}
|
||||||
{data.items.map((item, index) => (
|
>
|
||||||
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
|
{data.items.map((item, index) => (
|
||||||
))}
|
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,50 +1,69 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import ArtistLinks from "./ArtistLinks"
|
import ArtistLinks from "./ArtistLinks";
|
||||||
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api";
|
||||||
import { Link } from "react-router"
|
import { Link } from "react-router";
|
||||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
import TopListSkeleton from "./skeletons/TopListSkeleton";
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react";
|
||||||
import TopItemList from "./TopItemList"
|
import TopItemList from "./TopItemList";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
limit: number,
|
limit: number;
|
||||||
period: string,
|
period: string;
|
||||||
artistId?: Number
|
artistId?: Number;
|
||||||
albumId?: Number
|
albumId?: Number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopTracks = (props: Props) => {
|
const TopTracks = (props: Props) => {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"top-tracks",
|
||||||
|
{
|
||||||
|
limit: props.limit,
|
||||||
|
period: props.period,
|
||||||
|
artist_id: props.artistId,
|
||||||
|
album_id: props.albumId,
|
||||||
|
page: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
|
||||||
|
});
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const header = "Top tracks";
|
||||||
queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
|
|
||||||
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-[300px]">
|
|
||||||
<h2>Top Tracks</h2>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <p className="error">Error:{error.message}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = ''
|
|
||||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
|
||||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-[300px]">
|
||||||
<h2 className="hover:underline"><Link to={`/chart/top-tracks?period=${props.period}${params}`}>Top Tracks</Link></h2>
|
<h3>{header}</h3>
|
||||||
<div className="max-w-[300px]">
|
<p>Loading...</p>
|
||||||
<TopItemList type="track" data={data}/>
|
</div>
|
||||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
);
|
||||||
</div>
|
} else if (isError) {
|
||||||
</div>
|
return (
|
||||||
)
|
<div className="w-[300px]">
|
||||||
}
|
<h3>{header}</h3>
|
||||||
|
<p className="error">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data.items) return;
|
||||||
|
|
||||||
export default TopTracks
|
let params = "";
|
||||||
|
params += props.artistId ? `&artist_id=${props.artistId}` : "";
|
||||||
|
params += props.albumId ? `&album_id=${props.albumId}` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="hover:underline">
|
||||||
|
<Link to={`/chart/top-tracks?period=${props.period}${params}`}>
|
||||||
|
{header}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="max-w-[300px]">
|
||||||
|
<TopItemList type="track" data={data} />
|
||||||
|
{data.items.length < 1 ? "Nothing to show" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopTracks;
|
||||||
|
|
|
||||||
23
client/app/components/icons/MbzIcon.tsx
Normal file
23
client/app/components/icons/MbzIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,106 +1,124 @@
|
||||||
import { logout, updateUser } from "api/api"
|
import { logout, updateUser } from "api/api";
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { AsyncButton } from "../AsyncButton"
|
import { AsyncButton } from "../AsyncButton";
|
||||||
import { useAppContext } from "~/providers/AppProvider"
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPw, setConfirmPw] = useState('')
|
const [confirmPw, setConfirmPw] = useState("");
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState("");
|
||||||
const { user, setUsername: setCtxUsername } = useAppContext()
|
const { user, setUsername: setCtxUsername } = useAppContext();
|
||||||
|
|
||||||
const logoutHandler = () => {
|
const logoutHandler = () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
logout()
|
logout()
|
||||||
.then(r => {
|
.then((r) => {
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
r.json().then(r => setError(r.error))
|
r.json().then((r) => setError(r.error));
|
||||||
}
|
|
||||||
}).catch(err => setError(err))
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
const updateHandler = () => {
|
|
||||||
setError('')
|
|
||||||
setSuccess('')
|
|
||||||
if (password != "" && confirmPw === "") {
|
|
||||||
setError("confirm your new password before submitting")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setError('')
|
})
|
||||||
setSuccess('')
|
.catch((err) => setError(err));
|
||||||
setLoading(true)
|
setLoading(false);
|
||||||
updateUser(username, password)
|
};
|
||||||
.then(r => {
|
const updateHandler = () => {
|
||||||
if (r.ok) {
|
setError("");
|
||||||
setSuccess("sucessfully updated user")
|
setSuccess("");
|
||||||
if (username != "") {
|
if (password != "" && confirmPw === "") {
|
||||||
setCtxUsername(username)
|
setError("confirm your new password before submitting");
|
||||||
}
|
return;
|
||||||
setUsername('')
|
|
||||||
setPassword('')
|
|
||||||
setConfirmPw('')
|
|
||||||
} else {
|
|
||||||
r.json().then((r) => setError(r.error))
|
|
||||||
}
|
|
||||||
}).catch(err => setError(err))
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
setLoading(true);
|
||||||
|
updateUser(username, password)
|
||||||
|
.then((r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
setSuccess("sucessfully updated user");
|
||||||
|
if (username != "") {
|
||||||
|
setCtxUsername(username);
|
||||||
|
}
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPw("");
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2>Account</h2>
|
<h3>Account</h3>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-4 items-center">
|
<div className="flex flex-col gap-4 items-center">
|
||||||
<p>You're logged in as <strong>{user?.username}</strong></p>
|
<p>
|
||||||
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
You're logged in as <strong>{user?.username}</strong>
|
||||||
</div>
|
</p>
|
||||||
<h2>Update User</h2>
|
<AsyncButton loading={loading} onClick={logoutHandler}>
|
||||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
Logout
|
||||||
<div className="flex flex gap-4">
|
</AsyncButton>
|
||||||
<input
|
|
||||||
name="koito-update-username"
|
|
||||||
type="text"
|
|
||||||
placeholder="Update username"
|
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-sm">
|
|
||||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex gap-4">
|
|
||||||
<input
|
|
||||||
name="koito-update-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Update password"
|
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
name="koito-confirm-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
|
||||||
value={confirmPw}
|
|
||||||
onChange={(e) => setConfirmPw(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-sm">
|
|
||||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{success != "" && <p className="success">{success}</p>}
|
|
||||||
{error != "" && <p className="error">{error}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<h3>Update User</h3>
|
||||||
)
|
<form
|
||||||
|
action="#"
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex gap-4">
|
||||||
|
<input
|
||||||
|
name="koito-update-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Update username"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-sm">
|
||||||
|
<AsyncButton loading={loading} onClick={updateHandler}>
|
||||||
|
Submit
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
action="#"
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex gap-4">
|
||||||
|
<input
|
||||||
|
name="koito-update-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Update password"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="koito-confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-sm">
|
||||||
|
<AsyncButton loading={loading} onClick={updateHandler}>
|
||||||
|
Submit
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{success != "" && <p className="success">{success}</p>}
|
||||||
|
{error != "" && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
60
client/app/components/modals/AddListenModal.tsx
Normal file
60
client/app/components/modals/AddListenModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,172 +5,183 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { Copy, Trash } from "lucide-react";
|
import { Copy, Trash } from "lucide-react";
|
||||||
|
|
||||||
type CopiedState = {
|
type CopiedState = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApiKeysModal() {
|
export default function ApiKeysModal() {
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading ] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setError ] = useState<string>()
|
const [err, setError] = useState<string>();
|
||||||
const [displayData, setDisplayData] = useState<ApiKey[]>([])
|
const [displayData, setDisplayData] = useState<ApiKey[]>([]);
|
||||||
const [copied, setCopied] = useState<CopiedState | null>(null);
|
const [copied, setCopied] = useState<CopiedState | null>(null);
|
||||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
const handleRevealAndSelect = (key: string) => {
|
const handleRevealAndSelect = (key: string) => {
|
||||||
setExpandedKey(key);
|
setExpandedKey(key);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = textRefs.current[key];
|
const el = textRefs.current[key];
|
||||||
if (el) {
|
if (el) {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
sel?.removeAllRanges();
|
sel?.removeAllRanges();
|
||||||
sel?.addRange(range);
|
sel?.addRange(range);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isPending, isError, data, error } = useQuery({
|
const { isPending, isError, data, error } = useQuery({
|
||||||
queryKey: [
|
queryKey: ["api-keys"],
|
||||||
'api-keys'
|
queryFn: () => {
|
||||||
],
|
return getApiKeys();
|
||||||
queryFn: () => {
|
},
|
||||||
return getApiKeys();
|
});
|
||||||
},
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setDisplayData(data);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <p className="error">Error: {error.message}</p>;
|
||||||
|
}
|
||||||
|
if (isPending) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentRect = (
|
||||||
|
e.currentTarget.closest(".relative") as HTMLElement
|
||||||
|
).getBoundingClientRect();
|
||||||
|
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||||
|
|
||||||
|
setCopied({
|
||||||
|
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
|
||||||
|
y: buttonRect.top - parentRect.top - 8,
|
||||||
|
visible: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
setTimeout(() => setCopied(null), 1500);
|
||||||
if (data) {
|
};
|
||||||
setDisplayData(data)
|
|
||||||
}
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
if (isError) {
|
const fallbackCopy = (text: string) => {
|
||||||
return (
|
const textarea = document.createElement("textarea");
|
||||||
<p className="error">Error: {error.message}</p>
|
textarea.value = text;
|
||||||
)
|
textarea.style.position = "fixed"; // prevent scroll to bottom
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fallback: Copy failed", err);
|
||||||
}
|
}
|
||||||
if (isPending) {
|
document.body.removeChild(textarea);
|
||||||
return (
|
};
|
||||||
<p>Loading...</p>
|
|
||||||
)
|
const handleCreateApiKey = () => {
|
||||||
|
setError(undefined);
|
||||||
|
if (input === "") {
|
||||||
|
setError("a label must be provided");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setLoading(true);
|
||||||
|
createApiKey(input)
|
||||||
|
.then((r) => {
|
||||||
|
setDisplayData([r, ...displayData]);
|
||||||
|
setInput("");
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
const handleDeleteApiKey = (id: number) => {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
setError(undefined);
|
||||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
setLoading(true);
|
||||||
} else {
|
deleteApiKey(id).then((r) => {
|
||||||
fallbackCopy(text);
|
if (r.ok) {
|
||||||
}
|
setDisplayData(displayData.filter((v) => v.id != id));
|
||||||
|
} else {
|
||||||
|
r.json().then((r) => setError(r.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
|
return (
|
||||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
<div className="">
|
||||||
|
<h3>API Keys</h3>
|
||||||
setCopied({
|
<div className="flex flex-col gap-4 relative">
|
||||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
|
{displayData.map((v) => (
|
||||||
y: buttonRect.top - parentRect.top - 8,
|
<div className="flex gap-2">
|
||||||
visible: true,
|
<div
|
||||||
});
|
key={v.key}
|
||||||
|
ref={(el) => {
|
||||||
setTimeout(() => setCopied(null), 1500);
|
textRefs.current[v.key] = el;
|
||||||
};
|
}}
|
||||||
|
onClick={() => handleRevealAndSelect(v.key)}
|
||||||
const fallbackCopy = (text: string) => {
|
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
|
||||||
const textarea = document.createElement("textarea");
|
expandedKey === v.key ? "" : "truncate"
|
||||||
textarea.value = text;
|
}`}
|
||||||
textarea.style.position = "fixed"; // prevent scroll to bottom
|
style={{ whiteSpace: "nowrap" }}
|
||||||
document.body.appendChild(textarea);
|
title={v.key} // optional tooltip
|
||||||
textarea.focus();
|
>
|
||||||
textarea.select();
|
{expandedKey === v.key
|
||||||
try {
|
? v.key
|
||||||
document.execCommand("copy");
|
: `${v.key.slice(0, 8)}... ${v.label}`}
|
||||||
} catch (err) {
|
|
||||||
console.error("Fallback: Copy failed", err);
|
|
||||||
}
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateApiKey = () => {
|
|
||||||
setError(undefined)
|
|
||||||
if (input === "") {
|
|
||||||
setError("a label must be provided")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
createApiKey(input)
|
|
||||||
.then(r => {
|
|
||||||
setDisplayData([r, ...displayData])
|
|
||||||
setInput('')
|
|
||||||
}).catch((err) => setError(err.message))
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteApiKey = (id: number) => {
|
|
||||||
setError(undefined)
|
|
||||||
setLoading(true)
|
|
||||||
deleteApiKey(id)
|
|
||||||
.then(r => {
|
|
||||||
if (r.ok) {
|
|
||||||
setDisplayData(displayData.filter((v) => v.id != id))
|
|
||||||
} else {
|
|
||||||
r.json().then((r) => setError(r.error))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="">
|
|
||||||
<h2>API Keys</h2>
|
|
||||||
<div className="flex flex-col gap-4 relative">
|
|
||||||
{displayData.map((v) => (
|
|
||||||
<div className="flex gap-2"><div
|
|
||||||
key={v.key}
|
|
||||||
ref={el => {
|
|
||||||
textRefs.current[v.key] = el;
|
|
||||||
}}
|
|
||||||
onClick={() => handleRevealAndSelect(v.key)}
|
|
||||||
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
|
|
||||||
expandedKey === v.key ? '' : 'truncate'
|
|
||||||
}`}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
title={v.key} // optional tooltip
|
|
||||||
>
|
|
||||||
{expandedKey === v.key ? v.key : `${v.key.slice(0, 8)}... ${v.label}`}
|
|
||||||
</div>
|
|
||||||
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
|
|
||||||
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex gap-2 w-3/5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Add a label for a new API key"
|
|
||||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
|
|
||||||
</div>
|
</div>
|
||||||
{err && <p className="error">{err}</p>}
|
<button
|
||||||
{copied?.visible && (
|
onClick={(e) => handleCopy(e, v.key)}
|
||||||
<div
|
className="large-button px-5 rounded-md"
|
||||||
style={{
|
>
|
||||||
position: "absolute",
|
<Copy size={16} />
|
||||||
top: copied.y,
|
</button>
|
||||||
left: copied.x,
|
<AsyncButton
|
||||||
transform: "translate(-50%, -100%)",
|
loading={loading}
|
||||||
}}
|
onClick={() => handleDeleteApiKey(v.id)}
|
||||||
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
|
confirm
|
||||||
>
|
>
|
||||||
Copied!
|
<Trash size={16} />
|
||||||
</div>
|
</AsyncButton>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2 w-3/5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a label for a new API key"
|
||||||
|
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={loading} onClick={handleCreateApiKey}>
|
||||||
|
Create
|
||||||
|
</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{err && <p className="error">{err}</p>}
|
||||||
)
|
{copied?.visible && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: copied.y,
|
||||||
|
left: copied.x,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
}}
|
||||||
|
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
|
||||||
|
>
|
||||||
|
Copied!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,40 +1,41 @@
|
||||||
import { deleteItem } from "api/api"
|
import { deleteItem } from "api/api";
|
||||||
import { AsyncButton } from "../AsyncButton"
|
import { AsyncButton } from "../AsyncButton";
|
||||||
import { Modal } from "./Modal"
|
import { Modal } from "./Modal";
|
||||||
import { useNavigate } from "react-router"
|
import { useNavigate } from "react-router";
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: Function
|
setOpen: Function;
|
||||||
title: string,
|
title: string;
|
||||||
id: number,
|
id: number;
|
||||||
type: string
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
|
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const doDelete = () => {
|
const doDelete = () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
deleteItem(type.toLowerCase(), id)
|
deleteItem(type.toLowerCase(), id).then((r) => {
|
||||||
.then(r => {
|
if (r.ok) {
|
||||||
if (r.ok) {
|
navigate(-1);
|
||||||
navigate('/')
|
} else {
|
||||||
} else {
|
console.log(r);
|
||||||
console.log(r)
|
}
|
||||||
}
|
});
|
||||||
})
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
||||||
<h2>Delete "{title}"?</h2>
|
<h3>Delete "{title}"?</h3>
|
||||||
<p>This action is irreversible!</p>
|
<p>This action is irreversible!</p>
|
||||||
<div className="flex flex-col mt-3 items-center">
|
<div className="flex flex-col mt-3 items-center">
|
||||||
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
|
<AsyncButton loading={loading} onClick={doDelete}>
|
||||||
</div>
|
Yes, Delete It
|
||||||
</Modal>
|
</AsyncButton>
|
||||||
)
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
165
client/app/components/modals/EditModal/EditModal.tsx
Normal file
165
client/app/components/modals/EditModal/EditModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
client/app/components/modals/EditModal/SetPrimaryArtist.tsx
Normal file
99
client/app/components/modals/EditModal/SetPrimaryArtist.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
client/app/components/modals/EditModal/SetVariousArtist.tsx
Normal file
77
client/app/components/modals/EditModal/SetVariousArtist.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
client/app/components/modals/EditModal/UpdateMbzID.tsx
Normal file
53
client/app/components/modals/EditModal/UpdateMbzID.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
client/app/components/modals/ExportModal.tsx
Normal file
47
client/app/components/modals/ExportModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,86 +5,111 @@ import SearchResults from "../SearchResults";
|
||||||
import { AsyncButton } from "../AsyncButton";
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: string
|
type: string;
|
||||||
id: number
|
id: number;
|
||||||
musicbrainzId?: string
|
musicbrainzId?: string;
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: Function
|
setOpen: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
|
export default function ImageReplaceModal({
|
||||||
const [query, setQuery] = useState('');
|
musicbrainzId,
|
||||||
const [loading, setLoading] = useState(false)
|
type,
|
||||||
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
|
id,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true);
|
||||||
|
|
||||||
const doImageReplace = (url: string) => {
|
const doImageReplace = (url: string) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
const formData = new FormData
|
setError("");
|
||||||
formData.set(`${type.toLowerCase()}_id`, id.toString())
|
const formData = new FormData();
|
||||||
formData.set("image_url", url)
|
formData.set(`${type.toLowerCase()}_id`, id.toString());
|
||||||
replaceImage(formData)
|
formData.set("image_url", url);
|
||||||
.then((r) => {
|
replaceImage(formData)
|
||||||
if (r.ok) {
|
.then((r) => {
|
||||||
window.location.reload()
|
if (r.status >= 200 && r.status < 300) {
|
||||||
} else {
|
window.location.reload();
|
||||||
console.log(r)
|
} else {
|
||||||
setLoading(false)
|
r.json().then((r) => setError(r.error));
|
||||||
}
|
setLoading(false);
|
||||||
})
|
}
|
||||||
.catch((err) => console.log(err))
|
})
|
||||||
}
|
.catch((err) => setError(err));
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
setQuery('')
|
setQuery("");
|
||||||
}
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={open} onClose={closeModal}>
|
<Modal isOpen={open} onClose={closeModal}>
|
||||||
<h2>Replace Image</h2>
|
<h3>Replace Image</h3>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||||
placeholder={`Image URL`}
|
placeholder={`Enter image URL, or drag-and-drop a local file`}
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
{query != "" ? (
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<AsyncButton
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => doImageReplace(query)}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
{type === "Album" && musicbrainzId ? (
|
||||||
|
<>
|
||||||
|
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
|
||||||
|
<button
|
||||||
|
className="mt-4"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() =>
|
||||||
|
doImageReplace(
|
||||||
|
`https://coverartarchive.org/release/${musicbrainzId}/front`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={`relative`}>
|
||||||
|
{suggestedImgLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
|
||||||
|
style={{ width: 20, height: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
|
||||||
|
onLoad={() => setSuggestedImgLoading(false)}
|
||||||
|
onError={() => setSuggestedImgLoading(false)}
|
||||||
|
className={`block w-[130px] h-auto ${
|
||||||
|
suggestedImgLoading ? "opacity-0" : "opacity-100"
|
||||||
|
} transition-opacity duration-300`}
|
||||||
/>
|
/>
|
||||||
{ query != "" ?
|
</div>
|
||||||
<div className="flex gap-2 mt-4">
|
</button>
|
||||||
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
|
</>
|
||||||
</div> :
|
) : (
|
||||||
''}
|
""
|
||||||
{ type === "Album" && musicbrainzId ?
|
)}
|
||||||
<>
|
<p className="error">{error}</p>
|
||||||
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
|
</div>
|
||||||
<button
|
</Modal>
|
||||||
className="mt-4"
|
);
|
||||||
disabled={loading}
|
|
||||||
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
|
|
||||||
>
|
|
||||||
<div className={`relative`}>
|
|
||||||
{suggestedImgLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
|
|
||||||
style={{ width: 20, height: 20 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<img
|
|
||||||
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
|
|
||||||
onLoad={() => setSuggestedImgLoading(false)}
|
|
||||||
onError={() => setSuggestedImgLoading(false)}
|
|
||||||
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,59 +1,74 @@
|
||||||
import { login } from "api/api"
|
import { login } from "api/api";
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react";
|
||||||
import { AsyncButton } from "../AsyncButton"
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState("");
|
||||||
const [remember, setRemember] = useState(false)
|
const [remember, setRemember] = useState(false);
|
||||||
|
|
||||||
const loginHandler = () => {
|
const loginHandler = () => {
|
||||||
if (username && password) {
|
if (username && password) {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
login(username, password, remember)
|
login(username, password, remember)
|
||||||
.then(r => {
|
.then((r) => {
|
||||||
if (r.status >= 200 && r.status < 300) {
|
if (r.status >= 200 && r.status < 300) {
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
r.json().then(r => setError(r.error))
|
r.json().then((r) => setError(r.error));
|
||||||
}
|
}
|
||||||
}).catch(err => setError(err))
|
})
|
||||||
setLoading(false)
|
.catch((err) => setError(err));
|
||||||
} else if (username || password) {
|
setLoading(false);
|
||||||
setError("username and password are required")
|
} else if (username || password) {
|
||||||
}
|
setError("username and password are required");
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2>Log In</h2>
|
<h3>Log In</h3>
|
||||||
<div className="flex flex-col items-center gap-4 w-full">
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
|
<p>
|
||||||
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
|
Logging in gives you access to <strong>admin tools</strong>, such as
|
||||||
<input
|
updating images, merging items, deleting items, and more.
|
||||||
name="koito-username"
|
</p>
|
||||||
type="text"
|
<form
|
||||||
placeholder="Username"
|
action="#"
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
className="flex flex-col items-center gap-4 w-3/4"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
/>
|
>
|
||||||
<input
|
<input
|
||||||
name="koito-password"
|
name="koito-username"
|
||||||
type="password"
|
type="text"
|
||||||
placeholder="Password"
|
placeholder="Username"
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<input
|
||||||
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
|
name="koito-password"
|
||||||
<label htmlFor="kotio-remember">Remember me</label>
|
type="password"
|
||||||
</div>
|
placeholder="Password"
|
||||||
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
</form>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
<p className="error">{error}</p>
|
/>
|
||||||
</div>
|
<div className="flex gap-2 items-center">
|
||||||
</>
|
<input
|
||||||
)
|
type="checkbox"
|
||||||
|
name="koito-remember"
|
||||||
|
id="koito-remember"
|
||||||
|
onChange={() => setRemember(!remember)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="kotio-remember">Remember me</label>
|
||||||
|
</div>
|
||||||
|
<AsyncButton loading={loading} onClick={loginHandler}>
|
||||||
|
Login
|
||||||
|
</AsyncButton>
|
||||||
|
</form>
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2,124 +2,159 @@ import { useEffect, useState } from "react";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { search, type SearchResponse } from "api/api";
|
import { search, type SearchResponse } from "api/api";
|
||||||
import SearchResults from "../SearchResults";
|
import SearchResults from "../SearchResults";
|
||||||
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
|
import type {
|
||||||
|
MergeFunc,
|
||||||
|
MergeSearchCleanerFunc,
|
||||||
|
} from "~/routes/MediaItems/MediaLayout";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: Function
|
setOpen: Function;
|
||||||
type: string
|
type: string;
|
||||||
currentId: number
|
currentId: number;
|
||||||
currentTitle: string
|
currentTitle: string;
|
||||||
mergeFunc: MergeFunc
|
mergeFunc: MergeFunc;
|
||||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
mergeCleanerFunc: MergeSearchCleanerFunc;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MergeModal(props: Props) {
|
export default function MergeModal(props: Props) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState(props.currentTitle);
|
||||||
const [data, setData] = useState<SearchResponse>();
|
const [data, setData] = useState<SearchResponse>();
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||||
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
|
const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
|
||||||
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
|
{ title: "", id: 0 }
|
||||||
const navigate = useNavigate()
|
);
|
||||||
|
const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
|
||||||
|
const [replaceImage, setReplaceImage] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const closeMergeModal = () => {
|
||||||
|
props.setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
setData(undefined);
|
||||||
|
setMergeOrderReversed(false);
|
||||||
|
setMergeTarget({ title: "", id: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
const closeMergeModal = () => {
|
const toggleSelect = ({ title, id }: { title: string; id: number }) => {
|
||||||
props.setOpen(false)
|
setMergeTarget({ title: title, id: id });
|
||||||
setQuery('')
|
};
|
||||||
setData(undefined)
|
|
||||||
setMergeOrderReversed(false)
|
useEffect(() => {
|
||||||
setMergeTarget({title: '', id: 0})
|
console.log("mergeTarget", mergeTarget);
|
||||||
|
}, [mergeTarget]);
|
||||||
|
|
||||||
|
const doMerge = () => {
|
||||||
|
let from, to;
|
||||||
|
if (!mergeOrderReversed) {
|
||||||
|
from = mergeTarget;
|
||||||
|
to = { id: props.currentId, title: props.currentTitle };
|
||||||
|
} else {
|
||||||
|
from = { id: props.currentId, title: props.currentTitle };
|
||||||
|
to = mergeTarget;
|
||||||
}
|
}
|
||||||
|
props
|
||||||
const toggleSelect = ({title, id}: {title: string, id: number}) => {
|
.mergeFunc(from.id, to.id, replaceImage)
|
||||||
if (mergeTarget.id === 0) {
|
.then((r) => {
|
||||||
setMergeTarget({title: title, id: id})
|
if (r.ok) {
|
||||||
|
if (mergeOrderReversed) {
|
||||||
|
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
|
||||||
|
closeMergeModal();
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setMergeTarget({title:"", id: 0})
|
// TODO: handle error
|
||||||
|
console.log(r);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query);
|
||||||
|
if (query === "") {
|
||||||
|
setData(undefined);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery) {
|
||||||
|
search(debouncedQuery).then((r) => {
|
||||||
|
r = props.mergeCleanerFunc(r, props.currentId);
|
||||||
|
setData(r);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
console.log(mergeTarget)
|
|
||||||
}, [mergeTarget])
|
|
||||||
|
|
||||||
const doMerge = () => {
|
|
||||||
let from, to
|
|
||||||
if (!mergeOrderReversed) {
|
|
||||||
from = mergeTarget
|
|
||||||
to = {id: props.currentId, title: props.currentTitle}
|
|
||||||
} else {
|
|
||||||
from = {id: props.currentId, title: props.currentTitle}
|
|
||||||
to = mergeTarget
|
|
||||||
}
|
|
||||||
props.mergeFunc(from.id, to.id)
|
|
||||||
.then(r => {
|
|
||||||
if (r.ok) {
|
|
||||||
if (mergeOrderReversed) {
|
|
||||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`)
|
|
||||||
closeMergeModal()
|
|
||||||
} else {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO: handle error
|
|
||||||
console.log(r)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedQuery(query);
|
|
||||||
if (query === '') {
|
|
||||||
setData(undefined)
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(handler);
|
|
||||||
};
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debouncedQuery) {
|
|
||||||
search(debouncedQuery).then((r) => {
|
|
||||||
r = props.mergeCleanerFunc(r, props.currentId)
|
|
||||||
setData(r);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [debouncedQuery]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={props.open} onClose={closeMergeModal}>
|
<Modal isOpen={props.open} onClose={closeMergeModal}>
|
||||||
<h2>Merge {props.type}s</h2>
|
<h3>Merge {props.type}s</h3>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
defaultValue={props.currentTitle}
|
||||||
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
|
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
placeholder={`Search for a${props.type.toLowerCase()[0] === "a" ? "n" : ""
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
|
||||||
/>
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
|
onFocus={(e) => { setQuery(e.target.value); e.target.select()}}
|
||||||
{ mergeTarget.id !== 0 ?
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
<>
|
/>
|
||||||
{mergeOrderReversed ?
|
<SearchResults selectorMode data={data} onSelect={toggleSelect} />
|
||||||
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
|
{mergeTarget.id !== 0 ? (
|
||||||
:
|
<>
|
||||||
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
|
{mergeOrderReversed ? (
|
||||||
}
|
<p className="mt-5">
|
||||||
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
|
<strong>{props.currentTitle}</strong> will be merged into{" "}
|
||||||
<div className="flex gap-2 mt-3">
|
<strong>{mergeTarget.title}</strong>
|
||||||
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
|
</p>
|
||||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
) : (
|
||||||
|
<p className="mt-5">
|
||||||
|
<strong>{mergeTarget.title}</strong> will be merged into{" "}
|
||||||
|
<strong>{props.currentTitle}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)"
|
||||||
|
onClick={doMerge}
|
||||||
|
>
|
||||||
|
Merge Items
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="reverse-merge-order"
|
||||||
|
checked={mergeOrderReversed}
|
||||||
|
onChange={() => setMergeOrderReversed(!mergeOrderReversed)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||||
</div>
|
</div>
|
||||||
</> :
|
{(props.type.toLowerCase() === "album" ||
|
||||||
''}
|
props.type.toLowerCase() === "artist") && (
|
||||||
</div>
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="replace-image"
|
||||||
|
checked={replaceImage}
|
||||||
|
onChange={() => setReplaceImage(!replaceImage)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="replace-image">Replace image</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,34 @@ export function Modal({
|
||||||
}
|
}
|
||||||
}, [isOpen, shouldRender]);
|
}, [isOpen, shouldRender]);
|
||||||
|
|
||||||
// Close on Escape key
|
// Handle keyboard events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
// Close on Escape key
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
// Trap tab navigation to the modal
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
if (modalRef.current) {
|
||||||
|
const focusableEls = modalRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
const firstEl = focusableEls[0];
|
||||||
|
const lastEl = focusableEls[focusableEls.length - 1];
|
||||||
|
const activeEl = document.activeElement
|
||||||
|
|
||||||
|
if (e.shiftKey && activeEl === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl.focus();
|
||||||
|
} else if (!e.shiftKey && activeEl === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
} else if (!Array.from(focusableEls).find(node => node.isEqualNode(activeEl))) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
|
@ -70,13 +94,13 @@ export function Modal({
|
||||||
}`}
|
}`}
|
||||||
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
|
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
|
||||||
>
|
>
|
||||||
|
{children}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
|
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
🞪
|
🞪
|
||||||
</button>
|
</button>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -4,57 +4,57 @@ import { search, type SearchResponse } from "api/api";
|
||||||
import SearchResults from "../SearchResults";
|
import SearchResults from "../SearchResults";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: Function
|
setOpen: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchModal({ open, setOpen }: Props) {
|
export default function SearchModal({ open, setOpen }: Props) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState("");
|
||||||
const [data, setData] = useState<SearchResponse>();
|
const [data, setData] = useState<SearchResponse>();
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||||
|
|
||||||
const closeSearchModal = () => {
|
const closeSearchModal = () => {
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
setQuery('')
|
setQuery("");
|
||||||
setData(undefined)
|
setData(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query);
|
||||||
|
if (query === "") {
|
||||||
|
setData(undefined);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery) {
|
||||||
|
search(debouncedQuery).then((r) => {
|
||||||
|
setData(r);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
const handler = setTimeout(() => {
|
<Modal isOpen={open} onClose={closeSearchModal}>
|
||||||
setDebouncedQuery(query);
|
<h3>Search</h3>
|
||||||
if (query === '') {
|
<div className="flex flex-col items-center">
|
||||||
setData(undefined)
|
<input
|
||||||
}
|
type="text"
|
||||||
}, 300);
|
autoFocus
|
||||||
|
placeholder="Search for an artist, album, or track"
|
||||||
return () => {
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
clearTimeout(handler);
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
};
|
/>
|
||||||
}, [query]);
|
<div className="h-3/4 w-full">
|
||||||
|
<SearchResults data={data} onSelect={closeSearchModal} />
|
||||||
useEffect(() => {
|
</div>
|
||||||
if (debouncedQuery) {
|
</div>
|
||||||
search(debouncedQuery).then((r) => {
|
</Modal>
|
||||||
setData(r);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [debouncedQuery]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={open} onClose={closeSearchModal}>
|
|
||||||
<h2>Search</h2>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
placeholder="Search for an artist, album, or track"
|
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="h-3/4 w-full">
|
|
||||||
<SearchResults data={data} onSelect={closeSearchModal}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
|
||||||
import ThemeHelper from "../../routes/ThemeHelper";
|
import ThemeHelper from "../../routes/ThemeHelper";
|
||||||
import { useAppContext } from "~/providers/AppProvider";
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
import ApiKeysModal from "./ApiKeysModal";
|
import ApiKeysModal from "./ApiKeysModal";
|
||||||
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
import ExportModal from "./ExportModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -19,7 +21,7 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
||||||
const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
|
const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
<Modal h={700} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="Appearance"
|
defaultValue="Appearance"
|
||||||
orientation="vertical" // still vertical, but layout is responsive via Tailwind
|
orientation="vertical" // still vertical, but layout is responsive via Tailwind
|
||||||
|
|
@ -29,9 +31,12 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
||||||
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
||||||
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
||||||
{user && (
|
{user && (
|
||||||
<TabsTrigger className={triggerClasses} value="API Keys">
|
<>
|
||||||
API Keys
|
<TabsTrigger className={triggerClasses} value="API Keys">
|
||||||
</TabsTrigger>
|
API Keys
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className={triggerClasses} value="Export">Export</TabsTrigger>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
||||||
<TabsContent value="API Keys" className={contentClasses}>
|
<TabsContent value="API Keys" className={contentClasses}>
|
||||||
<ApiKeysModal />
|
<ApiKeysModal />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="Export" className={contentClasses}>
|
||||||
|
<ExportModal />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
79
client/app/components/rewind/Rewind.tsx
Normal file
79
client/app/components/rewind/Rewind.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
client/app/components/rewind/RewindStatText.tsx
Normal file
32
client/app/components/rewind/RewindStatText.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
client/app/components/rewind/RewindTopItem.tsx
Normal file
57
client/app/components/rewind/RewindTopItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { ExternalLink, Home, Info } from "lucide-react";
|
import { ExternalLink, History, Home, Info } from "lucide-react";
|
||||||
import SidebarSearch from "./SidebarSearch";
|
import SidebarSearch from "./SidebarSearch";
|
||||||
import SidebarItem from "./SidebarItem";
|
import SidebarItem from "./SidebarItem";
|
||||||
import SidebarSettings from "./SidebarSettings";
|
import SidebarSettings from "./SidebarSettings";
|
||||||
|
import { getRewindParams, getRewindYear } from "~/utils/utils";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const iconSize = 20;
|
const iconSize = 20;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="
|
<div
|
||||||
|
className="
|
||||||
z-50
|
z-50
|
||||||
flex
|
flex
|
||||||
sm:flex-col
|
sm:flex-col
|
||||||
|
|
@ -28,28 +30,44 @@ export default function Sidebar() {
|
||||||
sm:px-1
|
sm:px-1
|
||||||
px-4
|
px-4
|
||||||
bg-(--color-bg)
|
bg-(--color-bg)
|
||||||
">
|
"
|
||||||
<div className="flex gap-4 sm:flex-col">
|
>
|
||||||
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
|
<div className="flex gap-4 sm:flex-col">
|
||||||
<Home size={iconSize} />
|
<SidebarItem
|
||||||
</SidebarItem>
|
space={10}
|
||||||
<SidebarSearch size={iconSize} />
|
to="/"
|
||||||
</div>
|
name="Home"
|
||||||
<div className="flex gap-4 sm:flex-col">
|
onClick={() => {}}
|
||||||
<SidebarItem
|
modal={<></>}
|
||||||
icon
|
>
|
||||||
keyHint={<ExternalLink size={14} />}
|
<Home size={iconSize} />
|
||||||
space={22}
|
</SidebarItem>
|
||||||
externalLink
|
<SidebarSearch size={iconSize} />
|
||||||
to="https://koito.io"
|
<SidebarItem
|
||||||
name="About"
|
space={10}
|
||||||
onClick={() => {}}
|
to="/rewind"
|
||||||
modal={<></>}
|
name="Rewind"
|
||||||
>
|
onClick={() => {}}
|
||||||
<Info size={iconSize} />
|
modal={<></>}
|
||||||
</SidebarItem>
|
>
|
||||||
<SidebarSettings size={iconSize} />
|
<History size={iconSize} />
|
||||||
</div>
|
</SidebarItem>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="flex gap-4 sm:flex-col">
|
||||||
|
<SidebarItem
|
||||||
|
icon
|
||||||
|
keyHint={<ExternalLink size={14} />}
|
||||||
|
space={22}
|
||||||
|
externalLink
|
||||||
|
to="https://koito.io"
|
||||||
|
name="About"
|
||||||
|
onClick={() => {}}
|
||||||
|
modal={<></>}
|
||||||
|
>
|
||||||
|
<Info size={iconSize} />
|
||||||
|
</SidebarItem>
|
||||||
|
<SidebarSettings size={iconSize} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,43 @@
|
||||||
import type { Theme } from "~/providers/ThemeProvider";
|
import type { Theme } from "~/styles/themes.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme: Theme
|
theme: Theme;
|
||||||
setTheme: Function
|
themeName: string;
|
||||||
|
setTheme: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThemeOption({ theme, setTheme }: Props) {
|
export default function ThemeOption({ theme, themeName, setTheme }: Props) {
|
||||||
|
const capitalizeFirstLetter = (s: string) => {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
const capitalizeFirstLetter = (s: string) => {
|
return (
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
<div
|
||||||
}
|
onClick={() => setTheme(themeName)}
|
||||||
|
className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-3 items-center border-2 justify-between"
|
||||||
return (
|
style={{
|
||||||
<div onClick={() => setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
|
background: theme.bg,
|
||||||
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
|
color: theme.fg,
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
borderColor: theme.bgSecondary,
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
}}
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
>
|
||||||
</div>
|
<div className="text-xs sm:text-sm">
|
||||||
)
|
{capitalizeFirstLetter(themeName)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full">
|
||||||
|
<div
|
||||||
|
className="w-2/7 max-w-[50px] h-[30px] rounded-md"
|
||||||
|
style={{ background: theme.bgSecondary }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="w-2/7 max-w-[50px] h-[30px] rounded-md"
|
||||||
|
style={{ background: theme.fgSecondary }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="w-2/7 max-w-[50px] h-[30px] rounded-md"
|
||||||
|
style={{ background: theme.primary }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,36 +1,78 @@
|
||||||
// ThemeSwitcher.tsx
|
import { useState } from "react";
|
||||||
import { useEffect } from 'react';
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import themes from "~/styles/themes.css";
|
||||||
import { themes } from '~/providers/ThemeProvider';
|
import ThemeOption from "./ThemeOption";
|
||||||
import ThemeOption from './ThemeOption';
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
const initialTheme = {
|
||||||
|
bg: "#1e1816",
|
||||||
|
bgSecondary: "#2f2623",
|
||||||
|
bgTertiary: "#453733",
|
||||||
|
fg: "#f8f3ec",
|
||||||
|
fgSecondary: "#d6ccc2",
|
||||||
|
fgTertiary: "#b4a89c",
|
||||||
|
primary: "#f5a97f",
|
||||||
|
primaryDim: "#d88b65",
|
||||||
|
accent: "#f9db6d",
|
||||||
|
accentDim: "#d9bc55",
|
||||||
|
error: "#e26c6a",
|
||||||
|
warning: "#f5b851",
|
||||||
|
success: "#8fc48f",
|
||||||
|
info: "#87b8dd",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||||
|
const [custom, setCustom] = useState(
|
||||||
|
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCustomTheme = () => {
|
||||||
const saved = localStorage.getItem('theme');
|
console.log(custom);
|
||||||
if (saved && saved !== theme) {
|
try {
|
||||||
setTheme(saved);
|
const themeData = JSON.parse(custom);
|
||||||
} else if (!saved) {
|
setCustomTheme(themeData);
|
||||||
localStorage.setItem('theme', theme)
|
setCustom(JSON.stringify(themeData, null, " "));
|
||||||
}
|
console.log(themeData);
|
||||||
}, []);
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if (theme) {
|
<div className="flex flex-col gap-10">
|
||||||
localStorage.setItem('theme', theme)
|
<div>
|
||||||
}
|
<div className="flex items-center gap-3">
|
||||||
}, [theme]);
|
<h3>Select Theme</h3>
|
||||||
|
<div className="mb-3">
|
||||||
return (
|
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
||||||
<>
|
</div>
|
||||||
<h2>Select Theme</h2>
|
|
||||||
<div className="grid grid-cols-2 items-center gap-2">
|
|
||||||
{themes.map((t) => (
|
|
||||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="grid grid-cols-1 sm:grid-cols-2 items-center gap-2">
|
||||||
);
|
{Object.entries(themes).map(([name, themeData]) => (
|
||||||
|
<ThemeOption
|
||||||
|
setTheme={setTheme}
|
||||||
|
key={name}
|
||||||
|
theme={themeData}
|
||||||
|
themeName={name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Use Custom Theme</h3>
|
||||||
|
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||||
|
<textarea
|
||||||
|
name="custom-theme"
|
||||||
|
onChange={(e) => setCustom(e.target.value)}
|
||||||
|
id="custom-theme-input"
|
||||||
|
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
|
||||||
|
value={custom}
|
||||||
|
/>
|
||||||
|
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { User } from "api/api";
|
import { getCfg, type User } from "api/api";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
user: User | null | undefined;
|
user: User | null | undefined;
|
||||||
configurableHomeActivity: boolean;
|
configurableHomeActivity: boolean;
|
||||||
homeItems: number;
|
homeItems: number;
|
||||||
|
defaultTheme: string;
|
||||||
setConfigurableHomeActivity: (value: boolean) => void;
|
setConfigurableHomeActivity: (value: boolean) => void;
|
||||||
setHomeItems: (value: number) => void;
|
setHomeItems: (value: number) => void;
|
||||||
setUsername: (value: string) => void;
|
setUsername: (value: string) => void;
|
||||||
|
|
@ -22,15 +23,19 @@ export const useAppContext = () => {
|
||||||
|
|
||||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [user, setUser] = useState<User | null | undefined>(undefined);
|
const [user, setUser] = useState<User | null | undefined>(undefined);
|
||||||
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
|
const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [configurableHomeActivity, setConfigurableHomeActivity] =
|
||||||
|
useState<boolean>(false);
|
||||||
const [homeItems, setHomeItems] = useState<number>(0);
|
const [homeItems, setHomeItems] = useState<number>(0);
|
||||||
|
|
||||||
const setUsername = (value: string) => {
|
const setUsername = (value: string) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setUser({...user, username: value})
|
setUser({ ...user, username: value });
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/apis/web/v1/user/me")
|
fetch("/apis/web/v1/user/me")
|
||||||
|
|
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|
||||||
setConfigurableHomeActivity(true);
|
setConfigurableHomeActivity(true);
|
||||||
setHomeItems(12);
|
setHomeItems(12);
|
||||||
|
|
||||||
|
getCfg().then((cfg) => {
|
||||||
|
console.log(cfg);
|
||||||
|
if (cfg.default_theme !== "") {
|
||||||
|
setDefaultTheme(cfg.default_theme);
|
||||||
|
} else {
|
||||||
|
setDefaultTheme("yuu");
|
||||||
|
}
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (user === undefined) {
|
// Block rendering the app until config is loaded
|
||||||
|
if (user === undefined || defaultTheme === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
user,
|
user,
|
||||||
configurableHomeActivity,
|
configurableHomeActivity,
|
||||||
homeItems,
|
homeItems,
|
||||||
|
defaultTheme,
|
||||||
setConfigurableHomeActivity,
|
setConfigurableHomeActivity,
|
||||||
setHomeItems,
|
setHomeItems,
|
||||||
setUsername,
|
setUsername,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
return (
|
||||||
|
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,259 +1,135 @@
|
||||||
import { createContext, useEffect, useState, type ReactNode } from 'react';
|
import {
|
||||||
|
createContext,
|
||||||
// a fair number of colors aren't actually used, but i'm keeping
|
useEffect,
|
||||||
// them so that I don't have to worry about colors when adding new ui elements
|
useState,
|
||||||
export type Theme = {
|
useCallback,
|
||||||
name: string,
|
type ReactNode,
|
||||||
bg: string
|
} from "react";
|
||||||
bgSecondary: string
|
import { type Theme, themes } from "~/styles/themes.css";
|
||||||
bgTertiary: string
|
import { themeVars } from "~/styles/vars.css";
|
||||||
fg: string
|
import { useAppContext } from "./AppProvider";
|
||||||
fgSecondary: string
|
|
||||||
fgTertiary: string
|
|
||||||
primary: string
|
|
||||||
primaryDim: string
|
|
||||||
accent: string
|
|
||||||
accentDim: string
|
|
||||||
error: string
|
|
||||||
warning: string
|
|
||||||
info: string
|
|
||||||
success: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themes: Theme[] = [
|
|
||||||
{
|
|
||||||
name: "yuu",
|
|
||||||
bg: "#161312",
|
|
||||||
bgSecondary: "#272120",
|
|
||||||
bgTertiary: "#382F2E",
|
|
||||||
fg: "#faf5f4",
|
|
||||||
fgSecondary: "#CCC7C6",
|
|
||||||
fgTertiary: "#B0A3A1",
|
|
||||||
primary: "#ff826d",
|
|
||||||
primaryDim: "#CE6654",
|
|
||||||
accent: "#464DAE",
|
|
||||||
accentDim: "#393D74",
|
|
||||||
error: "#FF6247",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#3ECE5F",
|
|
||||||
info: "#41C4D8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "varia",
|
|
||||||
bg: "rgb(25, 25, 29)",
|
|
||||||
bgSecondary: "#222222",
|
|
||||||
bgTertiary: "#333333",
|
|
||||||
fg: "#eeeeee",
|
|
||||||
fgSecondary: "#aaaaaa",
|
|
||||||
fgTertiary: "#888888",
|
|
||||||
primary: "rgb(203, 110, 240)",
|
|
||||||
primaryDim: "#c28379",
|
|
||||||
accent: "#f0ad0a",
|
|
||||||
accentDim: "#d08d08",
|
|
||||||
error: "#f44336",
|
|
||||||
warning: "#ff9800",
|
|
||||||
success: "#4caf50",
|
|
||||||
info: "#2196f3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "midnight",
|
|
||||||
bg: "rgb(8, 15, 24)",
|
|
||||||
bgSecondary: "rgb(15, 27, 46)",
|
|
||||||
bgTertiary: "rgb(15, 41, 70)",
|
|
||||||
fg: "#dbdfe7",
|
|
||||||
fgSecondary: "#9ea3a8",
|
|
||||||
fgTertiary: "#74787c",
|
|
||||||
primary: "#1a97eb",
|
|
||||||
primaryDim: "#2680aa",
|
|
||||||
accent: "#f0ad0a",
|
|
||||||
accentDim: "#d08d08",
|
|
||||||
error: "#f44336",
|
|
||||||
warning: "#ff9800",
|
|
||||||
success: "#4caf50",
|
|
||||||
info: "#2196f3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "catppuccin",
|
|
||||||
bg: "#1e1e2e",
|
|
||||||
bgSecondary: "#181825",
|
|
||||||
bgTertiary: "#11111b",
|
|
||||||
fg: "#cdd6f4",
|
|
||||||
fgSecondary: "#a6adc8",
|
|
||||||
fgTertiary: "#9399b2",
|
|
||||||
primary: "#89b4fa",
|
|
||||||
primaryDim: "#739df0",
|
|
||||||
accent: "#f38ba8",
|
|
||||||
accentDim: "#d67b94",
|
|
||||||
error: "#f38ba8",
|
|
||||||
warning: "#f9e2af",
|
|
||||||
success: "#a6e3a1",
|
|
||||||
info: "#89dceb",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "autumn",
|
|
||||||
bg: "rgb(44, 25, 18)",
|
|
||||||
bgSecondary: "rgb(70, 40, 18)",
|
|
||||||
bgTertiary: "#4b2f1c",
|
|
||||||
fg: "#fef9f3",
|
|
||||||
fgSecondary: "#dbc6b0",
|
|
||||||
fgTertiary: "#a3917a",
|
|
||||||
primary: "#d97706",
|
|
||||||
primaryDim: "#b45309",
|
|
||||||
accent: "#8c4c28",
|
|
||||||
accentDim: "#6b3b1f",
|
|
||||||
error: "#d1433f",
|
|
||||||
warning: "#e38b29",
|
|
||||||
success: "#6b8e23",
|
|
||||||
info: "#c084fc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "black",
|
|
||||||
bg: "#000000",
|
|
||||||
bgSecondary: "#1a1a1a",
|
|
||||||
bgTertiary: "#2a2a2a",
|
|
||||||
fg: "#dddddd",
|
|
||||||
fgSecondary: "#aaaaaa",
|
|
||||||
fgTertiary: "#888888",
|
|
||||||
primary: "#08c08c",
|
|
||||||
primaryDim: "#08c08c",
|
|
||||||
accent: "#f0ad0a",
|
|
||||||
accentDim: "#d08d08",
|
|
||||||
error: "#f44336",
|
|
||||||
warning: "#ff9800",
|
|
||||||
success: "#4caf50",
|
|
||||||
info: "#2196f3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wine",
|
|
||||||
bg: "#23181E",
|
|
||||||
bgSecondary: "#2C1C25",
|
|
||||||
bgTertiary: "#422A37",
|
|
||||||
fg: "#FCE0B3",
|
|
||||||
fgSecondary: "#C7AC81",
|
|
||||||
fgTertiary: "#A78E64",
|
|
||||||
primary: "#EA8A64",
|
|
||||||
primaryDim: "#BD7255",
|
|
||||||
accent: "#FAE99B",
|
|
||||||
accentDim: "#C6B464",
|
|
||||||
error: "#fca5a5",
|
|
||||||
warning: "#fde68a",
|
|
||||||
success: "#bbf7d0",
|
|
||||||
info: "#bae6fd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pearl",
|
|
||||||
bg: "#FFFFFF",
|
|
||||||
bgSecondary: "#EEEEEE",
|
|
||||||
bgTertiary: "#E0E0E0",
|
|
||||||
fg: "#333333",
|
|
||||||
fgSecondary: "#555555",
|
|
||||||
fgTertiary: "#777777",
|
|
||||||
primary: "#007BFF",
|
|
||||||
primaryDim: "#0056B3",
|
|
||||||
accent: "#28A745",
|
|
||||||
accentDim: "#1E7E34",
|
|
||||||
error: "#DC3545",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "asuka",
|
|
||||||
bg: "#3B1212",
|
|
||||||
bgSecondary: "#471B1B",
|
|
||||||
bgTertiary: "#020202",
|
|
||||||
fg: "#F1E9E6",
|
|
||||||
fgSecondary: "#CCB6AE",
|
|
||||||
fgTertiary: "#9F8176",
|
|
||||||
primary: "#F1E9E6",
|
|
||||||
primaryDim: "#CCB6AE",
|
|
||||||
accent: "#41CE41",
|
|
||||||
accentDim: "#3BA03B",
|
|
||||||
error: "#DC143C",
|
|
||||||
warning: "#FFD700",
|
|
||||||
success: "#32CD32",
|
|
||||||
info: "#1E90FF",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "urim",
|
|
||||||
bg: "#101713",
|
|
||||||
bgSecondary: "#1B2921",
|
|
||||||
bgTertiary: "#273B30",
|
|
||||||
fg: "#D2E79E",
|
|
||||||
fgSecondary: "#B4DA55",
|
|
||||||
fgTertiary: "#7E9F2A",
|
|
||||||
primary: "#ead500",
|
|
||||||
primaryDim: "#C1B210",
|
|
||||||
accent: "#28A745",
|
|
||||||
accentDim: "#1E7E34",
|
|
||||||
error: "#EE5237",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "match",
|
|
||||||
bg: "#071014",
|
|
||||||
bgSecondary: "#0A181E",
|
|
||||||
bgTertiary: "#112A34",
|
|
||||||
fg: "#ebeaeb",
|
|
||||||
fgSecondary: "#BDBDBD",
|
|
||||||
fgTertiary: "#A2A2A2",
|
|
||||||
primary: "#fda827",
|
|
||||||
primaryDim: "#C78420",
|
|
||||||
accent: "#277CFD",
|
|
||||||
accentDim: "#1F60C1",
|
|
||||||
error: "#F14426",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lemon",
|
|
||||||
bg: "#1a171a",
|
|
||||||
bgSecondary: "#2E272E",
|
|
||||||
bgTertiary: "#443844",
|
|
||||||
fg: "#E6E2DC",
|
|
||||||
fgSecondary: "#B2ACA1",
|
|
||||||
fgTertiary: "#968F82",
|
|
||||||
primary: "#f5c737",
|
|
||||||
primaryDim: "#C29D2F",
|
|
||||||
accent: "#277CFD",
|
|
||||||
accentDim: "#1F60C1",
|
|
||||||
error: "#F14426",
|
|
||||||
warning: "#FFC107",
|
|
||||||
success: "#28A745",
|
|
||||||
info: "#17A2B8",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: string;
|
themeName: string;
|
||||||
|
theme: Theme;
|
||||||
setTheme: (theme: string) => void;
|
setTheme: (theme: string) => void;
|
||||||
|
resetTheme: () => void;
|
||||||
|
setCustomTheme: (theme: Theme) => void;
|
||||||
|
getCustomTheme: () => Theme | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
export function ThemeProvider({
|
function toKebabCase(str: string) {
|
||||||
theme: initialTheme,
|
return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
children,
|
}
|
||||||
}: {
|
|
||||||
theme: string;
|
function applyCustomThemeVars(theme: Theme) {
|
||||||
children: ReactNode;
|
const root = document.documentElement;
|
||||||
}) {
|
for (const [key, value] of Object.entries(theme)) {
|
||||||
const [theme, setTheme] = useState(initialTheme);
|
if (key === "name") continue;
|
||||||
|
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCustomThemeVars() {
|
||||||
|
for (const cssVar of Object.values(themeVars)) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredCustomTheme(): Theme | undefined {
|
||||||
|
const themeStr = localStorage.getItem("custom-theme");
|
||||||
|
if (!themeStr) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(themeStr);
|
||||||
|
const { name, ...theme } = parsed;
|
||||||
|
return theme as Theme;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
let defaultTheme = useAppContext().defaultTheme;
|
||||||
|
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
|
||||||
|
const [themeName, setThemeName] = useState(
|
||||||
|
themes[initialTheme] ? initialTheme : defaultTheme
|
||||||
|
);
|
||||||
|
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
||||||
|
if (initialTheme === "custom") {
|
||||||
|
const customTheme = getStoredCustomTheme();
|
||||||
|
return customTheme || themes[defaultTheme];
|
||||||
|
}
|
||||||
|
return themes[initialTheme] || themes[defaultTheme];
|
||||||
|
});
|
||||||
|
|
||||||
|
const setTheme = (newThemeName: string) => {
|
||||||
|
setThemeName(newThemeName);
|
||||||
|
if (newThemeName === "custom") {
|
||||||
|
const customTheme = getStoredCustomTheme();
|
||||||
|
if (customTheme) {
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
|
} else {
|
||||||
|
// Fallback to default theme if no custom theme found
|
||||||
|
setThemeName(defaultTheme);
|
||||||
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const foundTheme = themes[newThemeName];
|
||||||
|
if (foundTheme) {
|
||||||
|
localStorage.setItem("theme", newThemeName);
|
||||||
|
setCurrentTheme(foundTheme);
|
||||||
|
} else {
|
||||||
|
setTheme(defaultTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTheme = () => {
|
||||||
|
setThemeName(defaultTheme);
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||||
|
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
|
||||||
|
applyCustomThemeVars(customTheme);
|
||||||
|
setThemeName("custom");
|
||||||
|
localStorage.setItem("theme", "custom");
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCustomTheme = (): Theme | undefined => {
|
||||||
|
return getStoredCustomTheme();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme) {
|
const root = document.documentElement;
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
|
root.setAttribute("data-theme", themeName);
|
||||||
|
|
||||||
|
if (themeName === "custom") {
|
||||||
|
applyCustomThemeVars(currentTheme);
|
||||||
|
} else {
|
||||||
|
clearCustomThemeVars();
|
||||||
}
|
}
|
||||||
}, [theme]);
|
}, [themeName, currentTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
themeName,
|
||||||
|
theme: currentTheme,
|
||||||
|
setTheme,
|
||||||
|
resetTheme,
|
||||||
|
setCustomTheme,
|
||||||
|
getCustomTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ThemeContext }
|
export { ThemeContext };
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,19 @@ import {
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import './themes.css'
|
import "./themes.css";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ThemeProvider } from './providers/ThemeProvider';
|
import { ThemeProvider } from "./providers/ThemeProvider";
|
||||||
import Sidebar from "./components/sidebar/Sidebar";
|
import Sidebar from "./components/sidebar/Sidebar";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "./components/Footer";
|
||||||
import { AppProvider } from "./providers/AppProvider";
|
import { AppProvider } from "./providers/AppProvider";
|
||||||
|
import { initTimezoneCookie } from "./tz";
|
||||||
|
|
||||||
|
initTimezoneCookie();
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
|
@ -35,14 +38,23 @@ export const links: Route.LinksFunction = () => [
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" style={{backgroundColor: 'black'}}>
|
<html lang="en" style={{ backgroundColor: "black" }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="/favicon-96x96.png"
|
||||||
|
sizes="96x96"
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
<meta name="apple-mobile-web-app-title" content="Koito" />
|
<meta name="apple-mobile-web-app-title" content="Koito" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<Meta />
|
<Meta />
|
||||||
|
|
@ -58,81 +70,73 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
let theme = localStorage.getItem('theme') ?? 'yuu'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="flex-col flex sm:flex-row">
|
<div className="flex-col flex sm:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HydrateFallback() {
|
export function HydrateFallback() {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
let message = "Oops!";
|
let message = "Oops!";
|
||||||
let details = "An unexpected error occurred.";
|
let details = "An unexpected error occurred.";
|
||||||
let stack: string | undefined;
|
let stack: string | undefined;
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
message = error.status === 404 ? "404" : "Error";
|
message = error.status === 404 ? "404" : "Error";
|
||||||
details = error.status === 404
|
details =
|
||||||
|
error.status === 404
|
||||||
? "The requested page could not be found."
|
? "The requested page could not be found."
|
||||||
: error.statusText || details;
|
: error.statusText || details;
|
||||||
} else if (import.meta.env.DEV && error instanceof Error) {
|
} else if (import.meta.env.DEV && error instanceof Error) {
|
||||||
details = error.message;
|
details = error.message;
|
||||||
stack = error.stack;
|
stack = error.stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
let theme = 'yuu'
|
const title = `${message} - Koito`;
|
||||||
try {
|
|
||||||
theme = localStorage.getItem('theme') ?? theme
|
|
||||||
} catch(err) {
|
|
||||||
console.log(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = `${message} - Koito`
|
return (
|
||||||
|
<AppProvider>
|
||||||
return (
|
<ThemeProvider>
|
||||||
<AppProvider>
|
<title>{title}</title>
|
||||||
<ThemeProvider theme={theme}>
|
<Sidebar />
|
||||||
<title>{title}</title>
|
<div className="flex">
|
||||||
<div className="flex">
|
<div className="w-full flex flex-col">
|
||||||
<Sidebar />
|
<main className="pt-16 p-4 mx-auto flex-grow">
|
||||||
<div className="w-full flex flex-col">
|
<div className="md:flex gap-4">
|
||||||
<main className="pt-16 p-4 container mx-auto flex-grow">
|
<img className="w-[200px] rounded mb-3" src="../yuu.jpg" />
|
||||||
<div className="flex gap-4 items-end">
|
<div>
|
||||||
<img className="w-[200px] rounded" src="../yuu.jpg" />
|
<h1>{message}</h1>
|
||||||
<div>
|
<p>{details}</p>
|
||||||
<h1>{message}</h1>
|
|
||||||
<p>{details}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{stack && (
|
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</div>
|
||||||
</AppProvider>
|
{stack && (
|
||||||
);
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
|
<code>{stack}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
index("routes/Home.tsx"),
|
index("routes/Home.tsx"),
|
||||||
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
|
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
|
||||||
route("/album/:id", "routes/MediaItems/Album.tsx"),
|
route("/album/:id", "routes/MediaItems/Album.tsx"),
|
||||||
route("/track/:id", "routes/MediaItems/Track.tsx"),
|
route("/track/:id", "routes/MediaItems/Track.tsx"),
|
||||||
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
|
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
|
||||||
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
|
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
|
||||||
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
|
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
|
||||||
route("/listens", "routes/Charts/Listens.tsx"),
|
route("/listens", "routes/Charts/Listens.tsx"),
|
||||||
route("/theme-helper", "routes/ThemeHelper.tsx"),
|
route("/rewind", "routes/RewindPage.tsx"),
|
||||||
|
route("/theme-helper", "routes/ThemeHelper.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import TopItemList from "~/components/TopItemList";
|
import TopItemList from "~/components/TopItemList";
|
||||||
import ChartLayout from "./ChartLayout";
|
import ChartLayout from "./ChartLayout";
|
||||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
import { type Album, type PaginatedResponse } from "api/api";
|
import { type Album, type PaginatedResponse, type Ranked } from "api/api";
|
||||||
|
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
||||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumChart() {
|
export default function AlbumChart() {
|
||||||
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
|
const { top_albums: initialData } = useLoaderData<{
|
||||||
|
top_albums: PaginatedResponse<Ranked<Album>>;
|
||||||
|
}>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
|
|
@ -28,26 +30,35 @@ export default function AlbumChart() {
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
endpoint="chart/top-albums"
|
endpoint="chart/top-albums"
|
||||||
render={({ data, page, onNext, onPrev }) => (
|
render={({ data, page, onNext, onPrev }) => (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5 w-full">
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
Next
|
className="default"
|
||||||
</button>
|
onClick={onNext}
|
||||||
</div>
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<TopItemList
|
<TopItemList
|
||||||
|
ranked
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
className="w-[400px] sm:w-[600px]"
|
className="w-11/12 sm:w-[600px]"
|
||||||
type="album"
|
type="album"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import TopItemList from "~/components/TopItemList";
|
import TopItemList from "~/components/TopItemList";
|
||||||
import ChartLayout from "./ChartLayout";
|
import ChartLayout from "./ChartLayout";
|
||||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
import { type Album, type PaginatedResponse } from "api/api";
|
import { type Album, type PaginatedResponse, type Ranked } from "api/api";
|
||||||
|
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
||||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Artist() {
|
export default function Artist() {
|
||||||
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
|
const { top_artists: initialData } = useLoaderData<{
|
||||||
|
top_artists: PaginatedResponse<Ranked<Album>>;
|
||||||
|
}>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
|
|
@ -28,26 +30,35 @@ export default function Artist() {
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
endpoint="chart/top-artists"
|
endpoint="chart/top-artists"
|
||||||
render={({ data, page, onNext, onPrev }) => (
|
render={({ data, page, onNext, onPrev }) => (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5 w-full">
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
Next
|
className="default"
|
||||||
</button>
|
onClick={onNext}
|
||||||
</div>
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<TopItemList
|
<TopItemList
|
||||||
|
ranked
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
className="w-[400px] sm:w-[600px]"
|
className="w-11/12 sm:w-[600px]"
|
||||||
type="artist"
|
type="artist"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,264 +1,272 @@
|
||||||
import {
|
import { useFetcher, useLocation, useNavigate } from "react-router";
|
||||||
useFetcher,
|
import { useEffect, useState } from "react";
|
||||||
useLocation,
|
import { average } from "color.js";
|
||||||
useNavigate,
|
import { imageUrl, type PaginatedResponse } from "api/api";
|
||||||
} from "react-router"
|
import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { average } from "color.js"
|
|
||||||
import { imageUrl, type PaginatedResponse } from "api/api"
|
|
||||||
import PeriodSelector from "~/components/PeriodSelector"
|
|
||||||
|
|
||||||
interface ChartLayoutProps<T> {
|
interface ChartLayoutProps<T> {
|
||||||
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
|
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played";
|
||||||
initialData: PaginatedResponse<T>
|
initialData: PaginatedResponse<T>;
|
||||||
endpoint: string
|
endpoint: string;
|
||||||
render: (opts: {
|
render: (opts: {
|
||||||
data: PaginatedResponse<T>
|
data: PaginatedResponse<T>;
|
||||||
page: number
|
page: number;
|
||||||
onNext: () => void
|
onNext: () => void;
|
||||||
onPrev: () => void
|
onPrev: () => void;
|
||||||
}) => React.ReactNode
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChartLayout<T>({
|
export default function ChartLayout<T>({
|
||||||
title,
|
title,
|
||||||
initialData,
|
initialData,
|
||||||
endpoint,
|
endpoint,
|
||||||
render,
|
render,
|
||||||
}: ChartLayoutProps<T>) {
|
}: ChartLayoutProps<T>) {
|
||||||
const pgTitle = `${title} - Koito`
|
const pgTitle = `${title} - Koito`;
|
||||||
|
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const currentParams = new URLSearchParams(location.search)
|
const currentParams = new URLSearchParams(location.search);
|
||||||
const currentPage = parseInt(currentParams.get("page") || "1", 10)
|
const currentPage = parseInt(currentParams.get("page") || "1", 10);
|
||||||
|
|
||||||
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
||||||
? fetcher.data[endpoint]
|
? fetcher.data[endpoint]
|
||||||
: initialData
|
: initialData;
|
||||||
|
|
||||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)")
|
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((data?.items?.length ?? 0) === 0) return
|
if ((data?.items?.length ?? 0) === 0) return;
|
||||||
|
|
||||||
const img = (data.items[0] as any)?.image
|
const img = (data.items[0] as any)?.item?.image;
|
||||||
if (!img) return
|
if (!img) return;
|
||||||
|
|
||||||
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
||||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
|
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||||
})
|
});
|
||||||
}, [data])
|
}, [data]);
|
||||||
|
|
||||||
const period = currentParams.get("period") ?? "day"
|
const period = currentParams.get("period") ?? "day";
|
||||||
const year = currentParams.get("year")
|
const year = currentParams.get("year");
|
||||||
const month = currentParams.get("month")
|
const month = currentParams.get("month");
|
||||||
const week = currentParams.get("week")
|
const week = currentParams.get("week");
|
||||||
|
|
||||||
const updateParams = (params: Record<string, string | null>) => {
|
const updateParams = (params: Record<string, string | null>) => {
|
||||||
const nextParams = new URLSearchParams(location.search)
|
const nextParams = new URLSearchParams(location.search);
|
||||||
|
|
||||||
for (const key in params) {
|
for (const key in params) {
|
||||||
const val = params[key]
|
const val = params[key];
|
||||||
if (val !== null) {
|
if (val !== null) {
|
||||||
nextParams.set(key, val)
|
nextParams.set(key, val);
|
||||||
} else {
|
} else {
|
||||||
nextParams.delete(key)
|
nextParams.delete(key);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const url = `/${endpoint}?${nextParams.toString()}`
|
|
||||||
navigate(url, { replace: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSetPeriod = (p: string) => {
|
const url = `/${endpoint}?${nextParams.toString()}`;
|
||||||
updateParams({
|
navigate(url, { replace: false });
|
||||||
period: p,
|
};
|
||||||
page: "1",
|
|
||||||
year: null,
|
|
||||||
month: null,
|
|
||||||
week: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleSetYear = (val: string) => {
|
|
||||||
if (val == "") {
|
|
||||||
updateParams({
|
|
||||||
period: period,
|
|
||||||
page: "1",
|
|
||||||
year: null,
|
|
||||||
month: null,
|
|
||||||
week: null
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateParams({
|
|
||||||
period: null,
|
|
||||||
page: "1",
|
|
||||||
year: val,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleSetMonth = (val: string) => {
|
|
||||||
updateParams({
|
|
||||||
period: null,
|
|
||||||
page: "1",
|
|
||||||
year: year ?? new Date().getFullYear().toString(),
|
|
||||||
month: val,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleSetWeek = (val: string) => {
|
|
||||||
updateParams({
|
|
||||||
period: null,
|
|
||||||
page: "1",
|
|
||||||
year: year ?? new Date().getFullYear().toString(),
|
|
||||||
month: null,
|
|
||||||
week: val,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSetPeriod = (p: string) => {
|
||||||
fetcher.load(`/${endpoint}?${currentParams.toString()}`)
|
updateParams({
|
||||||
}, [location.search])
|
period: p,
|
||||||
|
page: "1",
|
||||||
|
year: null,
|
||||||
|
month: null,
|
||||||
|
week: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleSetYear = (val: string) => {
|
||||||
|
if (val == "") {
|
||||||
|
updateParams({
|
||||||
|
period: period,
|
||||||
|
page: "1",
|
||||||
|
year: null,
|
||||||
|
month: null,
|
||||||
|
week: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateParams({
|
||||||
|
period: null,
|
||||||
|
page: "1",
|
||||||
|
year: val,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleSetMonth = (val: string) => {
|
||||||
|
updateParams({
|
||||||
|
period: null,
|
||||||
|
page: "1",
|
||||||
|
year: year ?? new Date().getFullYear().toString(),
|
||||||
|
month: val,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleSetWeek = (val: string) => {
|
||||||
|
updateParams({
|
||||||
|
period: null,
|
||||||
|
page: "1",
|
||||||
|
year: year ?? new Date().getFullYear().toString(),
|
||||||
|
month: null,
|
||||||
|
week: val,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const setPage = (nextPage: number) => {
|
useEffect(() => {
|
||||||
const nextParams = new URLSearchParams(location.search)
|
fetcher.load(`/${endpoint}?${currentParams.toString()}`);
|
||||||
nextParams.set("page", String(nextPage))
|
}, [location.search]);
|
||||||
const url = `/${endpoint}?${nextParams.toString()}`
|
|
||||||
fetcher.load(url)
|
|
||||||
navigate(url, { replace: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNextPage = () => setPage(currentPage + 1)
|
const setPage = (nextPage: number) => {
|
||||||
const handlePrevPage = () => setPage(currentPage - 1)
|
const nextParams = new URLSearchParams(location.search);
|
||||||
|
nextParams.set("page", String(nextPage));
|
||||||
|
const url = `/${endpoint}?${nextParams.toString()}`;
|
||||||
|
fetcher.load(url);
|
||||||
|
navigate(url, { replace: false });
|
||||||
|
};
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`)
|
const handleNextPage = () => setPage(currentPage + 1);
|
||||||
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`)
|
const handlePrevPage = () => setPage(currentPage - 1);
|
||||||
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`)
|
|
||||||
|
|
||||||
const getDateRange = (): string => {
|
const yearOptions = Array.from(
|
||||||
let from: Date
|
{ length: 10 },
|
||||||
let to: Date
|
(_, i) => `${new Date().getFullYear() - i}`
|
||||||
|
);
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
|
||||||
|
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`);
|
||||||
|
|
||||||
const now = new Date()
|
const getDateRange = (): string => {
|
||||||
const currentYear = now.getFullYear()
|
let from: Date;
|
||||||
const currentMonth = now.getMonth() // 0-indexed
|
let to: Date;
|
||||||
const currentDate = now.getDate()
|
|
||||||
|
|
||||||
if (year && month) {
|
const now = new Date();
|
||||||
from = new Date(parseInt(year), parseInt(month) - 1, 1)
|
const currentYear = now.getFullYear();
|
||||||
to = new Date(from)
|
const currentMonth = now.getMonth(); // 0-indexed
|
||||||
to.setMonth(from.getMonth() + 1)
|
const currentDate = now.getDate();
|
||||||
to.setDate(0)
|
|
||||||
} else if (year && week) {
|
|
||||||
const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
|
|
||||||
const weekNumber = parseInt(week)
|
|
||||||
from = new Date(base)
|
|
||||||
from.setDate(base.getDate() + (weekNumber - 1) * 7)
|
|
||||||
to = new Date(from)
|
|
||||||
to.setDate(from.getDate() + 6)
|
|
||||||
} else if (year) {
|
|
||||||
from = new Date(parseInt(year), 0, 1)
|
|
||||||
to = new Date(parseInt(year), 11, 31)
|
|
||||||
} else {
|
|
||||||
switch (period) {
|
|
||||||
case "day":
|
|
||||||
from = new Date(now)
|
|
||||||
to = new Date(now)
|
|
||||||
break
|
|
||||||
case "week":
|
|
||||||
to = new Date(now)
|
|
||||||
from = new Date(now)
|
|
||||||
from.setDate(to.getDate() - 6)
|
|
||||||
break
|
|
||||||
case "month":
|
|
||||||
to = new Date(now)
|
|
||||||
from = new Date(now)
|
|
||||||
if (currentMonth === 0) {
|
|
||||||
from = new Date(currentYear - 1, 11, currentDate)
|
|
||||||
} else {
|
|
||||||
from = new Date(currentYear, currentMonth - 1, currentDate)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case "year":
|
|
||||||
to = new Date(now)
|
|
||||||
from = new Date(currentYear - 1, currentMonth, currentDate)
|
|
||||||
break
|
|
||||||
case "all_time":
|
|
||||||
return "All Time"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
if (year && month) {
|
||||||
year: "numeric",
|
from = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||||
month: "long",
|
to = new Date(from);
|
||||||
day: "numeric",
|
to.setMonth(from.getMonth() + 1);
|
||||||
})
|
to.setDate(0);
|
||||||
|
} else if (year && week) {
|
||||||
return `${formatter.format(from)} - ${formatter.format(to)}`
|
const base = new Date(parseInt(year), 0, 1); // Jan 1 of the year
|
||||||
|
const weekNumber = parseInt(week);
|
||||||
|
from = new Date(base);
|
||||||
|
from.setDate(base.getDate() + (weekNumber - 1) * 7);
|
||||||
|
to = new Date(from);
|
||||||
|
to.setDate(from.getDate() + 6);
|
||||||
|
} else if (year) {
|
||||||
|
from = new Date(parseInt(year), 0, 1);
|
||||||
|
to = new Date(parseInt(year), 11, 31);
|
||||||
|
} else {
|
||||||
|
switch (period) {
|
||||||
|
case "day":
|
||||||
|
from = new Date(now);
|
||||||
|
to = new Date(now);
|
||||||
|
break;
|
||||||
|
case "week":
|
||||||
|
to = new Date(now);
|
||||||
|
from = new Date(now);
|
||||||
|
from.setDate(to.getDate() - 6);
|
||||||
|
break;
|
||||||
|
case "month":
|
||||||
|
to = new Date(now);
|
||||||
|
from = new Date(now);
|
||||||
|
if (currentMonth === 0) {
|
||||||
|
from = new Date(currentYear - 1, 11, currentDate);
|
||||||
|
} else {
|
||||||
|
from = new Date(currentYear, currentMonth - 1, currentDate);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "year":
|
||||||
|
to = new Date(now);
|
||||||
|
from = new Date(currentYear - 1, currentMonth, currentDate);
|
||||||
|
break;
|
||||||
|
case "all_time":
|
||||||
|
return "All Time";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return `${formatter.format(from)} - ${formatter.format(to)}`;
|
||||||
<div
|
};
|
||||||
className="w-full min-h-screen"
|
|
||||||
style={{
|
return (
|
||||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
|
<div
|
||||||
transition: "1000",
|
className="w-full min-h-screen"
|
||||||
}}
|
style={{
|
||||||
>
|
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
|
||||||
<title>{pgTitle}</title>
|
transition: "1000",
|
||||||
<meta property="og:title" content={pgTitle} />
|
}}
|
||||||
<meta name="description" content={pgTitle} />
|
>
|
||||||
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
<title>{pgTitle}</title>
|
||||||
<h1>{title}</h1>
|
<meta property="og:title" content={pgTitle} />
|
||||||
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
<meta name="description" content={pgTitle} />
|
||||||
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
|
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
||||||
<div className="flex gap-5">
|
<h1>{title}</h1>
|
||||||
<select
|
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
||||||
value={year ?? ""}
|
<PeriodSelector
|
||||||
onChange={(e) => handleSetYear(e.target.value)}
|
current={period}
|
||||||
className="px-2 py-1 rounded border border-gray-400"
|
setter={handleSetPeriod}
|
||||||
>
|
disableCache
|
||||||
<option value="">Year</option>
|
/>
|
||||||
{yearOptions.map((y) => (
|
<div className="flex gap-5">
|
||||||
<option key={y} value={y}>{y}</option>
|
<select
|
||||||
))}
|
value={year ?? ""}
|
||||||
</select>
|
onChange={(e) => handleSetYear(e.target.value)}
|
||||||
<select
|
className="px-2 py-1 rounded border border-gray-400"
|
||||||
value={month ?? ""}
|
>
|
||||||
onChange={(e) => handleSetMonth(e.target.value)}
|
<option value="">Year</option>
|
||||||
className="px-2 py-1 rounded border border-gray-400"
|
{yearOptions.map((y) => (
|
||||||
>
|
<option key={y} value={y}>
|
||||||
<option value="">Month</option>
|
{y}
|
||||||
{monthOptions.map((m) => (
|
</option>
|
||||||
<option key={m} value={m}>{m}</option>
|
))}
|
||||||
))}
|
</select>
|
||||||
</select>
|
<select
|
||||||
<select
|
value={month ?? ""}
|
||||||
value={week ?? ""}
|
onChange={(e) => handleSetMonth(e.target.value)}
|
||||||
onChange={(e) => handleSetWeek(e.target.value)}
|
className="px-2 py-1 rounded border border-gray-400"
|
||||||
className="px-2 py-1 rounded border border-gray-400"
|
>
|
||||||
>
|
<option value="">Month</option>
|
||||||
<option value="">Week</option>
|
{monthOptions.map((m) => (
|
||||||
{weekOptions.map((w) => (
|
<option key={m} value={m}>
|
||||||
<option key={w} value={w}>{w}</option>
|
{m}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
<select
|
||||||
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
value={week ?? ""}
|
||||||
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
|
onChange={(e) => handleSetWeek(e.target.value)}
|
||||||
{render({
|
className="px-2 py-1 rounded border border-gray-400"
|
||||||
data,
|
>
|
||||||
page: currentPage,
|
<option value="">Week</option>
|
||||||
onNext: handleNextPage,
|
{weekOptions.map((w) => (
|
||||||
onPrev: handlePrevPage,
|
<option key={w} value={w}>
|
||||||
})}
|
{w}
|
||||||
</div>
|
</option>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</select>
|
||||||
)
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
||||||
|
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
|
||||||
|
{render({
|
||||||
|
data,
|
||||||
|
page: currentPage,
|
||||||
|
onNext: handleNextPage,
|
||||||
|
onPrev: handlePrevPage,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { deleteListen, type Listen, type PaginatedResponse } from "api/api";
|
||||||
import { timeSince } from "~/utils/utils";
|
import { timeSince } from "~/utils/utils";
|
||||||
import ArtistLinks from "~/components/ArtistLinks";
|
import ArtistLinks from "~/components/ArtistLinks";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
|
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
@ -25,6 +26,7 @@ export default function Listens() {
|
||||||
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
||||||
|
|
||||||
const [items, setItems] = useState<Listen[] | null>(null)
|
const [items, setItems] = useState<Listen[] | null>(null)
|
||||||
|
const { user } = useAppContext()
|
||||||
|
|
||||||
const handleDelete = async (listen: Listen) => {
|
const handleDelete = async (listen: Listen) => {
|
||||||
if (!initialData) return
|
if (!initialData) return
|
||||||
|
|
@ -61,11 +63,12 @@ export default function Listens() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{listens.map((item) => (
|
{listens.map((item) => (
|
||||||
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
||||||
<td className="w-[1px] pr-2 align-middle">
|
<td className="w-[17px] pr-2 align-middle">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item)}
|
onClick={() => handleDelete(item)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
|
hidden={user === null || user === undefined}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import TopItemList from "~/components/TopItemList";
|
import TopItemList from "~/components/TopItemList";
|
||||||
import ChartLayout from "./ChartLayout";
|
import ChartLayout from "./ChartLayout";
|
||||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||||
import { type Album, type PaginatedResponse } from "api/api";
|
import { type Track, type PaginatedResponse, type Ranked } from "api/api";
|
||||||
|
|
||||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = url.searchParams.get("page") || "0";
|
const page = url.searchParams.get("page") || "0";
|
||||||
url.searchParams.set('page', page)
|
url.searchParams.set("page", page);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
||||||
|
|
@ -15,12 +15,14 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||||
throw new Response("Failed to load top tracks", { status: 500 });
|
throw new Response("Failed to load top tracks", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const top_tracks: PaginatedResponse<Album> = await res.json();
|
const top_tracks: PaginatedResponse<Track> = await res.json();
|
||||||
return { top_tracks };
|
return { top_tracks };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TrackChart() {
|
export default function TrackChart() {
|
||||||
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
|
const { top_tracks: initialData } = useLoaderData<{
|
||||||
|
top_tracks: PaginatedResponse<Ranked<Track>>;
|
||||||
|
}>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartLayout
|
<ChartLayout
|
||||||
|
|
@ -28,26 +30,35 @@ export default function TrackChart() {
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
endpoint="chart/top-tracks"
|
endpoint="chart/top-tracks"
|
||||||
render={({ data, page, onNext, onPrev }) => (
|
render={({ data, page, onNext, onPrev }) => (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5 w-full">
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
Next
|
className="default"
|
||||||
</button>
|
onClick={onNext}
|
||||||
</div>
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<TopItemList
|
<TopItemList
|
||||||
|
ranked
|
||||||
separators
|
separators
|
||||||
data={data}
|
data={data}
|
||||||
className="w-[400px] sm:w-[600px]"
|
className="w-11/12 sm:w-[600px]"
|
||||||
type="track"
|
type="track"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-15 mx-auto">
|
<div className="flex gap-15 mx-auto">
|
||||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
<button
|
||||||
|
className="default"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!data.has_next_page}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,30 @@ import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import { useAppContext } from "~/providers/AppProvider";
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [
|
return [{ title: "Koito" }, { name: "description", content: "Koito" }];
|
||||||
{ title: "Koito" },
|
|
||||||
{ name: "description", content: "Koito" },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [period, setPeriod] = useState('week')
|
const [period, setPeriod] = useState("week");
|
||||||
|
|
||||||
const { homeItems } = useAppContext();
|
const { homeItems } = useAppContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-grow justify-center pb-4">
|
<main className="flex flex-grow justify-center pb-4 w-full bg-linear-to-b to-(--color-bg) from-(--color-bg-secondary) to-60%">
|
||||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
|
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 sm:mt-20 mt-10">
|
||||||
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
|
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
|
||||||
<AllTimeStats />
|
<AllTimeStats />
|
||||||
<ActivityGrid />
|
<ActivityGrid configurable />
|
||||||
</div>
|
</div>
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
|
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
|
||||||
<TopArtists period={period} limit={homeItems} />
|
<TopArtists period={period} limit={homeItems} />
|
||||||
<TopAlbums period={period} limit={homeItems} />
|
<TopAlbums period={period} limit={homeItems} />
|
||||||
<TopTracks period={period} limit={homeItems} />
|
<TopTracks period={period} limit={homeItems} />
|
||||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
<LastPlays
|
||||||
|
showNowPlaying={true}
|
||||||
|
limit={Math.floor(homeItems * 2.7)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import LastPlays from "~/components/LastPlays";
|
||||||
import PeriodSelector from "~/components/PeriodSelector";
|
import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
import InterestGraph from "~/components/InterestGraph";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||||
|
|
@ -18,40 +20,62 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
|
|
||||||
export default function Album() {
|
export default function Album() {
|
||||||
const album = useLoaderData() as Album;
|
const album = useLoaderData() as Album;
|
||||||
const [period, setPeriod] = useState('week')
|
const [period, setPeriod] = useState("week");
|
||||||
|
|
||||||
console.log(album)
|
console.log(album);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaLayout type="Album"
|
<MediaLayout
|
||||||
title={album.title}
|
type="Album"
|
||||||
img={album.image}
|
title={album.title}
|
||||||
id={album.id}
|
img={album.image}
|
||||||
musicbrainzId={album.musicbrainz_id}
|
id={album.id}
|
||||||
imgItemId={album.id}
|
rank={album.all_time_rank}
|
||||||
mergeFunc={mergeAlbums}
|
musicbrainzId={album.musicbrainz_id}
|
||||||
mergeCleanerFunc={(r, id) => {
|
imgItemId={album.id}
|
||||||
r.artists = []
|
mergeFunc={mergeAlbums}
|
||||||
r.tracks = []
|
mergeCleanerFunc={(r, id) => {
|
||||||
for (let i = 0; i < r.albums.length; i ++) {
|
r.artists = [];
|
||||||
if (r.albums[i].id === id) {
|
r.tracks = [];
|
||||||
delete r.albums[i]
|
for (let i = 0; i < r.albums.length; i++) {
|
||||||
}
|
if (r.albums[i].id === id) {
|
||||||
}
|
delete r.albums[i];
|
||||||
return r
|
}
|
||||||
}}
|
}
|
||||||
subContent={<>
|
return r;
|
||||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
}}
|
||||||
</>}
|
subContent={
|
||||||
|
<div className="flex flex-col gap-2 items-start">
|
||||||
|
{album.listen_count !== 0 && (
|
||||||
|
<p>
|
||||||
|
{album.listen_count} play{album.listen_count > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{album.time_listened !== 0 && (
|
||||||
|
<p title={Math.floor(album.time_listened / 60 / 60) + " hours"}>
|
||||||
|
{timeListenedString(album.time_listened)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{album.first_listen > 0 && (
|
||||||
|
<p title={new Date(album.first_listen * 1000).toLocaleString()}>
|
||||||
|
Listening since{" "}
|
||||||
|
{new Date(album.first_listen * 1000).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-20 mt-10">
|
<div className="flex flex-wrap gap-20 mt-10">
|
||||||
<LastPlays limit={30} albumId={album.id} />
|
<LastPlays limit={30} albumId={album.id} />
|
||||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||||
<ActivityGrid autoAdjust configurable albumId={album.id} />
|
<div className="flex flex-col items-start gap-4">
|
||||||
|
<ActivityGrid configurable albumId={album.id} />
|
||||||
|
<InterestGraph albumId={album.id} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</MediaLayout>
|
</MediaLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
import InterestGraph from "~/components/InterestGraph";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||||
|
|
@ -19,48 +21,70 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
|
|
||||||
export default function Artist() {
|
export default function Artist() {
|
||||||
const artist = useLoaderData() as Artist;
|
const artist = useLoaderData() as Artist;
|
||||||
const [period, setPeriod] = useState('week')
|
const [period, setPeriod] = useState("week");
|
||||||
|
|
||||||
// remove canonical name from alias list
|
// remove canonical name from alias list
|
||||||
console.log(artist.aliases)
|
console.log(artist.aliases);
|
||||||
let index = artist.aliases.indexOf(artist.name);
|
let index = artist.aliases.indexOf(artist.name);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
artist.aliases.splice(index, 1);
|
artist.aliases.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaLayout type="Artist"
|
<MediaLayout
|
||||||
title={artist.name}
|
type="Artist"
|
||||||
img={artist.image}
|
title={artist.name}
|
||||||
id={artist.id}
|
img={artist.image}
|
||||||
musicbrainzId={artist.musicbrainz_id}
|
id={artist.id}
|
||||||
imgItemId={artist.id}
|
rank={artist.all_time_rank}
|
||||||
mergeFunc={mergeArtists}
|
musicbrainzId={artist.musicbrainz_id}
|
||||||
mergeCleanerFunc={(r, id) => {
|
imgItemId={artist.id}
|
||||||
r.albums = []
|
mergeFunc={mergeArtists}
|
||||||
r.tracks = []
|
mergeCleanerFunc={(r, id) => {
|
||||||
for (let i = 0; i < r.artists.length; i ++) {
|
r.albums = [];
|
||||||
if (r.artists[i].id === id) {
|
r.tracks = [];
|
||||||
delete r.artists[i]
|
for (let i = 0; i < r.artists.length; i++) {
|
||||||
}
|
if (r.artists[i].id === id) {
|
||||||
}
|
delete r.artists[i];
|
||||||
return r
|
}
|
||||||
}}
|
}
|
||||||
subContent={<>
|
return r;
|
||||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
}}
|
||||||
</>}
|
subContent={
|
||||||
|
<div className="flex flex-col gap-2 items-start">
|
||||||
|
{artist.listen_count && (
|
||||||
|
<p>
|
||||||
|
{artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{artist.time_listened !== 0 && (
|
||||||
|
<p title={Math.floor(artist.time_listened / 60 / 60) + " hours"}>
|
||||||
|
{timeListenedString(artist.time_listened)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{artist.first_listen > 0 && (
|
||||||
|
<p title={new Date(artist.first_listen * 1000).toLocaleString()}>
|
||||||
|
Listening since{" "}
|
||||||
|
{new Date(artist.first_listen * 1000).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-20">
|
<div className="flex flex-col gap-20">
|
||||||
<div className="flex gap-15 mt-10 flex-wrap">
|
<div className="flex gap-15 mt-10 flex-wrap">
|
||||||
<LastPlays limit={20} artistId={artist.id} />
|
<LastPlays limit={20} artistId={artist.id} />
|
||||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||||
<ActivityGrid configurable autoAdjust artistId={artist.id} />
|
<div className="flex flex-col items-start gap-4">
|
||||||
</div>
|
<ActivityGrid configurable artistId={artist.id} />
|
||||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
<InterestGraph artistId={artist.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||||
|
</div>
|
||||||
</MediaLayout>
|
</MediaLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,96 +2,208 @@ import React, { useEffect, useState } from "react";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
import { imageUrl, type SearchResponse } from "api/api";
|
import { imageUrl, type SearchResponse } from "api/api";
|
||||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
|
||||||
import { useAppContext } from "~/providers/AppProvider";
|
import { useAppContext } from "~/providers/AppProvider";
|
||||||
import MergeModal from "~/components/modals/MergeModal";
|
import MergeModal from "~/components/modals/MergeModal";
|
||||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||||
import DeleteModal from "~/components/modals/DeleteModal";
|
import DeleteModal from "~/components/modals/DeleteModal";
|
||||||
import RenameModal from "~/components/modals/RenameModal";
|
import RenameModal from "~/components/modals/EditModal/EditModal";
|
||||||
|
import EditModal from "~/components/modals/EditModal/EditModal";
|
||||||
|
import AddListenModal from "~/components/modals/AddListenModal";
|
||||||
|
import MbzIcon from "~/components/icons/MbzIcon";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
export type MergeFunc = (from: number, to: number) => Promise<Response>
|
export type MergeFunc = (
|
||||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
from: number,
|
||||||
|
to: number,
|
||||||
|
replaceImage: boolean
|
||||||
|
) => Promise<Response>;
|
||||||
|
export type MergeSearchCleanerFunc = (
|
||||||
|
r: SearchResponse,
|
||||||
|
id: number
|
||||||
|
) => SearchResponse;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "Track" | "Album" | "Artist"
|
type: "Track" | "Album" | "Artist";
|
||||||
title: string
|
title: string;
|
||||||
img: string
|
img: string;
|
||||||
id: number
|
id: number;
|
||||||
musicbrainzId: string
|
rank: number;
|
||||||
imgItemId: number
|
musicbrainzId: string;
|
||||||
mergeFunc: MergeFunc
|
imgItemId: number;
|
||||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
mergeFunc: MergeFunc;
|
||||||
children: React.ReactNode
|
mergeCleanerFunc: MergeSearchCleanerFunc;
|
||||||
subContent: React.ReactNode
|
children: React.ReactNode;
|
||||||
|
subContent: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MediaLayout(props: Props) {
|
export default function MediaLayout(props: Props) {
|
||||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||||
const { user } = useAppContext();
|
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
|
||||||
|
const { user } = useAppContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
|
average(imageUrl(props.img, "small"), { amount: 1 }).then((color) => {
|
||||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||||
});
|
});
|
||||||
}, [props.img]);
|
}, [props.img]);
|
||||||
|
|
||||||
const replaceImageCallback = () => {
|
const replaceImageCallback = () => {
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
}
|
};
|
||||||
|
|
||||||
const title = `${props.title} - Koito`
|
const title = `${props.title} - Koito`;
|
||||||
|
|
||||||
const mobileIconSize = 22
|
const mobileIconSize = 22;
|
||||||
const normalIconSize = 30
|
const normalIconSize = 30;
|
||||||
|
|
||||||
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
|
let vw = Math.max(
|
||||||
|
document.documentElement.clientWidth || 0,
|
||||||
|
window.innerWidth || 0
|
||||||
|
);
|
||||||
|
|
||||||
let iconSize = vw > 768 ? normalIconSize : mobileIconSize
|
let iconSize = vw > 768 ? normalIconSize : mobileIconSize;
|
||||||
|
|
||||||
return (
|
console.log("MBZ:", props.musicbrainzId);
|
||||||
<main
|
|
||||||
className="w-full flex flex-col flex-grow"
|
return (
|
||||||
style={{
|
<main
|
||||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
|
className="w-full flex flex-col flex-grow"
|
||||||
transition: '1000',
|
style={{
|
||||||
}}
|
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 700px)`,
|
||||||
>
|
transition: "1000",
|
||||||
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
|
}}
|
||||||
<title>{title}</title>
|
>
|
||||||
<meta property="og:title" content={title} />
|
<ImageDropHandler
|
||||||
<meta
|
itemType={props.type.toLowerCase() === "artist" ? "artist" : "album"}
|
||||||
name="description"
|
onComplete={replaceImageCallback}
|
||||||
content={title}
|
/>
|
||||||
/>
|
<title>{title}</title>
|
||||||
<div className="w-19/20 mx-auto pt-12">
|
<meta property="og:title" content={title} />
|
||||||
<div className="flex gap-8 flex-wrap relative">
|
<meta name="description" content={title} />
|
||||||
<div className="flex flex-col justify-around">
|
<div className="w-19/20 mx-auto pt-12">
|
||||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
|
<div className="flex gap-8 flex-wrap md:flex-nowrap relative">
|
||||||
</div>
|
<div className="flex flex-col justify-around">
|
||||||
<div className="flex flex-col items-start">
|
<img
|
||||||
<h3>{props.type}</h3>
|
style={{ zIndex: 5 }}
|
||||||
<h1>{props.title}</h1>
|
src={imageUrl(props.img, "large")}
|
||||||
{props.subContent}
|
alt={props.title}
|
||||||
</div>
|
className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg"
|
||||||
{ user &&
|
/>
|
||||||
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
</div>
|
||||||
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
<div className="flex flex-col items-start">
|
||||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
<h3>{props.type}</h3>
|
||||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
<div className="flex">
|
||||||
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={iconSize} /></button>
|
<h1>
|
||||||
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
|
{props.title}
|
||||||
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
|
<span className="text-xl font-medium text-(--color-fg-secondary)">
|
||||||
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
|
{" "}
|
||||||
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
|
#{props.rank}
|
||||||
</div>
|
</span>
|
||||||
}
|
</h1>
|
||||||
</div>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
{props.subContent}
|
||||||
);
|
</div>
|
||||||
|
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
||||||
|
{props.musicbrainzId && (
|
||||||
|
<Link
|
||||||
|
title="View on MusicBrainz"
|
||||||
|
target="_blank"
|
||||||
|
to={`https://musicbrainz.org/${props.type.toLowerCase()}/${
|
||||||
|
props.musicbrainzId
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MbzIcon size={iconSize} hover />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
{props.type === "Track" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
title="Add Listen"
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
onClick={() => setAddListenModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<AddListenModal
|
||||||
|
open={addListenModalOpen}
|
||||||
|
setOpen={setAddListenModalOpen}
|
||||||
|
trackid={props.id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
title="Edit Item"
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
onClick={() => setRenameModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Edit size={iconSize} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{props.type !== "Track" && (
|
||||||
|
<button
|
||||||
|
title="Replace Image"
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
onClick={() => setImageModalOpen(true)}
|
||||||
|
>
|
||||||
|
<ImageIcon size={iconSize} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
title="Merge Items"
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
onClick={() => setMergeModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Merge size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Delete Item"
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
onClick={() => setDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<EditModal
|
||||||
|
open={renameModalOpen}
|
||||||
|
setOpen={setRenameModalOpen}
|
||||||
|
type={props.type.toLowerCase()}
|
||||||
|
id={props.id}
|
||||||
|
/>
|
||||||
|
<ImageReplaceModal
|
||||||
|
open={imageModalOpen}
|
||||||
|
setOpen={setImageModalOpen}
|
||||||
|
id={props.imgItemId}
|
||||||
|
musicbrainzId={props.musicbrainzId}
|
||||||
|
type={props.type === "Track" ? "Album" : props.type}
|
||||||
|
/>
|
||||||
|
<MergeModal
|
||||||
|
currentTitle={props.title}
|
||||||
|
mergeFunc={props.mergeFunc}
|
||||||
|
mergeCleanerFunc={props.mergeCleanerFunc}
|
||||||
|
type={props.type}
|
||||||
|
currentId={props.id}
|
||||||
|
open={mergeModalOpen}
|
||||||
|
setOpen={setMergeModalOpen}
|
||||||
|
/>
|
||||||
|
<DeleteModal
|
||||||
|
open={deleteModalOpen}
|
||||||
|
setOpen={setDeleteModalOpen}
|
||||||
|
title={props.title}
|
||||||
|
id={props.id}
|
||||||
|
type={props.type}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,55 +5,86 @@ import LastPlays from "~/components/LastPlays";
|
||||||
import PeriodSelector from "~/components/PeriodSelector";
|
import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
import InterestGraph from "~/components/InterestGraph";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Response("Failed to load track", { status: res.status });
|
throw new Response("Failed to load track", { status: res.status });
|
||||||
}
|
}
|
||||||
const track: Track = await res.json();
|
const track: Track = await res.json();
|
||||||
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
|
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Response("Failed to load album for track", { status: res.status })
|
throw new Response("Failed to load album for track", {
|
||||||
}
|
status: res.status,
|
||||||
const album: Album = await res.json()
|
});
|
||||||
return {track: track, album: album};
|
}
|
||||||
|
const album: Album = await res.json();
|
||||||
|
return { track: track, album: album };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Track() {
|
export default function Track() {
|
||||||
const { track, album } = useLoaderData();
|
const { track, album } = useLoaderData();
|
||||||
const [period, setPeriod] = useState('week')
|
const [period, setPeriod] = useState("week");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaLayout type="Track"
|
<MediaLayout
|
||||||
title={track.title}
|
type="Track"
|
||||||
img={track.image}
|
title={track.title}
|
||||||
id={track.id}
|
img={track.image}
|
||||||
musicbrainzId={album.musicbrainz_id}
|
id={track.id}
|
||||||
imgItemId={track.album_id}
|
rank={track.all_time_rank}
|
||||||
mergeFunc={mergeTracks}
|
musicbrainzId={track.musicbrainz_id}
|
||||||
mergeCleanerFunc={(r, id) => {
|
imgItemId={track.album_id}
|
||||||
r.albums = []
|
mergeFunc={mergeTracks}
|
||||||
r.artists = []
|
mergeCleanerFunc={(r, id) => {
|
||||||
for (let i = 0; i < r.tracks.length; i ++) {
|
r.albums = [];
|
||||||
if (r.tracks[i].id === id) {
|
r.artists = [];
|
||||||
delete r.tracks[i]
|
for (let i = 0; i < r.tracks.length; i++) {
|
||||||
}
|
if (r.tracks[i].id === id) {
|
||||||
}
|
delete r.tracks[i];
|
||||||
return r
|
}
|
||||||
}}
|
}
|
||||||
subContent={<div className="flex flex-col gap-4 items-start">
|
return r;
|
||||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
}}
|
||||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
subContent={
|
||||||
</div>}
|
<div className="flex flex-col gap-2 items-start">
|
||||||
>
|
<p>
|
||||||
<div className="mt-10">
|
Appears on{" "}
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<Link className="hover:underline" to={`/album/${track.album_id}`}>
|
||||||
</div>
|
{album.title}
|
||||||
<div className="flex flex-wrap gap-20 mt-10">
|
</Link>
|
||||||
<LastPlays limit={20} trackId={track.id}/>
|
</p>
|
||||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
{track.listen_count !== 0 && (
|
||||||
</div>
|
<p>
|
||||||
</MediaLayout>
|
{track.listen_count} play{track.listen_count > 1 ? "s" : ""}
|
||||||
)
|
</p>
|
||||||
|
)}
|
||||||
|
{track.time_listened !== 0 && (
|
||||||
|
<p title={Math.floor(track.time_listened / 60 / 60) + " hours"}>
|
||||||
|
{timeListenedString(track.time_listened)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{track.first_listen > 0 && (
|
||||||
|
<p title={new Date(track.first_listen * 1000).toLocaleString()}>
|
||||||
|
Listening since{" "}
|
||||||
|
{new Date(track.first_listen * 1000).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mt-10">
|
||||||
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-20 mt-10">
|
||||||
|
<LastPlays limit={20} trackId={track.id} />
|
||||||
|
<div className="flex flex-col items-start gap-4">
|
||||||
|
<ActivityGrid configurable trackId={track.id} />
|
||||||
|
<InterestGraph trackId={track.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MediaLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
213
client/app/routes/RewindPage.tsx
Normal file
213
client/app/routes/RewindPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,40 @@ import LastPlays from "~/components/LastPlays"
|
||||||
import TopAlbums from "~/components/TopAlbums"
|
import TopAlbums from "~/components/TopAlbums"
|
||||||
import TopArtists from "~/components/TopArtists"
|
import TopArtists from "~/components/TopArtists"
|
||||||
import TopTracks from "~/components/TopTracks"
|
import TopTracks from "~/components/TopTracks"
|
||||||
|
import { useTheme } from "~/hooks/useTheme"
|
||||||
|
import { themes, type Theme } from "~/styles/themes.css"
|
||||||
|
|
||||||
export default function ThemeHelper() {
|
export default function ThemeHelper() {
|
||||||
|
const initialTheme = {
|
||||||
|
bg: "#1e1816",
|
||||||
|
bgSecondary: "#2f2623",
|
||||||
|
bgTertiary: "#453733",
|
||||||
|
fg: "#f8f3ec",
|
||||||
|
fgSecondary: "#d6ccc2",
|
||||||
|
fgTertiary: "#b4a89c",
|
||||||
|
primary: "#f5a97f",
|
||||||
|
primaryDim: "#d88b65",
|
||||||
|
accent: "#f9db6d",
|
||||||
|
accentDim: "#d9bc55",
|
||||||
|
error: "#e26c6a",
|
||||||
|
warning: "#f5b851",
|
||||||
|
success: "#8fc48f",
|
||||||
|
info: "#87b8dd",
|
||||||
|
}
|
||||||
|
|
||||||
|
const [custom, setCustom] = useState(JSON.stringify(initialTheme, null, " "))
|
||||||
|
const { setCustomTheme } = useTheme()
|
||||||
|
|
||||||
|
const handleCustomTheme = () => {
|
||||||
|
console.log(custom)
|
||||||
|
try {
|
||||||
|
const theme = JSON.parse(custom) as Theme
|
||||||
|
console.log(theme)
|
||||||
|
setCustomTheme(theme)
|
||||||
|
} catch(err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const homeItems = 3
|
const homeItems = 3
|
||||||
|
|
||||||
|
|
@ -24,43 +56,49 @@ export default function ThemeHelper() {
|
||||||
<TopTracks period="all_time" limit={homeItems} />
|
<TopTracks period="all_time" limit={homeItems} />
|
||||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
|
<div className="flex gap-10">
|
||||||
<div className="flex flex-col gap-4 items-center">
|
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||||
<p>You're logged in as <strong>Example User</strong></p>
|
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) w-[300px] p-5 h-full rounded-md" value={custom} />
|
||||||
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex gap-4">
|
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
|
||||||
<input
|
<div className="flex flex-col gap-4 items-center">
|
||||||
name="koito-update-username"
|
<p>You"re logged in as <strong>Example User</strong></p>
|
||||||
type="text"
|
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
||||||
placeholder="Update username"
|
</div>
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
<div className="flex flex gap-4">
|
||||||
/>
|
<input
|
||||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
name="koito-update-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Update username"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex gap-4">
|
||||||
|
<input
|
||||||
|
name="koito-update-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Update password"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="koito-confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
|
||||||
|
<label htmlFor="reverse-merge-order">Example checkbox</label>
|
||||||
|
</div>
|
||||||
|
<p className="success">successfully displayed example text</p>
|
||||||
|
<p className="error">this is an example of error text</p>
|
||||||
|
<p className="info">here is an informational example</p>
|
||||||
|
<p className="warning">heed this warning, traveller</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex gap-4">
|
|
||||||
<input
|
|
||||||
name="koito-update-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Update password"
|
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
name="koito-confirm-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
|
||||||
/>
|
|
||||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
|
|
||||||
<label htmlFor="reverse-merge-order">Example checkbox</label>
|
|
||||||
</div>
|
|
||||||
<p className="success">successfully displayed example text</p>
|
|
||||||
<p className="error">this is an example of error text</p>
|
|
||||||
<p className="info">here is an informational example</p>
|
|
||||||
<p className="warning">heed this warning, traveller</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
241
client/app/styles/themes.css.ts
Normal file
241
client/app/styles/themes.css.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
16
client/app/styles/vars.css.ts
Normal file
16
client/app/styles/vars.css.ts
Normal 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',
|
||||||
|
}
|
||||||
|
|
@ -1,391 +1,5 @@
|
||||||
/* Theme Definitions */
|
/* Theme Definitions */
|
||||||
|
|
||||||
[data-theme="varia"]{
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg:rgb(25, 25, 29);
|
|
||||||
--color-bg-secondary: #222222;
|
|
||||||
--color-bg-tertiary: #333333;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #eeeeee;
|
|
||||||
--color-fg-secondary: #aaaaaa;
|
|
||||||
--color-fg-tertiary: #888888;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary:rgb(203, 110, 240);
|
|
||||||
--color-primary-dim: #c28379;
|
|
||||||
--color-accent: #f0ad0a;
|
|
||||||
--color-accent-dim: #d08d08;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #f44336;
|
|
||||||
--color-warning: #ff9800;
|
|
||||||
--color-success: #4caf50;
|
|
||||||
--color-info: #2196f3;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="wine"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #23181E;
|
|
||||||
--color-bg-secondary: #2C1C25;
|
|
||||||
--color-bg-tertiary: #422A37;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #FCE0B3;
|
|
||||||
--color-fg-secondary:#C7AC81;
|
|
||||||
--color-fg-tertiary:#A78E64;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #EA8A64;
|
|
||||||
--color-primary-dim: #BD7255;
|
|
||||||
--color-accent: #FAE99B;
|
|
||||||
--color-accent-dim: #C6B464;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #fca5a5;
|
|
||||||
--color-warning: #fde68a;
|
|
||||||
--color-success: #bbf7d0;
|
|
||||||
--color-info: #bae6fd;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.05);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="asuka"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #3B1212;
|
|
||||||
--color-bg-secondary: #471B1B;
|
|
||||||
--color-bg-tertiary: #020202;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #F1E9E6;
|
|
||||||
--color-fg-secondary: #CCB6AE;
|
|
||||||
--color-fg-tertiary: #9F8176;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #F1E9E6;
|
|
||||||
--color-primary-dim: #CCB6AE;
|
|
||||||
--color-accent: #41CE41;
|
|
||||||
--color-accent-dim: #3BA03B;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #EB97A8;
|
|
||||||
--color-warning: #FFD700;
|
|
||||||
--color-success: #32CD32;
|
|
||||||
--color-info: #1E90FF;
|
|
||||||
|
|
||||||
/* Borders and Shadows (derived from existing colors for consistency) */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.1); /* Slightly more prominent shadow for contrast */
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="midnight"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg:rgb(8, 15, 24);
|
|
||||||
--color-bg-secondary:rgb(15, 27, 46);
|
|
||||||
--color-bg-tertiary:rgb(15, 41, 70);
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #dbdfe7;
|
|
||||||
--color-fg-secondary: #9ea3a8;
|
|
||||||
--color-fg-tertiary: #74787c;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #1a97eb;
|
|
||||||
--color-primary-dim: #2680aa;
|
|
||||||
--color-accent: #f0ad0a;
|
|
||||||
--color-accent-dim: #d08d08;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #f44336;
|
|
||||||
--color-warning: #ff9800;
|
|
||||||
--color-success: #4caf50;
|
|
||||||
--color-info: #2196f3;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TODO: Adjust */
|
|
||||||
[data-theme="catppuccin"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #1e1e2e;
|
|
||||||
--color-bg-secondary: #181825;
|
|
||||||
--color-bg-tertiary: #11111b;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #cdd6f4;
|
|
||||||
--color-fg-secondary: #a6adc8;
|
|
||||||
--color-fg-tertiary: #9399b2;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #cba6f7;
|
|
||||||
--color-primary-dim: #739df0;
|
|
||||||
--color-accent: #f38ba8;
|
|
||||||
--color-accent-dim: #d67b94;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #f38ba8;
|
|
||||||
--color-warning: #f9e2af;
|
|
||||||
--color-success: #a6e3a1;
|
|
||||||
--color-info: #89dceb;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="pearl"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #FFFFFF;
|
|
||||||
--color-bg-secondary: #EEEEEE;
|
|
||||||
--color-bg-tertiary: #E0E0E0;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #333333;
|
|
||||||
--color-fg-secondary: #555555;
|
|
||||||
--color-fg-tertiary: #777777;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #007BFF;
|
|
||||||
--color-primary-dim: #0056B3;
|
|
||||||
--color-accent: #28A745;
|
|
||||||
--color-accent-dim: #1E7E34;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #DC3545;
|
|
||||||
--color-warning: #CE9B00;
|
|
||||||
--color-success: #099B2B;
|
|
||||||
--color-info: #02B3CE;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="urim"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #101713;
|
|
||||||
--color-bg-secondary: #1B2921;
|
|
||||||
--color-bg-tertiary: #273B30;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #D2E79E;
|
|
||||||
--color-fg-secondary: #B4DA55;
|
|
||||||
--color-fg-tertiary: #7E9F2A;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #ead500;
|
|
||||||
--color-primary-dim: #C1B210;
|
|
||||||
--color-accent: #28A745;
|
|
||||||
--color-accent-dim: #1E7E34;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #EE5237;
|
|
||||||
--color-warning: #FFC107;
|
|
||||||
--color-success: #28A745;
|
|
||||||
--color-info: #17A2B8;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="yuu"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #161312;
|
|
||||||
--color-bg-secondary: #272120;
|
|
||||||
--color-bg-tertiary: #382F2E;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #faf5f4;
|
|
||||||
--color-fg-secondary: #CCC7C6;
|
|
||||||
--color-fg-tertiary: #B0A3A1;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #ff826d;
|
|
||||||
--color-primary-dim: #CE6654;
|
|
||||||
--color-accent: #464DAE;
|
|
||||||
--color-accent-dim: #393D74;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #FF6247;
|
|
||||||
--color-warning: #FFC107;
|
|
||||||
--color-success: #3ECE5F;
|
|
||||||
--color-info: #41C4D8;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="match"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #071014;
|
|
||||||
--color-bg-secondary: #0A181E;
|
|
||||||
--color-bg-tertiary: #112A34;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #ebeaeb;
|
|
||||||
--color-fg-secondary: #BDBDBD;
|
|
||||||
--color-fg-tertiary: #A2A2A2;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #fda827;
|
|
||||||
--color-primary-dim: #C78420;
|
|
||||||
--color-accent: #277CFD;
|
|
||||||
--color-accent-dim: #1F60C1;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #F14426;
|
|
||||||
--color-warning: #FFC107;
|
|
||||||
--color-success: #28A745;
|
|
||||||
--color-info: #17A2B8;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="lemon"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #1a171a;
|
|
||||||
--color-bg-secondary: #2E272E;
|
|
||||||
--color-bg-tertiary: #443844;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #E6E2DC;
|
|
||||||
--color-fg-secondary: #B2ACA1;
|
|
||||||
--color-fg-tertiary: #968F82;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #f5c737;
|
|
||||||
--color-primary-dim: #C29D2F;
|
|
||||||
--color-accent: #277CFD;
|
|
||||||
--color-accent-dim: #1F60C1;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #F14426;
|
|
||||||
--color-warning: #FFC107;
|
|
||||||
--color-success: #28A745;
|
|
||||||
--color-info: #17A2B8;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="autumn"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg:rgb(44, 25, 18);
|
|
||||||
--color-bg-secondary:rgb(70, 40, 18);
|
|
||||||
--color-bg-tertiary: #4b2f1c;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #fef9f3;
|
|
||||||
--color-fg-secondary: #dbc6b0;
|
|
||||||
--color-fg-tertiary: #a3917a;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #d97706;
|
|
||||||
--color-primary-dim: #b45309;
|
|
||||||
--color-accent: #8c4c28;
|
|
||||||
--color-accent-dim: #6b3b1f;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #d1433f;
|
|
||||||
--color-warning: #e38b29;
|
|
||||||
--color-success: #6b8e23;
|
|
||||||
--color-info: #c084fc;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: var(--color-primary);
|
|
||||||
--color-link-hover: var(--color-primary-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="black"] {
|
|
||||||
/* Backgrounds */
|
|
||||||
--color-bg: #000000;
|
|
||||||
--color-bg-secondary: #1a1a1a;
|
|
||||||
--color-bg-tertiary: #2a2a2a;
|
|
||||||
|
|
||||||
/* Foregrounds */
|
|
||||||
--color-fg: #dddddd;
|
|
||||||
--color-fg-secondary: #aaaaaa;
|
|
||||||
--color-fg-tertiary: #888888;
|
|
||||||
|
|
||||||
/* Accents */
|
|
||||||
--color-primary: #08c08c;
|
|
||||||
--color-primary-dim: #08c08c;
|
|
||||||
--color-accent: #f0ad0a;
|
|
||||||
--color-accent-dim: #d08d08;
|
|
||||||
|
|
||||||
/* Status Colors */
|
|
||||||
--color-error: #f44336;
|
|
||||||
--color-warning: #ff9800;
|
|
||||||
--color-success: #4caf50;
|
|
||||||
--color-info: #2196f3;
|
|
||||||
|
|
||||||
/* Borders and Shadows */
|
|
||||||
--color-border: var(--color-bg-tertiary);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
/* Interactive Elements */
|
|
||||||
--color-link: #0af0af;
|
|
||||||
--color-link-hover: #08c08c;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Theme Helper Classes */
|
/* Theme Helper Classes */
|
||||||
|
|
||||||
/* Foreground Text */
|
/* Foreground Text */
|
||||||
|
|
|
||||||
10
client/app/tz.ts
Normal file
10
client/app/tz.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
|
@ -1,90 +1,121 @@
|
||||||
import Timeframe from "~/types/timeframe"
|
import Timeframe from "~/types/timeframe";
|
||||||
|
|
||||||
const timeframeToInterval = (timeframe: Timeframe): string => {
|
const timeframeToInterval = (timeframe: Timeframe): string => {
|
||||||
switch (timeframe) {
|
switch (timeframe) {
|
||||||
case Timeframe.Day:
|
case Timeframe.Day:
|
||||||
return "1 day"
|
return "1 day";
|
||||||
case Timeframe.Week:
|
case Timeframe.Week:
|
||||||
return "1 week"
|
return "1 week";
|
||||||
case Timeframe.Month:
|
case Timeframe.Month:
|
||||||
return "1 month"
|
return "1 month";
|
||||||
case Timeframe.Year:
|
case Timeframe.Year:
|
||||||
return "1 year"
|
return "1 year";
|
||||||
case Timeframe.AllTime:
|
case Timeframe.AllTime:
|
||||||
return "99 years"
|
return "99 years";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function timeSince(date: Date) {
|
|
||||||
const now = new Date();
|
|
||||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
||||||
|
|
||||||
const intervals = [
|
|
||||||
{ label: 'year', seconds: 31536000 },
|
|
||||||
{ label: 'month', seconds: 2592000 },
|
|
||||||
{ label: 'week', seconds: 604800 },
|
|
||||||
{ label: 'day', seconds: 86400 },
|
|
||||||
{ label: 'hour', seconds: 3600 },
|
|
||||||
{ label: 'minute', seconds: 60 },
|
|
||||||
{ label: 'second', seconds: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const interval of intervals) {
|
|
||||||
const count = Math.floor(seconds / interval.seconds);
|
|
||||||
if (count >= 1) {
|
|
||||||
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
export { timeSince }
|
|
||||||
|
|
||||||
type hsl = {
|
|
||||||
h: number,
|
|
||||||
s: number,
|
|
||||||
l: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
const hexToHSL = (hex: string): hsl => {
|
|
||||||
let r = 0, g = 0, b = 0;
|
|
||||||
hex = hex.replace('#', '');
|
|
||||||
|
|
||||||
if (hex.length === 3) {
|
|
||||||
r = parseInt(hex[0] + hex[0], 16);
|
|
||||||
g = parseInt(hex[1] + hex[1], 16);
|
|
||||||
b = parseInt(hex[2] + hex[2], 16);
|
|
||||||
} else if (hex.length === 6) {
|
|
||||||
r = parseInt(hex.substring(0, 2), 16);
|
|
||||||
g = parseInt(hex.substring(2, 4), 16);
|
|
||||||
b = parseInt(hex.substring(4, 6), 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
r /= 255;
|
|
||||||
g /= 255;
|
|
||||||
b /= 255;
|
|
||||||
|
|
||||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
||||||
let h = 0, s = 0, l = (max + min) / 2;
|
|
||||||
|
|
||||||
if (max !== min) {
|
|
||||||
const d = max - min;
|
|
||||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
switch (max) {
|
|
||||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
|
|
||||||
case g: h = ((b - r) / d + 2); break;
|
|
||||||
case b: h = ((r - g) / d + 4); break;
|
|
||||||
}
|
|
||||||
h /= 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
h: Math.round(h * 360),
|
|
||||||
s: Math.round(s * 100),
|
|
||||||
l: Math.round(l * 100)
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export {hexToHSL}
|
const getRewindYear = (): number => {
|
||||||
export type {hsl}
|
return new Date().getFullYear() - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRewindParams = (): { month: number; year: number } => {
|
||||||
|
const today = new Date();
|
||||||
|
if (today.getMonth() == 0) {
|
||||||
|
return { month: 0, year: today.getFullYear() - 1 };
|
||||||
|
} else {
|
||||||
|
return { month: today.getMonth(), year: today.getFullYear() };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeSince(date: Date) {
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
|
const intervals = [
|
||||||
|
{ label: "year", seconds: 31536000 },
|
||||||
|
{ label: "month", seconds: 2592000 },
|
||||||
|
{ label: "week", seconds: 604800 },
|
||||||
|
{ label: "day", seconds: 86400 },
|
||||||
|
{ label: "hour", seconds: 3600 },
|
||||||
|
{ label: "minute", seconds: 60 },
|
||||||
|
{ label: "second", seconds: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const interval of intervals) {
|
||||||
|
const count = Math.floor(seconds / interval.seconds);
|
||||||
|
if (count >= 1) {
|
||||||
|
return `${count} ${interval.label}${count !== 1 ? "s" : ""} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
export { timeSince };
|
||||||
|
|
||||||
|
type hsl = {
|
||||||
|
h: number;
|
||||||
|
s: number;
|
||||||
|
l: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToHSL = (hex: string): hsl => {
|
||||||
|
let r = 0,
|
||||||
|
g = 0,
|
||||||
|
b = 0;
|
||||||
|
hex = hex.replace("#", "");
|
||||||
|
|
||||||
|
if (hex.length === 3) {
|
||||||
|
r = parseInt(hex[0] + hex[0], 16);
|
||||||
|
g = parseInt(hex[1] + hex[1], 16);
|
||||||
|
b = parseInt(hex[2] + hex[2], 16);
|
||||||
|
} else if (hex.length === 6) {
|
||||||
|
r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b),
|
||||||
|
min = Math.min(r, g, b);
|
||||||
|
let h = 0,
|
||||||
|
s = 0,
|
||||||
|
l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
l: Math.round(l * 100),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeListenedString = (seconds: number) => {
|
||||||
|
if (!seconds) return "";
|
||||||
|
|
||||||
|
let minutes = Math.floor(seconds / 60);
|
||||||
|
return `${minutes} minutes listened`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
|
||||||
|
export type { hsl };
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,17 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@react-router/node": "^7.5.3",
|
"@react-router/node": "^7.5.3",
|
||||||
"@react-router/serve": "^7.5.3",
|
"@react-router/serve": "^7.5.3",
|
||||||
|
"@recharts/devtools": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
|
"@vanilla-extract/css": "^1.17.4",
|
||||||
"color.js": "^1.2.0",
|
"color.js": "^1.2.0",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.513.0",
|
"lucide-react": "^0.513.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.5.3"
|
"react-is": "^19.2.3",
|
||||||
|
"react-router": "^7.5.3",
|
||||||
|
"recharts": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.5.3",
|
"@react-router/dev": "^7.5.3",
|
||||||
|
|
@ -27,6 +31,7 @@
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vanilla-extract/vite-plugin": "^5.0.6",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.3",
|
"vite": "^6.3.3",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "MyWebSite",
|
"name": "Koito",
|
||||||
"short_name": "MySite",
|
"short_name": "Koito",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/web-app-manifest-192x192.png",
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import { reactRouter } from "@react-router/dev/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
|
||||||
|
|
||||||
const isDocker = process.env.BUILD_TARGET === 'docker';
|
const isDocker = process.env.BUILD_TARGET === 'docker';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), vanillaExtractPlugin()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/apis': {
|
'/apis': {
|
||||||
|
|
|
||||||
491
client/yarn.lock
491
client/yarn.lock
|
|
@ -24,7 +24,7 @@
|
||||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82"
|
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82"
|
||||||
integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==
|
integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==
|
||||||
|
|
||||||
"@babel/core@^7.21.8", "@babel/core@^7.23.7":
|
"@babel/core@^7.21.8", "@babel/core@^7.23.7", "@babel/core@^7.23.9":
|
||||||
version "7.27.4"
|
version "7.27.4"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
|
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
|
||||||
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
|
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/helper-plugin-utils" "^7.27.1"
|
"@babel/helper-plugin-utils" "^7.27.1"
|
||||||
|
|
||||||
"@babel/plugin-syntax-typescript@^7.27.1":
|
"@babel/plugin-syntax-typescript@^7.23.3", "@babel/plugin-syntax-typescript@^7.27.1":
|
||||||
version "7.27.1"
|
version "7.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
|
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
|
||||||
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
|
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
|
||||||
|
|
@ -222,6 +222,11 @@
|
||||||
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
|
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
|
||||||
"@babel/plugin-transform-typescript" "^7.27.1"
|
"@babel/plugin-transform-typescript" "^7.27.1"
|
||||||
|
|
||||||
|
"@babel/runtime@^7.12.5":
|
||||||
|
version "7.27.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
||||||
|
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
||||||
|
|
||||||
"@babel/template@^7.27.2":
|
"@babel/template@^7.27.2":
|
||||||
version "7.27.2"
|
version "7.27.2"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
||||||
|
|
@ -274,6 +279,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@emotion/hash@^0.9.0":
|
||||||
|
version "0.9.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b"
|
||||||
|
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
|
||||||
|
|
||||||
"@esbuild/aix-ppc64@0.25.5":
|
"@esbuild/aix-ppc64@0.25.5":
|
||||||
version "0.25.5"
|
version "0.25.5"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
|
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
|
||||||
|
|
@ -679,6 +689,23 @@
|
||||||
morgan "^1.10.0"
|
morgan "^1.10.0"
|
||||||
source-map-support "^0.5.21"
|
source-map-support "^0.5.21"
|
||||||
|
|
||||||
|
"@recharts/devtools@^0.0.7":
|
||||||
|
version "0.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@recharts/devtools/-/devtools-0.0.7.tgz#a909d102efd76fc45bc2b7a150e67a02da04b4c1"
|
||||||
|
integrity sha512-ud66rUf3FYf1yQLGSCowI50EQyC/rcZblvDgNvfUIVaEXyQtr5K2DFgwegziqbVclsVBQLTxyntVViJN5H4oWQ==
|
||||||
|
|
||||||
|
"@reduxjs/toolkit@1.x.x || 2.x.x":
|
||||||
|
version "2.11.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
|
||||||
|
integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==
|
||||||
|
dependencies:
|
||||||
|
"@standard-schema/spec" "^1.0.0"
|
||||||
|
"@standard-schema/utils" "^0.3.0"
|
||||||
|
immer "^11.0.0"
|
||||||
|
redux "^5.0.1"
|
||||||
|
redux-thunk "^3.1.0"
|
||||||
|
reselect "^5.1.0"
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.42.0":
|
"@rollup/rollup-android-arm-eabi@4.42.0":
|
||||||
version "4.42.0"
|
version "4.42.0"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
|
||||||
|
|
@ -779,6 +806,16 @@
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
|
||||||
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
|
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
|
||||||
|
|
||||||
|
"@standard-schema/spec@^1.0.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
|
||||||
|
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
|
||||||
|
|
||||||
|
"@standard-schema/utils@^0.3.0":
|
||||||
|
version "0.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||||
|
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||||
|
|
||||||
"@tailwindcss/node@4.1.8":
|
"@tailwindcss/node@4.1.8":
|
||||||
version "4.1.8"
|
version "4.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
|
||||||
|
|
@ -908,11 +945,69 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@types/d3-array@^3.0.3":
|
||||||
|
version "3.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c"
|
||||||
|
integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==
|
||||||
|
|
||||||
|
"@types/d3-color@*":
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
|
||||||
|
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
|
||||||
|
|
||||||
|
"@types/d3-ease@^3.0.0":
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
|
||||||
|
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
|
||||||
|
|
||||||
|
"@types/d3-interpolate@^3.0.1":
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||||
|
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-color" "*"
|
||||||
|
|
||||||
|
"@types/d3-path@*":
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a"
|
||||||
|
integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==
|
||||||
|
|
||||||
|
"@types/d3-scale@^4.0.2":
|
||||||
|
version "4.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb"
|
||||||
|
integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-time" "*"
|
||||||
|
|
||||||
|
"@types/d3-shape@^3.1.0":
|
||||||
|
version "3.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3"
|
||||||
|
integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-path" "*"
|
||||||
|
|
||||||
|
"@types/d3-time@*", "@types/d3-time@^3.0.0":
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f"
|
||||||
|
integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==
|
||||||
|
|
||||||
|
"@types/d3-timer@^3.0.0":
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
|
||||||
|
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
|
||||||
|
|
||||||
"@types/estree@1.0.7":
|
"@types/estree@1.0.7":
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||||
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
||||||
|
|
||||||
|
"@types/node@*":
|
||||||
|
version "24.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab"
|
||||||
|
integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==
|
||||||
|
dependencies:
|
||||||
|
undici-types "~7.8.0"
|
||||||
|
|
||||||
"@types/node@^20":
|
"@types/node@^20":
|
||||||
version "20.19.0"
|
version "20.19.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
|
||||||
|
|
@ -932,6 +1027,75 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/use-sync-external-store@^0.0.6":
|
||||||
|
version "0.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
|
||||||
|
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
|
||||||
|
|
||||||
|
"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
|
||||||
|
integrity sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==
|
||||||
|
dependencies:
|
||||||
|
"@babel/core" "^7.23.9"
|
||||||
|
|
||||||
|
"@vanilla-extract/compiler@^0.2.3":
|
||||||
|
version "0.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/compiler/-/compiler-0.2.3.tgz#97c4bb989aea92ee8329f1ad0a3ec01bf3aa8479"
|
||||||
|
integrity sha512-SFEDLbvd5rhpjhrLp9BtvvVNHNxWupiUht/yrsHQ7xfkpEn4xg45gbfma7aX9fsOpi82ebqFmowHd/g6jHDQnA==
|
||||||
|
dependencies:
|
||||||
|
"@vanilla-extract/css" "^1.17.4"
|
||||||
|
"@vanilla-extract/integration" "^8.0.4"
|
||||||
|
vite "^5.0.0 || ^6.0.0"
|
||||||
|
vite-node "^3.2.2"
|
||||||
|
|
||||||
|
"@vanilla-extract/css@^1.17.4":
|
||||||
|
version "1.17.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/css/-/css-1.17.4.tgz#c73353992b8243e8ab140582bf6d673ebc709b0a"
|
||||||
|
integrity sha512-m3g9nQDWPtL+sTFdtCGRMI1Vrp86Ay4PBYq1Bo7Bnchj5ElNtAJpOqD+zg+apthVA4fB7oVpMWNjwpa6ElDWFQ==
|
||||||
|
dependencies:
|
||||||
|
"@emotion/hash" "^0.9.0"
|
||||||
|
"@vanilla-extract/private" "^1.0.9"
|
||||||
|
css-what "^6.1.0"
|
||||||
|
cssesc "^3.0.0"
|
||||||
|
csstype "^3.0.7"
|
||||||
|
dedent "^1.5.3"
|
||||||
|
deep-object-diff "^1.1.9"
|
||||||
|
deepmerge "^4.2.2"
|
||||||
|
lru-cache "^10.4.3"
|
||||||
|
media-query-parser "^2.0.2"
|
||||||
|
modern-ahocorasick "^1.0.0"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
|
||||||
|
"@vanilla-extract/integration@^8.0.4":
|
||||||
|
version "8.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/integration/-/integration-8.0.4.tgz#eb176376b3b03c44713bf596cc41d6d97ba9f5d3"
|
||||||
|
integrity sha512-cmOb7tR+g3ulKvFtSbmdw3YUyIS1d7MQqN+FcbwNhdieyno5xzUyfDCMjeWJhmCSMvZ6WlinkrOkgs6SHB+FRg==
|
||||||
|
dependencies:
|
||||||
|
"@babel/core" "^7.23.9"
|
||||||
|
"@babel/plugin-syntax-typescript" "^7.23.3"
|
||||||
|
"@vanilla-extract/babel-plugin-debug-ids" "^1.2.2"
|
||||||
|
"@vanilla-extract/css" "^1.17.4"
|
||||||
|
dedent "^1.5.3"
|
||||||
|
esbuild "npm:esbuild@>=0.17.6 <0.26.0"
|
||||||
|
eval "0.1.8"
|
||||||
|
find-up "^5.0.0"
|
||||||
|
javascript-stringify "^2.0.1"
|
||||||
|
mlly "^1.4.2"
|
||||||
|
|
||||||
|
"@vanilla-extract/private@^1.0.9":
|
||||||
|
version "1.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.9.tgz#bb8aaf72d2e04439792f2e389d9b705cfe691bc0"
|
||||||
|
integrity sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==
|
||||||
|
|
||||||
|
"@vanilla-extract/vite-plugin@^5.0.6":
|
||||||
|
version "5.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/vite-plugin/-/vite-plugin-5.0.6.tgz#00084be8e872519dde5152d92241ad8ad1e85396"
|
||||||
|
integrity sha512-9dSPIuxR2NULvVk9bqCoTaZz3CtfBrvo5hImWaiWCblWZXzCcD7jIg7Nbcpdz9MvytO+mNta82/qCWj1G9mEMQ==
|
||||||
|
dependencies:
|
||||||
|
"@vanilla-extract/compiler" "^0.2.3"
|
||||||
|
"@vanilla-extract/integration" "^8.0.4"
|
||||||
|
|
||||||
accepts@~1.3.8:
|
accepts@~1.3.8:
|
||||||
version "1.3.8"
|
version "1.3.8"
|
||||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
||||||
|
|
@ -940,6 +1104,11 @@ accepts@~1.3.8:
|
||||||
mime-types "~2.1.34"
|
mime-types "~2.1.34"
|
||||||
negotiator "0.6.3"
|
negotiator "0.6.3"
|
||||||
|
|
||||||
|
acorn@^8.14.0:
|
||||||
|
version "8.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||||
|
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||||
|
|
||||||
ansi-regex@^5.0.1:
|
ansi-regex@^5.0.1:
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||||
|
|
@ -1077,6 +1246,11 @@ chownr@^3.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
|
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
|
||||||
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
|
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
|
||||||
|
|
||||||
|
clsx@^2.1.1:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||||
|
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||||
|
|
||||||
color-convert@^2.0.1:
|
color-convert@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||||
|
|
@ -1114,6 +1288,11 @@ compression@^1.7.4:
|
||||||
safe-buffer "5.2.1"
|
safe-buffer "5.2.1"
|
||||||
vary "~1.1.2"
|
vary "~1.1.2"
|
||||||
|
|
||||||
|
confbox@^0.1.8:
|
||||||
|
version "0.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
||||||
|
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
|
||||||
|
|
||||||
content-disposition@0.5.4:
|
content-disposition@0.5.4:
|
||||||
version "0.5.4"
|
version "0.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||||
|
|
@ -1155,11 +1334,92 @@ cross-spawn@^7.0.6:
|
||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
which "^2.0.1"
|
||||||
|
|
||||||
csstype@^3.0.2:
|
css-what@^6.1.0:
|
||||||
|
version "6.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
|
||||||
|
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
|
||||||
|
|
||||||
|
cssesc@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||||
|
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||||
|
|
||||||
|
csstype@^3.0.2, csstype@^3.0.7:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||||
|
|
||||||
|
"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
|
||||||
|
version "3.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
|
||||||
|
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
|
||||||
|
dependencies:
|
||||||
|
internmap "1 - 2"
|
||||||
|
|
||||||
|
"d3-color@1 - 3":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
|
||||||
|
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
|
||||||
|
|
||||||
|
d3-ease@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
|
||||||
|
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||||
|
|
||||||
|
"d3-format@1 - 3":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
|
||||||
|
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
|
||||||
|
|
||||||
|
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||||
|
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||||
|
dependencies:
|
||||||
|
d3-color "1 - 3"
|
||||||
|
|
||||||
|
d3-path@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
|
||||||
|
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
|
||||||
|
|
||||||
|
d3-scale@^4.0.2:
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
|
||||||
|
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
|
||||||
|
dependencies:
|
||||||
|
d3-array "2.10.0 - 3"
|
||||||
|
d3-format "1 - 3"
|
||||||
|
d3-interpolate "1.2.0 - 3"
|
||||||
|
d3-time "2.1.1 - 3"
|
||||||
|
d3-time-format "2 - 4"
|
||||||
|
|
||||||
|
d3-shape@^3.1.0:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
|
||||||
|
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
|
||||||
|
dependencies:
|
||||||
|
d3-path "^3.1.0"
|
||||||
|
|
||||||
|
"d3-time-format@2 - 4":
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
|
||||||
|
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
|
||||||
|
dependencies:
|
||||||
|
d3-time "1 - 3"
|
||||||
|
|
||||||
|
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
|
||||||
|
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
|
||||||
|
dependencies:
|
||||||
|
d3-array "2 - 3"
|
||||||
|
|
||||||
|
d3-timer@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
||||||
|
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
|
|
@ -1174,11 +1434,26 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms "^2.1.3"
|
ms "^2.1.3"
|
||||||
|
|
||||||
|
decimal.js-light@^2.5.1:
|
||||||
|
version "2.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
|
||||||
|
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
|
||||||
|
|
||||||
dedent@^1.5.3:
|
dedent@^1.5.3:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
|
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
|
||||||
integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==
|
integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==
|
||||||
|
|
||||||
|
deep-object-diff@^1.1.9:
|
||||||
|
version "1.1.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595"
|
||||||
|
integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==
|
||||||
|
|
||||||
|
deepmerge@^4.2.2:
|
||||||
|
version "4.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||||
|
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
||||||
|
|
||||||
depd@2.0.0, depd@~2.0.0:
|
depd@2.0.0, depd@~2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||||
|
|
@ -1273,7 +1548,12 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
|
|
||||||
esbuild@^0.25.0:
|
es-toolkit@^1.39.3:
|
||||||
|
version "1.43.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.43.0.tgz#2c278d55ffeb30421e6e73a009738ed37b10ef61"
|
||||||
|
integrity sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==
|
||||||
|
|
||||||
|
esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
|
||||||
version "0.25.5"
|
version "0.25.5"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
||||||
integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==
|
integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==
|
||||||
|
|
@ -1319,6 +1599,19 @@ etag@~1.8.1:
|
||||||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||||
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
||||||
|
|
||||||
|
eval@0.1.8:
|
||||||
|
version "0.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.8.tgz#2b903473b8cc1d1989b83a1e7923f883eb357f85"
|
||||||
|
integrity sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
require-like ">= 0.1.1"
|
||||||
|
|
||||||
|
eventemitter3@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
|
||||||
|
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
|
||||||
|
|
||||||
exit-hook@2.2.1:
|
exit-hook@2.2.1:
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
|
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
|
||||||
|
|
@ -1379,6 +1672,14 @@ finalhandler@1.3.1:
|
||||||
statuses "2.0.1"
|
statuses "2.0.1"
|
||||||
unpipe "~1.0.0"
|
unpipe "~1.0.0"
|
||||||
|
|
||||||
|
find-up@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
|
||||||
|
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
|
||||||
|
dependencies:
|
||||||
|
locate-path "^6.0.0"
|
||||||
|
path-exists "^4.0.0"
|
||||||
|
|
||||||
foreground-child@^3.1.0:
|
foreground-child@^3.1.0:
|
||||||
version "3.3.1"
|
version "3.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
||||||
|
|
@ -1519,11 +1820,26 @@ iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer ">= 2.1.2 < 3"
|
safer-buffer ">= 2.1.2 < 3"
|
||||||
|
|
||||||
|
immer@^10.1.1:
|
||||||
|
version "10.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1"
|
||||||
|
integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==
|
||||||
|
|
||||||
|
immer@^11.0.0:
|
||||||
|
version "11.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
|
||||||
|
integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
|
||||||
|
|
||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
"internmap@1 - 2":
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
|
||||||
|
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||||
|
|
@ -1560,6 +1876,11 @@ jackspeak@^3.1.2:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@pkgjs/parseargs" "^0.11.0"
|
"@pkgjs/parseargs" "^0.11.0"
|
||||||
|
|
||||||
|
javascript-stringify@^2.0.1:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
|
||||||
|
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
|
||||||
|
|
||||||
jiti@^2.4.2:
|
jiti@^2.4.2:
|
||||||
version "2.4.2"
|
version "2.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
||||||
|
|
@ -1667,12 +1988,19 @@ lightningcss@1.30.1:
|
||||||
lightningcss-win32-arm64-msvc "1.30.1"
|
lightningcss-win32-arm64-msvc "1.30.1"
|
||||||
lightningcss-win32-x64-msvc "1.30.1"
|
lightningcss-win32-x64-msvc "1.30.1"
|
||||||
|
|
||||||
|
locate-path@^6.0.0:
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||||
|
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
|
||||||
|
dependencies:
|
||||||
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
lodash@^4.17.21:
|
lodash@^4.17.21:
|
||||||
version "4.17.21"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
|
||||||
lru-cache@^10.2.0:
|
lru-cache@^10.2.0, lru-cache@^10.4.3:
|
||||||
version "10.4.3"
|
version "10.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||||
|
|
@ -1706,6 +2034,13 @@ math-intrinsics@^1.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||||
|
|
||||||
|
media-query-parser@^2.0.2:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29"
|
||||||
|
integrity sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.12.5"
|
||||||
|
|
||||||
media-typer@0.3.0:
|
media-typer@0.3.0:
|
||||||
version "0.3.0"
|
version "0.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||||
|
|
@ -1767,6 +2102,21 @@ mkdirp@^3.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
|
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
|
||||||
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
|
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
|
||||||
|
|
||||||
|
mlly@^1.4.2, mlly@^1.7.4:
|
||||||
|
version "1.7.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
|
||||||
|
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
|
||||||
|
dependencies:
|
||||||
|
acorn "^8.14.0"
|
||||||
|
pathe "^2.0.1"
|
||||||
|
pkg-types "^1.3.0"
|
||||||
|
ufo "^1.5.4"
|
||||||
|
|
||||||
|
modern-ahocorasick@^1.0.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz#9b1fa15d4f654be20a2ad7ecc44ec9d7645bb420"
|
||||||
|
integrity sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==
|
||||||
|
|
||||||
morgan@^1.10.0:
|
morgan@^1.10.0:
|
||||||
version "1.10.0"
|
version "1.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
|
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
|
||||||
|
|
@ -1874,6 +2224,20 @@ on-headers@~1.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
|
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
|
||||||
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
|
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
|
||||||
|
|
||||||
|
p-limit@^3.0.2:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||||
|
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||||
|
dependencies:
|
||||||
|
yocto-queue "^0.1.0"
|
||||||
|
|
||||||
|
p-locate@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
|
||||||
|
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
|
||||||
|
dependencies:
|
||||||
|
p-limit "^3.0.2"
|
||||||
|
|
||||||
package-json-from-dist@^1.0.0:
|
package-json-from-dist@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
||||||
|
|
@ -1884,6 +2248,11 @@ parseurl@~1.3.3:
|
||||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||||
|
|
||||||
|
path-exists@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||||
|
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
|
||||||
|
|
||||||
path-key@^3.1.0:
|
path-key@^3.1.0:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||||
|
|
@ -1907,12 +2276,12 @@ pathe@^1.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
|
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
|
||||||
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||||
|
|
||||||
pathe@^2.0.3:
|
pathe@^2.0.1, pathe@^2.0.3:
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||||
|
|
||||||
picocolors@^1.1.1:
|
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||||
|
|
@ -1922,6 +2291,15 @@ picomatch@^4.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
|
||||||
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
|
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
|
||||||
|
|
||||||
|
pkg-types@^1.3.0:
|
||||||
|
version "1.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
|
||||||
|
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
|
||||||
|
dependencies:
|
||||||
|
confbox "^0.1.8"
|
||||||
|
mlly "^1.7.4"
|
||||||
|
pathe "^2.0.1"
|
||||||
|
|
||||||
postcss@^8.5.3:
|
postcss@^8.5.3:
|
||||||
version "8.5.4"
|
version "8.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
|
||||||
|
|
@ -1991,6 +2369,19 @@ react-dom@^19.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
scheduler "^0.26.0"
|
scheduler "^0.26.0"
|
||||||
|
|
||||||
|
react-is@^19.2.3:
|
||||||
|
version "19.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
|
||||||
|
integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==
|
||||||
|
|
||||||
|
"react-redux@8.x.x || 9.x.x":
|
||||||
|
version "9.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"
|
||||||
|
integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==
|
||||||
|
dependencies:
|
||||||
|
"@types/use-sync-external-store" "^0.0.6"
|
||||||
|
use-sync-external-store "^1.4.0"
|
||||||
|
|
||||||
react-refresh@^0.14.0:
|
react-refresh@^0.14.0:
|
||||||
version "0.14.2"
|
version "0.14.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
||||||
|
|
@ -2014,6 +2405,43 @@ readdirp@^4.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||||
|
|
||||||
|
recharts@^3.6.0:
|
||||||
|
version "3.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.6.0.tgz#403f0606581153601857e46733277d1411633df3"
|
||||||
|
integrity sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==
|
||||||
|
dependencies:
|
||||||
|
"@reduxjs/toolkit" "1.x.x || 2.x.x"
|
||||||
|
clsx "^2.1.1"
|
||||||
|
decimal.js-light "^2.5.1"
|
||||||
|
es-toolkit "^1.39.3"
|
||||||
|
eventemitter3 "^5.0.1"
|
||||||
|
immer "^10.1.1"
|
||||||
|
react-redux "8.x.x || 9.x.x"
|
||||||
|
reselect "5.1.1"
|
||||||
|
tiny-invariant "^1.3.3"
|
||||||
|
use-sync-external-store "^1.2.2"
|
||||||
|
victory-vendor "^37.0.2"
|
||||||
|
|
||||||
|
redux-thunk@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
|
||||||
|
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
|
||||||
|
|
||||||
|
redux@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
|
||||||
|
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
|
||||||
|
|
||||||
|
"require-like@>= 0.1.1":
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
|
||||||
|
integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
|
||||||
|
|
||||||
|
reselect@5.1.1, reselect@^5.1.0:
|
||||||
|
version "5.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
|
||||||
|
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
|
||||||
|
|
||||||
retry@^0.12.0:
|
retry@^0.12.0:
|
||||||
version "0.12.0"
|
version "0.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
||||||
|
|
@ -2298,6 +2726,11 @@ tar@^7.4.3:
|
||||||
mkdirp "^3.0.1"
|
mkdirp "^3.0.1"
|
||||||
yallist "^5.0.0"
|
yallist "^5.0.0"
|
||||||
|
|
||||||
|
tiny-invariant@^1.3.3:
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||||
|
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
|
||||||
|
|
||||||
tinyglobby@^0.2.13:
|
tinyglobby@^0.2.13:
|
||||||
version "0.2.14"
|
version "0.2.14"
|
||||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
|
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
|
||||||
|
|
@ -2334,11 +2767,21 @@ typescript@^5.8.3:
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
|
||||||
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
|
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
|
||||||
|
|
||||||
|
ufo@^1.5.4:
|
||||||
|
version "1.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
|
||||||
|
integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
|
||||||
|
|
||||||
undici-types@~6.21.0:
|
undici-types@~6.21.0:
|
||||||
version "6.21.0"
|
version "6.21.0"
|
||||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
||||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||||
|
|
||||||
|
undici-types@~7.8.0:
|
||||||
|
version "7.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
|
||||||
|
integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
|
||||||
|
|
||||||
undici@^6.19.2:
|
undici@^6.19.2:
|
||||||
version "6.21.3"
|
version "6.21.3"
|
||||||
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
|
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
|
||||||
|
|
@ -2362,6 +2805,11 @@ update-browserslist-db@^1.1.3:
|
||||||
escalade "^3.2.0"
|
escalade "^3.2.0"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
|
|
||||||
|
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||||
|
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||||
|
|
||||||
utils-merge@1.0.1:
|
utils-merge@1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
|
|
@ -2390,7 +2838,27 @@ vary@~1.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||||
|
|
||||||
vite-node@^3.1.4:
|
victory-vendor@^37.0.2:
|
||||||
|
version "37.3.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da"
|
||||||
|
integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-array" "^3.0.3"
|
||||||
|
"@types/d3-ease" "^3.0.0"
|
||||||
|
"@types/d3-interpolate" "^3.0.1"
|
||||||
|
"@types/d3-scale" "^4.0.2"
|
||||||
|
"@types/d3-shape" "^3.1.0"
|
||||||
|
"@types/d3-time" "^3.0.0"
|
||||||
|
"@types/d3-timer" "^3.0.0"
|
||||||
|
d3-array "^3.1.6"
|
||||||
|
d3-ease "^3.0.1"
|
||||||
|
d3-interpolate "^3.0.1"
|
||||||
|
d3-scale "^4.0.2"
|
||||||
|
d3-shape "^3.1.0"
|
||||||
|
d3-time "^3.0.0"
|
||||||
|
d3-timer "^3.0.1"
|
||||||
|
|
||||||
|
vite-node@^3.1.4, vite-node@^3.2.2:
|
||||||
version "3.2.3"
|
version "3.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
|
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
|
||||||
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
|
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
|
||||||
|
|
@ -2410,7 +2878,7 @@ vite-tsconfig-paths@^5.1.4:
|
||||||
globrex "^0.1.2"
|
globrex "^0.1.2"
|
||||||
tsconfck "^3.0.3"
|
tsconfck "^3.0.3"
|
||||||
|
|
||||||
"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
|
"vite@^5.0.0 || ^6.0.0", "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
|
||||||
version "6.3.5"
|
version "6.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
|
||||||
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
|
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
|
||||||
|
|
@ -2465,3 +2933,8 @@ yallist@^5.0.0:
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
|
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
|
||||||
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
|
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
|
||||||
|
|
||||||
|
yocto-queue@^0.1.0:
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||||
|
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/gabehf/koito/engine"
|
"github.com/gabehf/koito/engine"
|
||||||
)
|
)
|
||||||
|
|
@ -11,7 +13,7 @@ var Version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := engine.Run(
|
if err := engine.Run(
|
||||||
os.Getenv,
|
readEnvOrFile,
|
||||||
os.Stdout,
|
os.Stdout,
|
||||||
Version,
|
Version,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|
@ -19,3 +21,23 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readEnvOrFile(envName string) string {
|
||||||
|
envContent := os.Getenv(envName)
|
||||||
|
|
||||||
|
if envContent == "" {
|
||||||
|
filename := os.Getenv(envName + "_FILE")
|
||||||
|
|
||||||
|
if filename != "" {
|
||||||
|
b, err := os.ReadFile(filename)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load file for %s_FILE (%s): %s", envName, filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envContent = strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envContent
|
||||||
|
}
|
||||||
|
|
|
||||||
48
db/migrations/000003_add_primary_artist.sql
Normal file
48
db/migrations/000003_add_primary_artist.sql
Normal 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);
|
||||||
3
db/migrations/000004_fix_usernames.sql
Normal file
3
db/migrations/000004_fix_usernames.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- +goose Up
|
||||||
|
UPDATE users
|
||||||
|
SET username = LOWER(username);
|
||||||
9
db/migrations/000005_rm_orphan_artist_releases.sql
Normal file
9
db/migrations/000005_rm_orphan_artist_releases.sql
Normal 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
|
||||||
|
);
|
||||||
6
db/migrations/migrations.go
Normal file
6
db/migrations/migrations.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var Files embed.FS
|
||||||
|
|
@ -14,22 +14,24 @@ GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||||
|
|
||||||
-- name: GetTrackArtists :many
|
-- name: GetTrackArtists :many
|
||||||
SELECT
|
SELECT
|
||||||
a.*
|
a.*,
|
||||||
|
at.is_primary as is_primary
|
||||||
FROM artists_with_name a
|
FROM artists_with_name a
|
||||||
LEFT JOIN artist_tracks at ON a.id = at.artist_id
|
LEFT JOIN artist_tracks at ON a.id = at.artist_id
|
||||||
WHERE at.track_id = $1
|
WHERE at.track_id = $1
|
||||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary;
|
||||||
|
|
||||||
-- name: GetArtistByImage :one
|
-- name: GetArtistByImage :one
|
||||||
SELECT * FROM artists WHERE image = $1 LIMIT 1;
|
SELECT * FROM artists WHERE image = $1 LIMIT 1;
|
||||||
|
|
||||||
-- name: GetReleaseArtists :many
|
-- name: GetReleaseArtists :many
|
||||||
SELECT
|
SELECT
|
||||||
a.*
|
a.*,
|
||||||
|
ar.is_primary as is_primary
|
||||||
FROM artists_with_name a
|
FROM artists_with_name a
|
||||||
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
|
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
|
||||||
WHERE ar.release_id = $1
|
WHERE ar.release_id = $1
|
||||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary;
|
||||||
|
|
||||||
-- name: GetArtistByName :one
|
-- name: GetArtistByName :one
|
||||||
WITH artist_with_aliases AS (
|
WITH artist_with_aliases AS (
|
||||||
|
|
@ -54,28 +56,77 @@ LEFT JOIN artist_aliases aa ON a.id = aa.artist_id
|
||||||
WHERE a.musicbrainz_id = $1
|
WHERE a.musicbrainz_id = $1
|
||||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||||
|
|
||||||
|
-- name: GetArtistsWithoutImages :many
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM artists_with_name
|
||||||
|
WHERE image IS NULL
|
||||||
|
AND id > $2
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
-- name: GetTopArtistsPaginated :many
|
-- name: GetTopArtistsPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
|
x.id,
|
||||||
|
x.name,
|
||||||
|
x.musicbrainz_id,
|
||||||
|
x.image,
|
||||||
|
x.listen_count,
|
||||||
|
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
a.id,
|
a.id,
|
||||||
a.name,
|
a.name,
|
||||||
a.musicbrainz_id,
|
a.musicbrainz_id,
|
||||||
a.image,
|
a.image,
|
||||||
COUNT(*) AS listen_count
|
COUNT(*) AS listen_count
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
JOIN artist_tracks at ON at.track_id = t.id
|
JOIN artist_tracks at ON at.track_id = t.id
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
JOIN artists_with_name a ON a.id = at.artist_id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
|
GROUP BY a.id, a.name, a.musicbrainz_id, a.image
|
||||||
ORDER BY listen_count DESC
|
) x
|
||||||
|
ORDER BY x.listen_count DESC, x.id
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetArtistAllTimeRank :one
|
||||||
|
SELECT
|
||||||
|
artist_id,
|
||||||
|
rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
x.artist_id,
|
||||||
|
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
at.artist_id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
|
GROUP BY at.artist_id
|
||||||
|
) x
|
||||||
|
)
|
||||||
|
WHERE artist_id = $1;
|
||||||
|
|
||||||
-- name: CountTopArtists :one
|
-- name: CountTopArtists :one
|
||||||
SELECT COUNT(DISTINCT at.artist_id) AS total_count
|
SELECT COUNT(DISTINCT at.artist_id) AS total_count
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN artist_tracks at ON l.track_id = at.track_id
|
JOIN artist_tracks at ON l.track_id = at.track_id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2;
|
WHERE l.listened_at BETWEEN $1 AND $2;
|
||||||
|
|
||||||
|
-- name: CountNewArtists :one
|
||||||
|
SELECT COUNT(*) AS total_count
|
||||||
|
FROM (
|
||||||
|
SELECT at.artist_id
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
|
GROUP BY at.artist_id
|
||||||
|
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
|
||||||
|
) first_appearances;
|
||||||
|
|
||||||
-- name: UpdateArtistMbzID :exec
|
-- name: UpdateArtistMbzID :exec
|
||||||
UPDATE artists SET musicbrainz_id = $2
|
UPDATE artists SET musicbrainz_id = $2
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l);
|
DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l);
|
||||||
DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t);
|
DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t);
|
||||||
-- DELETE FROM releases WHERE release_group_id NOT IN (SELECT t.release_group_id FROM tracks t);
|
|
||||||
-- DELETE FROM releases WHERE release_group_id NOT IN (SELECT rg.id FROM release_groups rg);
|
|
||||||
DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at);
|
DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at);
|
||||||
|
DELETE FROM artist_releases ar
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM artist_tracks at
|
||||||
|
JOIN tracks t ON at.track_id = t.id
|
||||||
|
WHERE at.artist_id = ar.artist_id
|
||||||
|
AND t.release_id = ar.release_id
|
||||||
|
);
|
||||||
END $$;
|
END $$;
|
||||||
|
|
|
||||||
139
db/queries/interest.sql
Normal file
139
db/queries/interest.sql
Normal 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;
|
||||||
|
|
@ -8,12 +8,7 @@ SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
(
|
get_artists_for_track(t.id) AS artists
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artist_tracks at
|
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
|
||||||
WHERE at.track_id = t.id
|
|
||||||
) AS artists
|
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
|
|
@ -25,12 +20,7 @@ SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
(
|
get_artists_for_track(t.id) AS artists
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artist_tracks at
|
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
|
||||||
WHERE at.track_id = t.id
|
|
||||||
) AS artists
|
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
JOIN artist_tracks at ON t.id = at.track_id
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
|
|
@ -39,17 +29,22 @@ WHERE at.artist_id = $5
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetFirstListenFromArtist :one
|
||||||
|
SELECT
|
||||||
|
l.*
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
|
WHERE at.artist_id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetLastListensFromReleasePaginated :many
|
-- name: GetLastListensFromReleasePaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
(
|
get_artists_for_track(t.id) AS artists
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artist_tracks at
|
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
|
||||||
WHERE at.track_id = t.id
|
|
||||||
) AS artists
|
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
|
|
@ -57,17 +52,21 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetFirstListenFromRelease :one
|
||||||
|
SELECT
|
||||||
|
l.*
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE t.release_id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetLastListensFromTrackPaginated :many
|
-- name: GetLastListensFromTrackPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
(
|
get_artists_for_track(t.id) AS artists
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artist_tracks at
|
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
|
||||||
WHERE at.track_id = t.id
|
|
||||||
) AS artists
|
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
|
|
@ -75,6 +74,22 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetFirstListenFromTrack :one
|
||||||
|
SELECT
|
||||||
|
l.*
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
ORDER BY l.listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetFirstListen :one
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM listens
|
||||||
|
ORDER BY listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: CountListens :one
|
-- name: CountListens :one
|
||||||
SELECT COUNT(*) AS total_count
|
SELECT COUNT(*) AS total_count
|
||||||
FROM listens l
|
FROM listens l
|
||||||
|
|
@ -129,90 +144,51 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
AND t.id = $3;
|
AND t.id = $3;
|
||||||
|
|
||||||
-- name: ListenActivity :many
|
-- name: ListenActivity :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
bucketed_listens AS (
|
FROM listens
|
||||||
SELECT
|
WHERE listened_at >= $2
|
||||||
b.bucket_start,
|
AND listened_at < $3
|
||||||
COUNT(l.listened_at) AS listen_count
|
GROUP BY day
|
||||||
FROM buckets b
|
ORDER BY day;
|
||||||
LEFT JOIN listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: ListenActivityForArtist :many
|
-- name: ListenActivityForArtist :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.*
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
JOIN artist_tracks t ON l.track_id = t.track_id
|
WHERE l.listened_at >= $2
|
||||||
WHERE t.artist_id = $4
|
AND l.listened_at < $3
|
||||||
),
|
AND at.artist_id = $4
|
||||||
bucketed_listens AS (
|
GROUP BY day
|
||||||
SELECT
|
ORDER BY day;
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: ListenActivityForRelease :many
|
-- name: ListenActivityForRelease :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.*
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
WHERE l.listened_at >= $2
|
||||||
JOIN tracks t ON l.track_id = t.id
|
AND l.listened_at < $3
|
||||||
WHERE t.release_id = $4
|
AND t.release_id = $4
|
||||||
),
|
GROUP BY day
|
||||||
bucketed_listens AS (
|
ORDER BY day;
|
||||||
SELECT
|
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: ListenActivityForTrack :many
|
-- name: ListenActivityForTrack :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.*
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
WHERE l.listened_at >= $2
|
||||||
JOIN tracks t ON l.track_id = t.id
|
AND l.listened_at < $3
|
||||||
WHERE t.id = $4
|
AND t.id = $4
|
||||||
),
|
GROUP BY day
|
||||||
bucketed_listens AS (
|
ORDER BY day;
|
||||||
SELECT
|
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: UpdateTrackIdForListens :exec
|
-- name: UpdateTrackIdForListens :exec
|
||||||
UPDATE listens SET track_id = $2
|
UPDATE listens SET track_id = $2
|
||||||
|
|
@ -220,3 +196,70 @@ WHERE track_id = $1;
|
||||||
|
|
||||||
-- name: DeleteListen :exec
|
-- name: DeleteListen :exec
|
||||||
DELETE FROM listens WHERE track_id = $1 AND listened_at = $2;
|
DELETE FROM listens WHERE track_id = $1 AND listened_at = $2;
|
||||||
|
|
||||||
|
-- name: GetListensExportPage :many
|
||||||
|
SELECT
|
||||||
|
l.listened_at,
|
||||||
|
l.user_id,
|
||||||
|
l.client,
|
||||||
|
|
||||||
|
-- Track info
|
||||||
|
t.id AS track_id,
|
||||||
|
t.musicbrainz_id AS track_mbid,
|
||||||
|
t.duration AS track_duration,
|
||||||
|
(
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'alias', ta.alias,
|
||||||
|
'source', ta.source,
|
||||||
|
'is_primary', ta.is_primary
|
||||||
|
))
|
||||||
|
FROM track_aliases ta
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
) AS track_aliases,
|
||||||
|
|
||||||
|
-- Release info
|
||||||
|
r.id AS release_id,
|
||||||
|
r.musicbrainz_id AS release_mbid,
|
||||||
|
r.image AS release_image,
|
||||||
|
r.image_source AS release_image_source,
|
||||||
|
r.various_artists,
|
||||||
|
(
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'alias', ra.alias,
|
||||||
|
'source', ra.source,
|
||||||
|
'is_primary', ra.is_primary
|
||||||
|
))
|
||||||
|
FROM release_aliases ra
|
||||||
|
WHERE ra.release_id = r.id
|
||||||
|
) AS release_aliases,
|
||||||
|
|
||||||
|
-- Artists
|
||||||
|
(
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'id', a.id,
|
||||||
|
'musicbrainz_id', a.musicbrainz_id,
|
||||||
|
'image', a.image,
|
||||||
|
'image_source', a.image_source,
|
||||||
|
'aliases', (
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'alias', aa.alias,
|
||||||
|
'source', aa.source,
|
||||||
|
'is_primary', aa.is_primary
|
||||||
|
))
|
||||||
|
FROM artist_aliases aa
|
||||||
|
WHERE aa.artist_id = a.id
|
||||||
|
)
|
||||||
|
))
|
||||||
|
FROM artist_tracks at
|
||||||
|
JOIN artists a ON a.id = at.artist_id
|
||||||
|
WHERE at.track_id = t.id
|
||||||
|
) AS artists
|
||||||
|
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
JOIN releases r ON t.release_id = r.id
|
||||||
|
|
||||||
|
WHERE l.user_id = @user_id::int
|
||||||
|
AND (l.listened_at, l.track_id) > (@listened_at::timestamptz, @track_id::int)
|
||||||
|
ORDER BY l.listened_at, l.track_id
|
||||||
|
LIMIT $1;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetRelease :one
|
-- name: GetRelease :one
|
||||||
SELECT * FROM releases_with_title
|
SELECT
|
||||||
|
*,
|
||||||
|
get_artists_for_release(id) AS artists
|
||||||
|
FROM releases_with_title
|
||||||
WHERE id = $1 LIMIT 1;
|
WHERE id = $1 LIMIT 1;
|
||||||
|
|
||||||
-- name: GetReleaseByMbzID :one
|
-- name: GetReleaseByMbzID :one
|
||||||
|
|
@ -29,44 +32,76 @@ JOIN artist_releases ar ON r.id = ar.release_id
|
||||||
WHERE r.title = ANY ($1::TEXT[]) AND ar.artist_id = $2
|
WHERE r.title = ANY ($1::TEXT[]) AND ar.artist_id = $2
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetReleaseByArtistAndTitlesNoMbzID :one
|
||||||
|
SELECT r.*
|
||||||
|
FROM releases_with_title r
|
||||||
|
JOIN artist_releases ar ON r.id = ar.release_id
|
||||||
|
WHERE r.title = ANY ($1::TEXT[])
|
||||||
|
AND ar.artist_id = $2
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM releases r2
|
||||||
|
WHERE r2.id = r.id
|
||||||
|
AND r2.musicbrainz_id IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- name: GetTopReleasesFromArtist :many
|
-- name: GetTopReleasesFromArtist :many
|
||||||
SELECT
|
SELECT
|
||||||
r.*,
|
x.*,
|
||||||
COUNT(*) AS listen_count,
|
get_artists_for_release(x.id) AS artists,
|
||||||
(
|
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
FROM (
|
||||||
FROM artists_with_name a
|
SELECT
|
||||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
r.*,
|
||||||
WHERE ar.release_id = r.id
|
COUNT(*) AS listen_count
|
||||||
) AS artists
|
FROM listens l
|
||||||
FROM listens l
|
JOIN tracks t ON l.track_id = t.id
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN releases_with_title r ON t.release_id = r.id
|
||||||
JOIN releases_with_title r ON t.release_id = r.id
|
JOIN artist_releases ar ON r.id = ar.release_id
|
||||||
JOIN artist_releases ar ON r.id = ar.release_id
|
WHERE ar.artist_id = $5
|
||||||
WHERE ar.artist_id = $5
|
AND l.listened_at BETWEEN $1 AND $2
|
||||||
AND l.listened_at BETWEEN $1 AND $2
|
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
) x
|
||||||
ORDER BY listen_count DESC
|
ORDER BY listen_count DESC, x.id
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
-- name: GetTopReleasesPaginated :many
|
-- name: GetTopReleasesPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
r.*,
|
x.*,
|
||||||
COUNT(*) AS listen_count,
|
get_artists_for_release(x.id) AS artists,
|
||||||
(
|
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
FROM (
|
||||||
FROM artists_with_name a
|
SELECT
|
||||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
r.*,
|
||||||
WHERE ar.release_id = r.id
|
COUNT(*) AS listen_count
|
||||||
) AS artists
|
FROM listens l
|
||||||
FROM listens l
|
JOIN tracks t ON l.track_id = t.id
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN releases_with_title r ON t.release_id = r.id
|
||||||
JOIN releases_with_title r ON t.release_id = r.id
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
) x
|
||||||
ORDER BY listen_count DESC
|
ORDER BY listen_count DESC, x.id
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
|
-- name: GetReleaseAllTimeRank :one
|
||||||
|
SELECT
|
||||||
|
release_id,
|
||||||
|
rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
x.release_id,
|
||||||
|
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
t.release_id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
GROUP BY t.release_id
|
||||||
|
) x
|
||||||
|
)
|
||||||
|
WHERE release_id = $1;
|
||||||
|
|
||||||
-- name: CountTopReleases :one
|
-- name: CountTopReleases :one
|
||||||
SELECT COUNT(DISTINCT r.id) AS total_count
|
SELECT COUNT(DISTINCT r.id) AS total_count
|
||||||
FROM listens l
|
FROM listens l
|
||||||
|
|
@ -80,20 +115,25 @@ FROM releases r
|
||||||
JOIN artist_releases ar ON r.id = ar.release_id
|
JOIN artist_releases ar ON r.id = ar.release_id
|
||||||
WHERE ar.artist_id = $1;
|
WHERE ar.artist_id = $1;
|
||||||
|
|
||||||
|
-- name: CountNewReleases :one
|
||||||
|
SELECT COUNT(*) AS total_count
|
||||||
|
FROM (
|
||||||
|
SELECT t.release_id
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
GROUP BY t.release_id
|
||||||
|
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
|
||||||
|
) first_appearances;
|
||||||
|
|
||||||
-- name: AssociateArtistToRelease :exec
|
-- name: AssociateArtistToRelease :exec
|
||||||
INSERT INTO artist_releases (artist_id, release_id)
|
INSERT INTO artist_releases (artist_id, release_id, is_primary)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- name: GetReleasesWithoutImages :many
|
-- name: GetReleasesWithoutImages :many
|
||||||
SELECT
|
SELECT
|
||||||
r.*,
|
r.*,
|
||||||
(
|
get_artists_for_release(r.id) AS artists
|
||||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artists_with_name a
|
|
||||||
JOIN artist_releases ar ON a.id = ar.artist_id
|
|
||||||
WHERE ar.release_id = r.id
|
|
||||||
) AS artists
|
|
||||||
FROM releases_with_title r
|
FROM releases_with_title r
|
||||||
WHERE r.image IS NULL
|
WHERE r.image IS NULL
|
||||||
AND r.id > $2
|
AND r.id > $2
|
||||||
|
|
@ -104,6 +144,14 @@ LIMIT $1;
|
||||||
UPDATE releases SET musicbrainz_id = $2
|
UPDATE releases SET musicbrainz_id = $2
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateReleaseVariousArtists :exec
|
||||||
|
UPDATE releases SET various_artists = $2
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateReleasePrimaryArtist :exec
|
||||||
|
UPDATE artist_releases SET is_primary = $3
|
||||||
|
WHERE artist_id = $1 AND release_id = $2;
|
||||||
|
|
||||||
-- name: UpdateReleaseImage :exec
|
-- name: UpdateReleaseImage :exec
|
||||||
UPDATE releases SET image = $2, image_source = $3
|
UPDATE releases SET image = $2, image_source = $3
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,7 @@ SELECT
|
||||||
ranked.release_id,
|
ranked.release_id,
|
||||||
ranked.image,
|
ranked.image,
|
||||||
ranked.score,
|
ranked.score,
|
||||||
(
|
get_artists_for_track(ranked.id) AS artists
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artist_tracks at
|
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
|
||||||
WHERE at.track_id = ranked.id
|
|
||||||
) AS artists
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
|
|
@ -74,12 +69,7 @@ SELECT
|
||||||
ranked.release_id,
|
ranked.release_id,
|
||||||
ranked.image,
|
ranked.image,
|
||||||
ranked.score,
|
ranked.score,
|
||||||
(
|
get_artists_for_track(ranked.id) AS artists
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artist_tracks at
|
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
|
||||||
WHERE at.track_id = ranked.id
|
|
||||||
) AS artists
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
|
|
@ -106,12 +96,7 @@ SELECT
|
||||||
ranked.image,
|
ranked.image,
|
||||||
ranked.various_artists,
|
ranked.various_artists,
|
||||||
ranked.score,
|
ranked.score,
|
||||||
(
|
get_artists_for_release(ranked.id) AS artists
|
||||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artists_with_name a
|
|
||||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
|
||||||
WHERE ar.release_id = ranked.id
|
|
||||||
) AS artists
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
|
|
@ -137,12 +122,7 @@ SELECT
|
||||||
ranked.image,
|
ranked.image,
|
||||||
ranked.various_artists,
|
ranked.various_artists,
|
||||||
ranked.score,
|
ranked.score,
|
||||||
(
|
get_artists_for_release(ranked.id) AS artists
|
||||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
|
||||||
FROM artists_with_name a
|
|
||||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
|
||||||
WHERE ar.release_id = ranked.id
|
|
||||||
) AS artists
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ VALUES ($1, $2, $3)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: AssociateArtistToTrack :exec
|
-- name: AssociateArtistToTrack :exec
|
||||||
INSERT INTO artist_tracks (artist_id, track_id)
|
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- name: GetTrack :one
|
-- name: GetTrack :one
|
||||||
SELECT
|
SELECT
|
||||||
t.*,
|
t.*,
|
||||||
|
get_artists_for_track(t.id) AS artists,
|
||||||
r.image
|
r.image
|
||||||
FROM tracks_with_title t
|
FROM tracks_with_title t
|
||||||
JOIN releases r ON t.release_id = r.id
|
JOIN releases r ON t.release_id = r.id
|
||||||
|
|
@ -26,83 +27,112 @@ FROM tracks_with_title t
|
||||||
JOIN artist_tracks at ON t.id = at.track_id
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
WHERE at.artist_id = $1;
|
WHERE at.artist_id = $1;
|
||||||
|
|
||||||
-- name: GetTrackByTitleAndArtists :one
|
-- name: GetTrackByTrackInfo :one
|
||||||
SELECT t.*
|
SELECT t.*
|
||||||
FROM tracks_with_title t
|
FROM tracks_with_title t
|
||||||
JOIN artist_tracks at ON at.track_id = t.id
|
JOIN artist_tracks at ON at.track_id = t.id
|
||||||
WHERE t.title = $1
|
WHERE t.title = $1
|
||||||
AND at.artist_id = ANY($2::int[])
|
AND at.artist_id = ANY($3::int[])
|
||||||
|
AND t.release_id = $2
|
||||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id
|
GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id
|
||||||
HAVING COUNT(DISTINCT at.artist_id) = cardinality($2::int[]);
|
HAVING COUNT(DISTINCT at.artist_id) = cardinality($3::int[]);
|
||||||
|
|
||||||
-- name: GetTopTracksPaginated :many
|
-- name: GetTopTracksPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
x.track_id AS id,
|
||||||
t.title,
|
t.title,
|
||||||
t.musicbrainz_id,
|
t.musicbrainz_id,
|
||||||
t.release_id,
|
t.release_id,
|
||||||
r.image,
|
r.image,
|
||||||
COUNT(*) AS listen_count,
|
x.listen_count,
|
||||||
(
|
get_artists_for_track(x.track_id) AS artists,
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
x.rank
|
||||||
FROM artist_tracks at
|
FROM (
|
||||||
JOIN artists_with_name a ON a.id = at.artist_id
|
SELECT
|
||||||
WHERE at.track_id = t.id
|
track_id,
|
||||||
) AS artists
|
COUNT(*) AS listen_count,
|
||||||
FROM listens l
|
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
FROM listens
|
||||||
|
WHERE listened_at BETWEEN $1 AND $2
|
||||||
|
GROUP BY track_id
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
) x
|
||||||
|
JOIN tracks_with_title t ON x.track_id = t.id
|
||||||
JOIN releases r ON t.release_id = r.id
|
JOIN releases r ON t.release_id = r.id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
ORDER BY x.listen_count DESC, x.track_id;
|
||||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
|
||||||
ORDER BY listen_count DESC
|
|
||||||
LIMIT $3 OFFSET $4;
|
|
||||||
|
|
||||||
-- name: GetTopTracksByArtistPaginated :many
|
-- name: GetTopTracksByArtistPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
x.track_id AS id,
|
||||||
t.title,
|
t.title,
|
||||||
t.musicbrainz_id,
|
t.musicbrainz_id,
|
||||||
t.release_id,
|
t.release_id,
|
||||||
r.image,
|
r.image,
|
||||||
COUNT(*) AS listen_count,
|
x.listen_count,
|
||||||
(
|
get_artists_for_track(x.track_id) AS artists,
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
x.rank
|
||||||
FROM artist_tracks at2
|
FROM (
|
||||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
SELECT
|
||||||
WHERE at2.track_id = t.id
|
l.track_id,
|
||||||
) AS artists
|
COUNT(*) AS listen_count,
|
||||||
FROM listens l
|
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
FROM listens l
|
||||||
|
JOIN artist_tracks at ON l.track_id = at.track_id
|
||||||
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
|
AND at.artist_id = $5
|
||||||
|
GROUP BY l.track_id
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
) x
|
||||||
|
JOIN tracks_with_title t ON x.track_id = t.id
|
||||||
JOIN releases r ON t.release_id = r.id
|
JOIN releases r ON t.release_id = r.id
|
||||||
JOIN artist_tracks at ON at.track_id = t.id
|
ORDER BY x.listen_count DESC, x.track_id;
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
|
||||||
AND at.artist_id = $5
|
|
||||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
|
||||||
ORDER BY listen_count DESC
|
|
||||||
LIMIT $3 OFFSET $4;
|
|
||||||
|
|
||||||
-- name: GetTopTracksInReleasePaginated :many
|
-- name: GetTopTracksInReleasePaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
x.track_id AS id,
|
||||||
t.title,
|
t.title,
|
||||||
t.musicbrainz_id,
|
t.musicbrainz_id,
|
||||||
t.release_id,
|
t.release_id,
|
||||||
r.image,
|
r.image,
|
||||||
COUNT(*) AS listen_count,
|
x.listen_count,
|
||||||
(
|
get_artists_for_track(x.track_id) AS artists,
|
||||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
x.rank
|
||||||
FROM artist_tracks at2
|
FROM (
|
||||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
SELECT
|
||||||
WHERE at2.track_id = t.id
|
l.track_id,
|
||||||
) AS artists
|
COUNT(*) AS listen_count,
|
||||||
FROM listens l
|
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
FROM listens l
|
||||||
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
|
AND t.release_id = $5
|
||||||
|
GROUP BY l.track_id
|
||||||
|
ORDER BY listen_count DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
) x
|
||||||
|
JOIN tracks_with_title t ON x.track_id = t.id
|
||||||
JOIN releases r ON t.release_id = r.id
|
JOIN releases r ON t.release_id = r.id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
ORDER BY x.listen_count DESC, x.track_id;
|
||||||
AND t.release_id = $5
|
|
||||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
-- name: GetTrackAllTimeRank :one
|
||||||
ORDER BY listen_count DESC
|
SELECT
|
||||||
LIMIT $3 OFFSET $4;
|
id,
|
||||||
|
rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
x.id,
|
||||||
|
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
|
GROUP BY t.id) x
|
||||||
|
) y
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: CountTopTracks :one
|
-- name: CountTopTracks :one
|
||||||
SELECT COUNT(DISTINCT l.track_id) AS total_count
|
SELECT COUNT(DISTINCT l.track_id) AS total_count
|
||||||
|
|
@ -123,6 +153,15 @@ JOIN tracks t ON l.track_id = t.id
|
||||||
WHERE l.listened_at BETWEEN $1 AND $2
|
WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
AND t.release_id = $3;
|
AND t.release_id = $3;
|
||||||
|
|
||||||
|
-- name: CountNewTracks :one
|
||||||
|
SELECT COUNT(*) AS total_count
|
||||||
|
FROM (
|
||||||
|
SELECT track_id
|
||||||
|
FROM listens
|
||||||
|
GROUP BY track_id
|
||||||
|
HAVING MIN(listened_at) BETWEEN $1 AND $2
|
||||||
|
) first_appearances;
|
||||||
|
|
||||||
-- name: UpdateTrackMbzID :exec
|
-- name: UpdateTrackMbzID :exec
|
||||||
UPDATE tracks SET musicbrainz_id = $2
|
UPDATE tracks SET musicbrainz_id = $2
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
@ -135,5 +174,19 @@ WHERE id = $1;
|
||||||
UPDATE tracks SET release_id = $2
|
UPDATE tracks SET release_id = $2
|
||||||
WHERE release_id = $1;
|
WHERE release_id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateTrackPrimaryArtist :exec
|
||||||
|
UPDATE artist_tracks SET is_primary = $3
|
||||||
|
WHERE artist_id = $1 AND track_id = $2;
|
||||||
|
|
||||||
-- name: DeleteTrack :exec
|
-- name: DeleteTrack :exec
|
||||||
DELETE FROM tracks WHERE id = $1;
|
DELETE FROM tracks WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetTracksWithNoDurationButHaveMbzID :many
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM tracks_with_title
|
||||||
|
WHERE duration = 0
|
||||||
|
AND musicbrainz_id IS NOT NULL
|
||||||
|
AND id > $2
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT $1;
|
||||||
|
|
|
||||||
374
db/queries/year.sql
Normal file
374
db/queries/year.sql
Normal 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;
|
||||||
|
|
@ -1,53 +1,65 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
import starlight from '@astrojs/starlight';
|
import starlight from "@astrojs/starlight";
|
||||||
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
head: [
|
head: [
|
||||||
{
|
{
|
||||||
tag: 'script',
|
tag: "script",
|
||||||
attrs: {
|
attrs: {
|
||||||
src: 'https://static.cloudflareinsights.com/beacon.min.js',
|
src: "https://static.cloudflareinsights.com/beacon.min.js",
|
||||||
'data-cf-beacon': '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
|
"data-cf-beacon": '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
|
||||||
defer: true,
|
defer: true,
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Koito',
|
|
||||||
logo: {
|
|
||||||
src: './src/assets/logo_text.png',
|
|
||||||
replacesTitle: true,
|
|
||||||
},
|
},
|
||||||
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gabehf/koito' }],
|
},
|
||||||
sidebar: [
|
],
|
||||||
{
|
title: "Koito",
|
||||||
label: 'Guides',
|
logo: {
|
||||||
items: [
|
src: "./src/assets/logo_text.png",
|
||||||
// Each item here is one entry in the navigation menu.
|
replacesTitle: true,
|
||||||
{ label: 'Installation', slug: 'guides/installation' },
|
},
|
||||||
{ label: 'Importing Data', slug: 'guides/importing' },
|
social: [
|
||||||
{ label: 'Setting up the Scrobbler', slug: 'guides/scrobbler' },
|
{
|
||||||
{ label: 'Editing Data', slug: 'guides/editing' },
|
icon: "github",
|
||||||
],
|
label: "GitHub",
|
||||||
},
|
href: "https://github.com/gabehf/koito",
|
||||||
{
|
},
|
||||||
label: 'Reference',
|
],
|
||||||
items: [
|
sidebar: [
|
||||||
{ label: 'Configuration Options', slug: 'reference/configuration' },
|
{
|
||||||
]
|
label: "Guides",
|
||||||
},
|
items: [
|
||||||
|
// Each item here is one entry in the navigation menu.
|
||||||
|
{ label: "Installation", slug: "guides/installation" },
|
||||||
|
{ label: "Importing Data", slug: "guides/importing" },
|
||||||
|
{ label: "Setting up the Scrobbler", slug: "guides/scrobbler" },
|
||||||
|
{ label: "Editing Data", slug: "guides/editing" },
|
||||||
],
|
],
|
||||||
customCss: [
|
},
|
||||||
// Path to your Tailwind base styles:
|
{
|
||||||
'./src/styles/global.css',
|
label: "Quickstart",
|
||||||
],
|
items: [
|
||||||
}),
|
{ label: "Setup with Navidrome", slug: "quickstart/navidrome" },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reference",
|
||||||
|
items: [
|
||||||
|
{ label: "Configuration Options", slug: "reference/configuration" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customCss: [
|
||||||
|
// Path to your Tailwind base styles:
|
||||||
|
"./src/styles/global.css",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
site: "https://koito.io",
|
site: "https://koito.io",
|
||||||
|
|
||||||
|
|
|
||||||
BIN
docs/src/assets/navidrome_lbz_switch.png
Normal file
BIN
docs/src/assets/navidrome_lbz_switch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
|
|
@ -60,6 +60,8 @@ Once merged, we can see that all of the listen activity for Tsumugu has been asi
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
You can also search for items when merging by their ID using the format `id:1234`.
|
||||||
|
|
||||||
#### Deleting Items
|
#### Deleting Items
|
||||||
|
|
||||||
To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children
|
To delete at item, just click the trash icon, which is the fourth and final icon in the editing options. Doing so will open a confirmation dialogue. Once confirmed, the item you delete, as well as all of its children
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from:
|
||||||
:::note
|
:::note
|
||||||
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
|
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
|
||||||
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
|
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
|
||||||
means that artist aliases will not be automatically fetched for imported artists. This also means that artists will not be associated with their MusicBrainz IDs internally,
|
means that artist aliases will not be automatically fetched for imported artists. You can also use
|
||||||
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
|
|
||||||
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
|
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
|
||||||
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
|
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ title: Setting up the Scrobber
|
||||||
description: How to relay listens submitted to Koito to another ListenBrainz compatible server.
|
description: How to relay listens submitted to Koito to another ListenBrainz compatible server.
|
||||||
---
|
---
|
||||||
|
|
||||||
To use the ListenBrainz API, you need to get your generated api key from the UI.
|
To use the ListenBrainz API, you need to get your generated API key from the UI. The API key is what you will use as the ListenBrainz token.
|
||||||
|
|
||||||
First, open the settings in your Koito instance by clicking on the settings icon or pressing `\`.
|
First, open the settings in your Koito instance by clicking on the settings icon or pressing `\`.
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ After logging in, open the settings menu again and find the `API Keys` tab. On t
|
||||||
If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it.
|
If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` and provide the api key from the UI as the token.
|
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` (or `{your_koito_address}/apis/listenbrainz` for some applications) and provide the API key from the UI as the token.
|
||||||
|
|
||||||
## Set up a relay
|
## Set up a relay
|
||||||
|
|
||||||
|
|
@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
|
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
|
||||||
|
For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,12 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||||
Koito can be connected to any music server or client that allows for custom ListenBrainz URLs.
|
Koito can be connected to any music server or client that allows for custom ListenBrainz URLs.
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Scrobbler relay" icon="rocket">
|
<Card title="Scrobbler relay" icon="rocket">
|
||||||
Automatically relay listens submitted to your Koito instance to other ListenBrainz compatble servers.
|
Automatically relay listens submitted to your Koito instance to other ListenBrainz compatible servers.
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Automatic data fetching" icon="download">
|
<Card title="Automatic data fetching" icon="download">
|
||||||
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
|
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Themeable" icon="seti:css">
|
<Card title="Themeable" icon="seti:css">
|
||||||
Koito ships with twelve different themes, with custom theme options to be added soon™.
|
Koito ships with twelve different themes, now with support for custom themes!
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
|
||||||
68
docs/src/content/docs/quickstart/navidrome.md
Normal file
68
docs/src/content/docs/quickstart/navidrome.md
Normal 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.
|
||||||
|

|
||||||
|
|
||||||
|
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!
|
||||||
|
|
@ -5,6 +5,12 @@ description: The available configuration options when setting up Koito.
|
||||||
|
|
||||||
Koito is configured using **environment variables**. This is the full list of configuration options supported by Koito.
|
Koito is configured using **environment variables**. This is the full list of configuration options supported by Koito.
|
||||||
|
|
||||||
|
The suffix `_FILE` is also supported for every environment variable. This allows the use of Docker secrets, for example: `KOITO_DATABASE_URL_FILE=/run/secrets/database-url` will load the content of the file at `/run/secrets/database-url` for the environment variable `KOITO_DATABASE_URL`.
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
If the environment variable is defined without **and** with the suffix at the same time, the content of the environment variable without the `_FILE` suffix will have the higher priority.
|
||||||
|
:::
|
||||||
|
|
||||||
##### KOITO_DATABASE_URL
|
##### KOITO_DATABASE_URL
|
||||||
- Required: `true`
|
- Required: `true`
|
||||||
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
|
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
|
||||||
|
|
@ -17,6 +23,12 @@ Koito is configured using **environment variables**. This is the full list of co
|
||||||
##### KOITO_DEFAULT_PASSWORD
|
##### KOITO_DEFAULT_PASSWORD
|
||||||
- Default: `changeme`
|
- Default: `changeme`
|
||||||
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
|
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
|
||||||
|
##### KOITO_DEFAULT_THEME
|
||||||
|
- Default: `yuu`
|
||||||
|
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
|
||||||
|
##### KOITO_LOGIN_GATE
|
||||||
|
- Default: `false`
|
||||||
|
- Description: When `true`, Koito will not show any statistics unless the user is logged in.
|
||||||
##### KOITO_BIND_ADDR
|
##### KOITO_BIND_ADDR
|
||||||
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
|
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
|
||||||
##### KOITO_LISTEN_PORT
|
##### KOITO_LISTEN_PORT
|
||||||
|
|
@ -31,6 +43,9 @@ Koito is configured using **environment variables**. This is the full list of co
|
||||||
##### KOITO_LOG_LEVEL
|
##### KOITO_LOG_LEVEL
|
||||||
- Default: `info`
|
- Default: `info`
|
||||||
- Description: One of `debug | info | warn | error | fatal`
|
- Description: One of `debug | info | warn | error | fatal`
|
||||||
|
##### KOITO_ARTIST_SEPARATORS_REGEX
|
||||||
|
- Default: `\s+·\s+`
|
||||||
|
- Description: The list of regex patterns Koito will use to separate artist strings, separated by two semicolons (`;;`).
|
||||||
##### KOITO_MUSICBRAINZ_URL
|
##### KOITO_MUSICBRAINZ_URL
|
||||||
- Default: `https://musicbrainz.org`
|
- Default: `https://musicbrainz.org`
|
||||||
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
|
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
|
||||||
|
|
@ -49,6 +64,8 @@ Koito is configured using **environment variables**. This is the full list of co
|
||||||
##### KOITO_CONFIG_DIR
|
##### KOITO_CONFIG_DIR
|
||||||
- Default: `/etc/koito`
|
- Default: `/etc/koito`
|
||||||
- Description: The location where import folders and image caches are stored.
|
- Description: The location where import folders and image caches are stored.
|
||||||
|
##### KOITO_FORCE_TZ
|
||||||
|
- Description: A canonical IANA database time zone name (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) that Koito will use to serve all clients. Overrides any timezones requested via a `tz` cookie or `tz` query parameter. Koito will fail to start if this value is invalid.
|
||||||
##### KOITO_DISABLE_DEEZER
|
##### KOITO_DISABLE_DEEZER
|
||||||
- Default: `false`
|
- Default: `false`
|
||||||
- Description: Disables Deezer as a source for finding artist and album images.
|
- Description: Disables Deezer as a source for finding artist and album images.
|
||||||
|
|
@ -57,6 +74,19 @@ Koito is configured using **environment variables**. This is the full list of co
|
||||||
- Description: Disables Cover Art Archive as a source for finding album images.
|
- Description: Disables Cover Art Archive as a source for finding album images.
|
||||||
##### KOITO_DISABLE_MUSICBRAINZ
|
##### KOITO_DISABLE_MUSICBRAINZ
|
||||||
- Default: `false`
|
- Default: `false`
|
||||||
|
##### KOITO_SUBSONIC_URL
|
||||||
|
- Required: `true` if KOITO_SUBSONIC_PARAMS is set
|
||||||
|
- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`.
|
||||||
|
##### KOITO_SUBSONIC_PARAMS
|
||||||
|
- Required: `true` if KOITO_SUBSONIC_URL is set
|
||||||
|
- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request.
|
||||||
|
:::caution
|
||||||
|
If Koito is unable to validate your Subsonic configuration, it will fail to start. If you notice your container isn't running after
|
||||||
|
changing these parameters, check the logs!
|
||||||
|
:::
|
||||||
|
##### KOITO_LASTFM_API_KEY
|
||||||
|
- Required: `false`
|
||||||
|
- Description: Your LastFM API key, which will be used for fetching images if provided. You can get an API key [here](https://www.last.fm/api/authentication),
|
||||||
##### KOITO_SKIP_IMPORT
|
##### KOITO_SKIP_IMPORT
|
||||||
- Default: `false`
|
- Default: `false`
|
||||||
- Description: Skips running the importer on startup.
|
- Description: Skips running the importer on startup.
|
||||||
|
|
@ -70,6 +100,9 @@ Koito is configured using **environment variables**. This is the full list of co
|
||||||
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
|
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
|
||||||
##### KOITO_IMPORT_AFTER_UNIX
|
##### KOITO_IMPORT_AFTER_UNIX
|
||||||
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
|
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
|
||||||
|
##### KOITO_FETCH_IMAGES_DURING_IMPORT
|
||||||
|
- Default: `false`
|
||||||
|
- Description: When true, images will be downloaded and cached during imports.
|
||||||
##### KOITO_CORS_ALLOWED_ORIGINS
|
##### KOITO_CORS_ALLOWED_ORIGINS
|
||||||
- Default: No CORS policy
|
- Default: No CORS policy
|
||||||
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
||||||
|
|
@ -2,6 +2,7 @@ package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -95,6 +96,10 @@ func Run(
|
||||||
defer store.Close(ctx)
|
defer store.Close(ctx)
|
||||||
l.Info().Msg("Engine: Database connection established")
|
l.Info().Msg("Engine: Database connection established")
|
||||||
|
|
||||||
|
if cfg.ForceTZ() != nil {
|
||||||
|
l.Debug().Msgf("Engine: Forcing the use of timezone '%s'", cfg.ForceTZ().String())
|
||||||
|
}
|
||||||
|
|
||||||
l.Debug().Msg("Engine: Initializing MusicBrainz client")
|
l.Debug().Msg("Engine: Initializing MusicBrainz client")
|
||||||
var mbzC mbz.MusicBrainzCaller
|
var mbzC mbz.MusicBrainzCaller
|
||||||
if !cfg.MusicBrainzDisabled() {
|
if !cfg.MusicBrainzDisabled() {
|
||||||
|
|
@ -105,11 +110,39 @@ func Run(
|
||||||
l.Warn().Msg("Engine: MusicBrainz client disabled")
|
l.Warn().Msg("Engine: MusicBrainz client disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.SubsonicEnabled() {
|
||||||
|
l.Debug().Msg("Engine: Checking Subsonic configuration")
|
||||||
|
pingURL := cfg.SubsonicUrl() + "/rest/ping.view?" + cfg.SubsonicParams() + "&f=json&v=1&c=koito"
|
||||||
|
|
||||||
|
resp, err := http.Get(pingURL)
|
||||||
|
if err != nil {
|
||||||
|
l.Fatal().Err(err).Msg("Engine: Failed to contact Subsonic server! Ensure the provided URL is correct")
|
||||||
|
} else {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
l.Fatal().Err(err).Msg("Engine: Failed to parse Subsonic response")
|
||||||
|
} else if result.Response.Status != "ok" {
|
||||||
|
l.Fatal().Msg("Engine: Provided Subsonic credentials are invalid")
|
||||||
|
} else {
|
||||||
|
l.Info().Msg("Engine: Subsonic credentials validated successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
l.Debug().Msg("Engine: Initializing image sources")
|
l.Debug().Msg("Engine: Initializing image sources")
|
||||||
images.Initialize(images.ImageSourceOpts{
|
images.Initialize(images.ImageSourceOpts{
|
||||||
UserAgent: cfg.UserAgent(),
|
UserAgent: cfg.UserAgent(),
|
||||||
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
||||||
EnableDeezer: !cfg.DeezerDisabled(),
|
EnableDeezer: !cfg.DeezerDisabled(),
|
||||||
|
EnableSubsonic: cfg.SubsonicEnabled(),
|
||||||
|
EnableLastFM: cfg.LastFMApiKey() != "",
|
||||||
})
|
})
|
||||||
l.Info().Msg("Engine: Image sources initialized")
|
l.Info().Msg("Engine: Image sources initialized")
|
||||||
|
|
||||||
|
|
@ -183,6 +216,8 @@ func Run(
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
l.Info().Msg("Engine: Beginning startup tasks...")
|
||||||
|
|
||||||
l.Debug().Msg("Engine: Checking import configuration")
|
l.Debug().Msg("Engine: Checking import configuration")
|
||||||
if !cfg.SkipImport() {
|
if !cfg.SkipImport() {
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -192,6 +227,12 @@ func Run(
|
||||||
|
|
||||||
l.Info().Msg("Engine: Pruning orphaned images")
|
l.Info().Msg("Engine: Pruning orphaned images")
|
||||||
go catalog.PruneOrphanedImages(logger.NewContext(l), store)
|
go catalog.PruneOrphanedImages(logger.NewContext(l), store)
|
||||||
|
l.Info().Msg("Engine: Running duration backfill task")
|
||||||
|
go catalog.BackfillTrackDurationsFromMusicBrainz(ctx, store, mbzC)
|
||||||
|
l.Info().Msg("Engine: Attempting to fetch missing artist images")
|
||||||
|
go catalog.FetchMissingArtistImages(ctx, store)
|
||||||
|
l.Info().Msg("Engine: Attempting to fetch missing album images")
|
||||||
|
go catalog.FetchMissingAlbumImages(ctx, store)
|
||||||
|
|
||||||
l.Info().Msg("Engine: Initialization finished")
|
l.Info().Msg("Engine: Initialization finished")
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
|
|
@ -212,19 +253,19 @@ func Run(
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
||||||
l.Debug().Msg("Checking for import files...")
|
l.Debug().Msg("Importer: Checking for import files...")
|
||||||
files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import"))
|
files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msg("Failed to read files from import dir")
|
l.Err(err).Msg("Importer: Failed to read files from import dir")
|
||||||
}
|
}
|
||||||
if len(files) > 0 {
|
if len(files) > 0 {
|
||||||
l.Info().Msg("Files found in import directory. Attempting to import...")
|
l.Info().Msg("Importer: Files found in import directory. Attempting to import...")
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
l.Error().Interface("recover", r).Msg("Panic when importing files")
|
l.Error().Interface("recover", r).Msg("Importer: Panic when importing files")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|
@ -232,31 +273,37 @@ func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(file.Name(), "Streaming_History_Audio") {
|
if strings.Contains(file.Name(), "Streaming_History_Audio") {
|
||||||
l.Info().Msgf("Import file %s detecting as being Spotify export", file.Name())
|
l.Info().Msgf("Importer: Import file %s detecting as being Spotify export", file.Name())
|
||||||
err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name())
|
err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||||
}
|
}
|
||||||
} else if strings.Contains(file.Name(), "maloja") {
|
} else if strings.Contains(file.Name(), "maloja") {
|
||||||
l.Info().Msgf("Import file %s detecting as being Maloja export", file.Name())
|
l.Info().Msgf("Importer: Import file %s detecting as being Maloja export", file.Name())
|
||||||
err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name())
|
err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||||
}
|
}
|
||||||
} else if strings.Contains(file.Name(), "recenttracks") {
|
} else if strings.Contains(file.Name(), "recenttracks") {
|
||||||
l.Info().Msgf("Import file %s detecting as being ghan.nl LastFM export", file.Name())
|
l.Info().Msgf("Importer: Import file %s detecting as being ghan.nl LastFM export", file.Name())
|
||||||
err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name())
|
err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||||
}
|
}
|
||||||
} else if strings.Contains(file.Name(), "listenbrainz") {
|
} else if strings.Contains(file.Name(), "listenbrainz") {
|
||||||
l.Info().Msgf("Import file %s detecting as being ListenBrainz export", file.Name())
|
l.Info().Msgf("Importer: Import file %s detecting as being ListenBrainz export", file.Name())
|
||||||
err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name())
|
err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||||
|
}
|
||||||
|
} else if strings.Contains(file.Name(), "koito") {
|
||||||
|
l.Info().Msgf("Importer: Import file %s detecting as being Koito export", file.Name())
|
||||||
|
err := importer.ImportKoitoFile(logger.NewContext(l), store, file.Name())
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
l.Warn().Msgf("File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
|
l.Warn().Msgf("Importer: File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue