mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-09 07:28:55 -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:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/**'
|
||||
- "docs/**"
|
||||
- ".github/workflows/**"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -21,9 +24,9 @@ jobs:
|
|||
- name: Install, build, and upload your site output
|
||||
uses: withastro/action@v4
|
||||
with:
|
||||
path: ./docs # The root location of your Astro project inside the repository. (optional)
|
||||
node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional)
|
||||
package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
|
||||
path: ./docs # The root location of your Astro project inside the repository. (optional)
|
||||
node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional)
|
||||
package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
|
|
|||
98
.github/workflows/docker.yml
vendored
98
.github/workflows/docker.yml
vendored
|
|
@ -12,66 +12,64 @@ name: Publish Docker image
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Go Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install libvips
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvips-dev
|
||||
- name: Install libvips
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvips-dev
|
||||
|
||||
- name: Verify libvips install
|
||||
run: vips --version
|
||||
- name: Verify libvips install
|
||||
run: vips --version
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
uses: robherley/go-test-action@v0
|
||||
- name: Test
|
||||
uses: robherley/go-test-action@v0
|
||||
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
name: Push Docker image to Docker Hub (release)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: gabehf/koito
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract tag version
|
||||
id: extract_version
|
||||
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push release image
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
|
@ -81,10 +79,34 @@ jobs:
|
|||
gabehf/koito:${{ env.KOITO_VERSION }}
|
||||
build-args: |
|
||||
KOITO_VERSION=${{ env.KOITO_VERSION }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
push_dev:
|
||||
name: Push Docker image (dev branch)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
subject-name: index.docker.io/gabehf/koito
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push dev image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
gabehf/koito:dev
|
||||
gabehf/koito:dev-${{ github.sha }}
|
||||
build-args: |
|
||||
KOITO_VERSION=dev
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
|
|
|||
32
.github/workflows/test.yml
vendored
Normal file
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
|
||||
|
||||
FROM golang:1.23 AS backend
|
||||
FROM golang:1.24 AS backend
|
||||
|
||||
ARG KOITO_VERSION
|
||||
ENV CGO_ENABLED=1
|
||||
|
|
|
|||
23
Makefile
23
Makefile
|
|
@ -1,3 +1,8 @@
|
|||
ifneq (,$(wildcard ./.env))
|
||||
include .env
|
||||
export
|
||||
endif
|
||||
|
||||
.PHONY: all test clean client
|
||||
|
||||
postgres.schemadump:
|
||||
|
|
@ -10,7 +15,10 @@ postgres.schemadump:
|
|||
-v --dbname="koitodb" -f "/tmp/dump/schema.sql"
|
||||
|
||||
postgres.run:
|
||||
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
||||
docker run --name koito-db -p 5432:5432 -v koito_dev_db:/var/lib/postgresql -e POSTGRES_PASSWORD=secret -d postgres
|
||||
|
||||
postgres.run-scratch:
|
||||
docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
||||
|
||||
postgres.start:
|
||||
docker start koito-db
|
||||
|
|
@ -18,8 +26,17 @@ postgres.start:
|
|||
postgres.stop:
|
||||
docker stop koito-db
|
||||
|
||||
api.debug:
|
||||
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go
|
||||
postgres.remove:
|
||||
docker stop koito-db && docker rm koito-db
|
||||
|
||||
postgres.remove-scratch:
|
||||
docker stop koito-scratch && docker rm koito-scratch
|
||||
|
||||
api.debug: postgres.start
|
||||
go run cmd/api/main.go
|
||||
|
||||
api.scratch: postgres.run-scratch
|
||||
KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go
|
||||
|
||||
api.test:
|
||||
go test ./... -timeout 60s
|
||||
|
|
|
|||
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.
|
||||
It supports relaying to other compatible scrobblers, so you can try it safely without replacing your current setup.
|
||||
|
||||
> This project is currently pre-release, and therefore you can expect rapid development and some bugs. If you don't want to replace your current scrobbler
|
||||
> This project is under active development and still considered "unstable", and therefore you can expect some bugs. If you don't want to replace your current scrobbler
|
||||
with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible
|
||||
scrobbler. This is what I've been doing for the entire development of this app and it hasn't failed me once. Or, you can always use something
|
||||
like [multi-scrobbler](https://github.com/FoxxMD/multi-scrobbler).
|
||||
|
|
@ -23,8 +35,9 @@ You can view my public instance with my listening data at https://koito.mnrva.de
|
|||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||
<img width="2021" height="1330" alt="image" src="https://github.com/user-attachments/assets/956748ff-f61f-4102-94b2-50783d9ee72b" />
|
||||
<img width="1505" height="1018" alt="image" src="https://github.com/user-attachments/assets/5f7e1162-f723-4e4b-a528-06cf26d1d870" />
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -75,6 +88,16 @@ There are currently some known issues that I am actively working on, in addition
|
|||
|
||||
If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#gabehf/koito&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=gabehf/koito&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=gabehf/koito&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=gabehf/koito&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Albums that fueled development + notes
|
||||
|
||||
More relevant here than any of my other projects...
|
||||
|
|
@ -84,5 +107,4 @@ Not just during development, you can see my complete listening data on my [live
|
|||
#### Random notes
|
||||
|
||||
- I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to...
|
||||
- It's funny how you can see the days in my listening history when I was just working on this project because they have way more listens than other days.
|
||||
- About 50% of the reason I built this was minor/not-so-minor greivances with Maloja. Could I have just contributed to Maloja? Maybe, but I like building stuff and I like Koito's UI a lot more anyways.
|
||||
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 {
|
||||
limit: number,
|
||||
period: string,
|
||||
page: number,
|
||||
artist_id?: number,
|
||||
album_id?: number,
|
||||
track_id?: number
|
||||
limit: number;
|
||||
period: string;
|
||||
page: number;
|
||||
artist_id?: number;
|
||||
album_id?: number;
|
||||
track_id?: number;
|
||||
}
|
||||
interface getActivityArgs {
|
||||
step: string
|
||||
range: number
|
||||
month: number
|
||||
year: number
|
||||
artist_id: number
|
||||
album_id: number
|
||||
track_id: number
|
||||
step: string;
|
||||
range: number;
|
||||
month: number;
|
||||
year: number;
|
||||
artist_id: number;
|
||||
album_id: number;
|
||||
track_id: number;
|
||||
}
|
||||
interface timeframe {
|
||||
week?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
period?: string;
|
||||
}
|
||||
interface getInterestArgs {
|
||||
buckets: number;
|
||||
artist_id: number;
|
||||
album_id: number;
|
||||
track_id: number;
|
||||
}
|
||||
|
||||
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
|
||||
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>)
|
||||
async function handleJson<T>(r: Response): Promise<T> {
|
||||
if (!r.ok) {
|
||||
const err = await r.json();
|
||||
throw Error(err.error);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
async function getLastListens(
|
||||
args: getItemsArgs
|
||||
): Promise<PaginatedResponse<Listen>> {
|
||||
const r = await fetch(
|
||||
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
|
||||
);
|
||||
return handleJson<PaginatedResponse<Listen>>(r);
|
||||
}
|
||||
|
||||
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
||||
if (args.artist_id) {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
} else if (args.album_id) {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
} else {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
}
|
||||
async function getTopTracks(
|
||||
args: getItemsArgs
|
||||
): Promise<PaginatedResponse<Ranked<Track>>> {
|
||||
let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||
|
||||
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
|
||||
else if (args.album_id) url += `&album_id=${args.album_id}`;
|
||||
|
||||
const r = await fetch(url);
|
||||
return handleJson<PaginatedResponse<Ranked<Track>>>(r);
|
||||
}
|
||||
|
||||
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
if (args.artist_id) {
|
||||
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
} else {
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
}
|
||||
async function getTopAlbums(
|
||||
args: getItemsArgs
|
||||
): Promise<PaginatedResponse<Ranked<Album>>> {
|
||||
let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
|
||||
|
||||
const r = await fetch(url);
|
||||
return handleJson<PaginatedResponse<Ranked<Album>>>(r);
|
||||
}
|
||||
|
||||
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
|
||||
async function getTopArtists(
|
||||
args: getItemsArgs
|
||||
): Promise<PaginatedResponse<Ranked<Artist>>> {
|
||||
const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||
const r = await fetch(url);
|
||||
return handleJson<PaginatedResponse<Ranked<Artist>>>(r);
|
||||
}
|
||||
|
||||
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
||||
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>)
|
||||
async function getActivity(
|
||||
args: getActivityArgs
|
||||
): Promise<ListenActivityItem[]> {
|
||||
const r = await fetch(
|
||||
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
|
||||
);
|
||||
return handleJson<ListenActivityItem[]>(r);
|
||||
}
|
||||
|
||||
function getStats(period: string): Promise<Stats> {
|
||||
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
|
||||
async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
|
||||
const r = await fetch(
|
||||
`/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
|
||||
);
|
||||
return handleJson<InterestBucket[]>(r);
|
||||
}
|
||||
|
||||
async function getStats(period: string): Promise<Stats> {
|
||||
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
|
||||
|
||||
return handleJson<Stats>(r);
|
||||
}
|
||||
|
||||
function search(q: string): Promise<SearchResponse> {
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
||||
q = encodeURIComponent(q);
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(
|
||||
(r) => r.json() as Promise<SearchResponse>
|
||||
);
|
||||
}
|
||||
|
||||
function imageUrl(id: string, size: string) {
|
||||
if (!id) {
|
||||
id = 'default'
|
||||
}
|
||||
return `/images/${size}/${id}`
|
||||
if (!id) {
|
||||
id = "default";
|
||||
}
|
||||
return `/images/${size}/${id}`;
|
||||
}
|
||||
function replaceImage(form: FormData): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/replace-image`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
return fetch(`/apis/web/v1/replace-image`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeTracks(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
function mergeAlbums(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
function mergeAlbums(
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
): Promise<Response> {
|
||||
return fetch(
|
||||
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
function mergeArtists(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
function mergeArtists(
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
): Promise<Response> {
|
||||
return fetch(
|
||||
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
function login(username: string, password: string, remember: boolean): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, {
|
||||
method: "POST",
|
||||
})
|
||||
function login(
|
||||
username: string,
|
||||
password: string,
|
||||
remember: boolean
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", username);
|
||||
form.append("password", password);
|
||||
form.append("remember_me", String(remember));
|
||||
return fetch(`/apis/web/v1/login`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function logout(): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
method: "POST",
|
||||
})
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
function getCfg(): Promise<Config> {
|
||||
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
|
||||
}
|
||||
|
||||
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append("track_id", id);
|
||||
const ms = new Date(ts).getTime();
|
||||
const unix = Math.floor(ms / 1000);
|
||||
form.append("unix", unix.toString());
|
||||
return fetch(`/apis/web/v1/listen`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function getApiKeys(): Promise<ApiKey[]> {
|
||||
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
|
||||
return fetch(`/apis/web/v1/user/apikeys`).then(
|
||||
(r) => r.json() as Promise<ApiKey[]>
|
||||
);
|
||||
}
|
||||
const createApiKey = async (label: string): Promise<ApiKey> => {
|
||||
const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, {
|
||||
method: "POST"
|
||||
});
|
||||
if (!r.ok) {
|
||||
let errorMessage = `error: ${r.status}`;
|
||||
try {
|
||||
const errorData: ApiError = await r.json();
|
||||
if (errorData && typeof errorData.error === 'string') {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unexpected api error:", e);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
const form = new URLSearchParams();
|
||||
form.append("label", label);
|
||||
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!r.ok) {
|
||||
let errorMessage = `error: ${r.status}`;
|
||||
try {
|
||||
const errorData: ApiError = await r.json();
|
||||
if (errorData && typeof errorData.error === "string") {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unexpected api error:", e);
|
||||
}
|
||||
const data: ApiKey = await r.json();
|
||||
return data;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const data: ApiKey = await r.json();
|
||||
return data;
|
||||
};
|
||||
function deleteApiKey(id: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, {
|
||||
method: "PATCH"
|
||||
})
|
||||
const form = new URLSearchParams();
|
||||
form.append("id", String(id));
|
||||
form.append("label", label);
|
||||
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function deleteItem(itemType: string, id: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
function updateUser(username: string, password: string) {
|
||||
return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, {
|
||||
method: "PATCH"
|
||||
})
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", username);
|
||||
form.append("password", password);
|
||||
return fetch(`/apis/web/v1/user`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function getAliases(type: string, id: number): Promise<Alias[]> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
|
||||
(r) => r.json() as Promise<Alias[]>
|
||||
);
|
||||
}
|
||||
function createAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
function createAlias(
|
||||
type: string,
|
||||
id: number,
|
||||
alias: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("alias", alias);
|
||||
return fetch(`/apis/web/v1/aliases`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
function deleteAlias(
|
||||
type: string,
|
||||
id: number,
|
||||
alias: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("alias", alias);
|
||||
return fetch(`/apis/web/v1/aliases/delete`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, {
|
||||
method: "POST"
|
||||
})
|
||||
function setPrimaryAlias(
|
||||
type: string,
|
||||
id: number,
|
||||
alias: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("alias", alias);
|
||||
return fetch(`/apis/web/v1/aliases/primary`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function updateMbzId(
|
||||
type: string,
|
||||
id: number,
|
||||
mbzid: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("mbz_id", mbzid);
|
||||
return fetch(`/apis/web/v1/mbzid`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function getAlbum(id: number): Promise<Album> {
|
||||
return fetch(`/apis/web/v1/album?id=${id}`).then(
|
||||
(r) => r.json() as Promise<Album>
|
||||
);
|
||||
}
|
||||
|
||||
function deleteListen(listen: Listen): Promise<Response> {
|
||||
const ms = new Date(listen.time).getTime()
|
||||
const unix= Math.floor(ms / 1000);
|
||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
const ms = new Date(listen.time).getTime();
|
||||
const unix = Math.floor(ms / 1000);
|
||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
function getExport() {}
|
||||
|
||||
function getNowPlaying(): Promise<NowPlaying> {
|
||||
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
|
||||
}
|
||||
|
||||
async function getRewindStats(args: timeframe): Promise<RewindStats> {
|
||||
const r = await fetch(
|
||||
`/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}`
|
||||
);
|
||||
return handleJson<RewindStats>(r);
|
||||
}
|
||||
|
||||
export {
|
||||
getLastListens,
|
||||
getTopTracks,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getActivity,
|
||||
getStats,
|
||||
search,
|
||||
replaceImage,
|
||||
mergeTracks,
|
||||
mergeAlbums,
|
||||
mergeArtists,
|
||||
imageUrl,
|
||||
login,
|
||||
logout,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
}
|
||||
getLastListens,
|
||||
getTopTracks,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getActivity,
|
||||
getInterest,
|
||||
getStats,
|
||||
search,
|
||||
replaceImage,
|
||||
mergeTracks,
|
||||
mergeAlbums,
|
||||
mergeArtists,
|
||||
imageUrl,
|
||||
login,
|
||||
logout,
|
||||
getCfg,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
updateMbzId,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
submitListen,
|
||||
getNowPlaying,
|
||||
getRewindStats,
|
||||
};
|
||||
type Track = {
|
||||
id: number
|
||||
title: string
|
||||
artists: SimpleArtists[]
|
||||
listen_count: number
|
||||
image: string
|
||||
album_id: number
|
||||
musicbrainz_id: string
|
||||
}
|
||||
id: number;
|
||||
title: string;
|
||||
artists: SimpleArtists[];
|
||||
listen_count: number;
|
||||
image: string;
|
||||
album_id: number;
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
all_time_rank: number;
|
||||
};
|
||||
type Artist = {
|
||||
id: number
|
||||
name: string
|
||||
image: string,
|
||||
aliases: string[]
|
||||
listen_count: number
|
||||
musicbrainz_id: string
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
aliases: string[];
|
||||
listen_count: number;
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
is_primary: boolean;
|
||||
all_time_rank: number;
|
||||
};
|
||||
type Album = {
|
||||
id: number,
|
||||
title: string
|
||||
image: string
|
||||
listen_count: number
|
||||
is_various_artists: boolean
|
||||
artists: SimpleArtists[]
|
||||
musicbrainz_id: string
|
||||
}
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
listen_count: number;
|
||||
is_various_artists: boolean;
|
||||
artists: SimpleArtists[];
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
all_time_rank: number;
|
||||
};
|
||||
type Alias = {
|
||||
id: number
|
||||
alias: string
|
||||
source: string
|
||||
is_primary: boolean
|
||||
}
|
||||
id: number;
|
||||
alias: string;
|
||||
source: string;
|
||||
is_primary: boolean;
|
||||
};
|
||||
type Listen = {
|
||||
time: string,
|
||||
track: Track,
|
||||
}
|
||||
time: string;
|
||||
track: Track;
|
||||
};
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[],
|
||||
total_record_count: number,
|
||||
has_next_page: boolean,
|
||||
current_page: number,
|
||||
items_per_page: number,
|
||||
}
|
||||
items: T[];
|
||||
total_record_count: number;
|
||||
has_next_page: boolean;
|
||||
current_page: number;
|
||||
items_per_page: number;
|
||||
};
|
||||
type Ranked<T> = {
|
||||
item: T;
|
||||
rank: number;
|
||||
};
|
||||
type ListenActivityItem = {
|
||||
start_time: Date,
|
||||
listens: number
|
||||
}
|
||||
start_time: Date;
|
||||
listens: number;
|
||||
};
|
||||
type InterestBucket = {
|
||||
bucket_start: Date;
|
||||
bucket_end: Date;
|
||||
listen_count: number;
|
||||
};
|
||||
type SimpleArtists = {
|
||||
name: string
|
||||
id: number
|
||||
}
|
||||
name: string;
|
||||
id: number;
|
||||
};
|
||||
type Stats = {
|
||||
listen_count: number
|
||||
track_count: number
|
||||
album_count: number
|
||||
artist_count: number
|
||||
hours_listened: number
|
||||
}
|
||||
listen_count: number;
|
||||
track_count: number;
|
||||
album_count: number;
|
||||
artist_count: number;
|
||||
minutes_listened: number;
|
||||
};
|
||||
type SearchResponse = {
|
||||
albums: Album[]
|
||||
artists: Artist[]
|
||||
tracks: Track[]
|
||||
}
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
tracks: Track[];
|
||||
};
|
||||
type User = {
|
||||
id: number
|
||||
username: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
id: number;
|
||||
username: string;
|
||||
role: "user" | "admin";
|
||||
};
|
||||
type ApiKey = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
created_at: Date
|
||||
}
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
created_at: Date;
|
||||
};
|
||||
type ApiError = {
|
||||
error: string
|
||||
}
|
||||
error: string;
|
||||
};
|
||||
type Config = {
|
||||
default_theme: string;
|
||||
};
|
||||
type NowPlaying = {
|
||||
currently_playing: boolean;
|
||||
track: Track;
|
||||
};
|
||||
type RewindStats = {
|
||||
title: string;
|
||||
top_artists: Ranked<Artist>[];
|
||||
top_albums: Ranked<Album>[];
|
||||
top_tracks: Ranked<Track>[];
|
||||
minutes_listened: number;
|
||||
avg_minutes_listened_per_day: number;
|
||||
plays: number;
|
||||
avg_plays_per_day: number;
|
||||
unique_tracks: number;
|
||||
unique_albums: number;
|
||||
unique_artists: number;
|
||||
new_tracks: number;
|
||||
new_albums: number;
|
||||
new_artists: number;
|
||||
};
|
||||
|
||||
export type {
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError
|
||||
}
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
getInterestArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
Ranked,
|
||||
ListenActivityItem,
|
||||
InterestBucket,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError,
|
||||
Config,
|
||||
NowPlaying,
|
||||
Stats,
|
||||
RewindStats,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,59 +1,56 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap');
|
||||
@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
|
||||
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
|
||||
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
|
||||
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
@keyframes fade-in-scale {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@keyframes fade-out-scale {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-fade-in: fade-in 0.1s ease forwards;
|
||||
--animate-fade-out: fade-out 0.1s ease forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes fade-out-scale {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-fade-in: fade-in 0.1s ease forwards;
|
||||
--animate-fade-out: fade-out 0.1s ease forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
:root {
|
||||
--header-xl: 36px;
|
||||
--header-lg: 28px;
|
||||
|
|
@ -61,20 +58,21 @@
|
|||
--header-sm: 16px;
|
||||
--header-xl-weight: 600;
|
||||
--header-weight: 600;
|
||||
--header-line-height: 3rem;
|
||||
}
|
||||
|
||||
@media (min-width: 60rem) {
|
||||
:root {
|
||||
--header-xl: 78px;
|
||||
--header-lg: 28px;
|
||||
--header-lg: 36px;
|
||||
--header-md: 22px;
|
||||
--header-sm: 16px;
|
||||
--header-xl-weight: 600;
|
||||
--header-weight: 600;
|
||||
--header-line-height: 1.3em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
|
|
@ -102,21 +100,24 @@ h1 {
|
|||
font-family: "League Spartan";
|
||||
font-weight: var(--header-weight);
|
||||
font-size: var(--header-xl);
|
||||
line-height: var(--header-line-height);
|
||||
}
|
||||
h2 {
|
||||
font-family: "League Spartan";
|
||||
font-weight: var(--header-weight);
|
||||
font-size: var(--header-lg);
|
||||
}
|
||||
h3 {
|
||||
font-family: "League Spartan";
|
||||
font-weight: var(--header-weight);
|
||||
font-size: var(--header-md);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h3 {
|
||||
h4 {
|
||||
font-family: "League Spartan";
|
||||
font-size: var(--header-sm);
|
||||
font-weight: var(--header-weight);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--header-md);
|
||||
}
|
||||
.header-font {
|
||||
font-family: "League Spartan";
|
||||
}
|
||||
|
|
@ -132,23 +133,21 @@ h4 {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
input[type="checkbox"] {
|
||||
height: fit-content;
|
||||
}
|
||||
input[type="password"] {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
}
|
||||
input[type="checkbox"]:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
input:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
border-color: transparent;
|
||||
outline: 2px solid var(--color-fg-tertiary);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
|
|
|
|||
|
|
@ -1,186 +1,196 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
||||
import Popup from "./Popup"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getActivity,
|
||||
type getActivityArgs,
|
||||
type ListenActivityItem,
|
||||
} from "api/api";
|
||||
import Popup from "./Popup";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "~/hooks/useTheme";
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector";
|
||||
import type { Theme } from "~/styles/themes.css";
|
||||
|
||||
function getPrimaryColor(): string {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-primary')
|
||||
.trim();
|
||||
function getPrimaryColor(theme: Theme): string {
|
||||
const value = theme.primary;
|
||||
const rgbMatch = value.match(
|
||||
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||
);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((n) => n.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
step?: string
|
||||
range?: number
|
||||
month?: number
|
||||
year?: number
|
||||
artistId?: number
|
||||
albumId?: number
|
||||
trackId?: number
|
||||
configurable?: boolean
|
||||
autoAdjust?: boolean
|
||||
step?: string;
|
||||
range?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
artistId?: number;
|
||||
albumId?: number;
|
||||
trackId?: number;
|
||||
configurable?: boolean;
|
||||
autoAdjust?: boolean;
|
||||
}
|
||||
|
||||
export default function ActivityGrid({
|
||||
step = 'day',
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
autoAdjust = false,
|
||||
}: Props) {
|
||||
step = "day",
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
}: Props) {
|
||||
const [stepState, setStep] = useState(step);
|
||||
const [rangeState, setRange] = useState(range);
|
||||
|
||||
const [color, setColor] = useState(getPrimaryColor())
|
||||
const [stepState, setStep] = useState(step)
|
||||
const [rangeState, setRange] = useState(range)
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"listen-activity",
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'listen-activity',
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
|
||||
const { theme } = useTheme();
|
||||
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>
|
||||
const { theme } = useTheme();
|
||||
const color = getPrimaryColor(theme);
|
||||
|
||||
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 {
|
||||
stepSetter: (value: string) => void;
|
||||
|
|
@ -15,18 +16,15 @@ export default function ActivityOptsSelector({
|
|||
currentRange,
|
||||
disableCache = false,
|
||||
}: Props) {
|
||||
const stepPeriods = ['day', 'week', 'month', 'year'];
|
||||
const rangePeriods = [105, 182, 365];
|
||||
const stepPeriods = ['day', 'week', 'month'];
|
||||
const rangePeriods = [105, 182, 364];
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const stepDisplay = (str: string): string => {
|
||||
return str.split('_').map(w =>
|
||||
w.split('').map((char, index) =>
|
||||
index === 0 ? char.toUpperCase() : char).join('')
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
const rangeDisplay = (r: number): string => {
|
||||
return `${r}`
|
||||
const setMenuOpen = (val: boolean) => {
|
||||
setCollapsed(val)
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val));
|
||||
}
|
||||
}
|
||||
|
||||
const setStep = (val: string) => {
|
||||
|
|
@ -45,53 +43,64 @@ export default function ActivityOptsSelector({
|
|||
|
||||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
|
||||
if (cachedRange) {
|
||||
rangeSetter(cachedRange);
|
||||
}
|
||||
// TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default.
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182');
|
||||
if (cachedRange) rangeSetter(cachedRange);
|
||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) {
|
||||
stepSetter(cachedStep);
|
||||
}
|
||||
if (cachedStep) stepSetter(cachedStep);
|
||||
const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) setMenuOpen(cachedConfiguring !== "true");
|
||||
}
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>Step:</p>
|
||||
{stepPeriods.map((p, i) => (
|
||||
<div key={`step_selector_${p}`}>
|
||||
<button
|
||||
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setStep(p)}
|
||||
disabled={p === currentStep}
|
||||
>
|
||||
{stepDisplay(p)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== stepPeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!collapsed)}
|
||||
className="absolute left-[75px] -top-9 text-muted hover:color-fg transition"
|
||||
title="Toggle options"
|
||||
>
|
||||
{collapsed ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>Range:</p>
|
||||
{rangePeriods.map((r, i) => (
|
||||
<div key={`range_selector_${r}`}>
|
||||
<button
|
||||
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setRange(r)}
|
||||
disabled={r === currentRange}
|
||||
>
|
||||
{rangeDisplay(r)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== rangePeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
<div
|
||||
className={`overflow-hidden transition-[max-height,opacity] duration-250 ease ${
|
||||
collapsed ? 'max-h-0 opacity-0' : 'max-h-[100px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4 mt-1 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted">Step:</span>
|
||||
{stepPeriods.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`px-1 rounded transition ${
|
||||
p === currentStep ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
|
||||
}`}
|
||||
onClick={() => setStep(p)}
|
||||
disabled={p === currentStep}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted">Range:</span>
|
||||
{rangePeriods.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`px-1 rounded transition ${
|
||||
r === currentRange ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
|
||||
}`}
|
||||
onClick={() => setRange(r)}
|
||||
disabled={r === currentRange}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,24 +2,31 @@ import { imageUrl, type Album } from "api/api";
|
|||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
album: Album
|
||||
size: number
|
||||
album: Album;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export default function AlbumDisplay({ album, size }: Props) {
|
||||
return (
|
||||
<div className="flex gap-3" key={album.id}>
|
||||
<div>
|
||||
<Link to={`/album/${album.id}`}>
|
||||
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-start" style={{width: size}}>
|
||||
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
|
||||
<h4>{album.title}</h4>
|
||||
</Link>
|
||||
<p className="color-fg-secondary">{album.listen_count} plays</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex gap-3" key={album.id}>
|
||||
<div>
|
||||
<Link to={`/album/${album.id}`}>
|
||||
<img
|
||||
src={imageUrl(album.image, "large")}
|
||||
alt={album.title}
|
||||
style={{ width: size }}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-start" style={{ width: size }}>
|
||||
<Link
|
||||
to={`/album/${album.id}`}
|
||||
className="hover:text-(--color-fg-secondary)"
|
||||
>
|
||||
<h4>{album.title}</h4>
|
||||
</Link>
|
||||
<p className="color-fg-secondary">{album.listen_count} plays</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +1,58 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getStats } from "api/api"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStats, type Stats, type ApiError } from "api/api";
|
||||
|
||||
export default function AllTimeStats() {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ["stats", "all_time"],
|
||||
queryFn: ({ queryKey }) => getStats(queryKey[1]),
|
||||
});
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['stats', 'all_time'],
|
||||
queryFn: ({ queryKey }) => getStats(queryKey[1]),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[200px]">
|
||||
<h2>All Time Stats</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
const numberClasses = 'header-font font-bold text-xl'
|
||||
const header = "All time stats";
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h3>{header}</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2>All Time Stats</h2>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.listen_count}</span> Plays
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.artist_count}</span> Artists
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.album_count}</span> Albums
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.track_count}</span> Tracks
|
||||
</div>
|
||||
<h3>{header}</h3>
|
||||
<p className="error">Error: {error.message}</p>
|
||||
</div>
|
||||
)
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const numberClasses = "header-font font-bold text-xl";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{header}</h3>
|
||||
<div>
|
||||
<span
|
||||
className={numberClasses}
|
||||
title={Math.floor(data.minutes_listened / 60) + " hours"}
|
||||
>
|
||||
{data.minutes_listened}
|
||||
</span>{" "}
|
||||
Minutes Listened
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.listen_count}</span> Plays
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.track_count}</span> Tracks
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.album_count}</span> Albums
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.artist_count}</span> Artists
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,51 +1,63 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
artistId: number
|
||||
name: string
|
||||
period: string
|
||||
artistId: number;
|
||||
name: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export default function ArtistAlbums({artistId, name, period}: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums From This Artist</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums From This Artist</h2>
|
||||
<p className="error">Error:{error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default function ArtistAlbums({ artistId, name }: Props) {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"top-albums",
|
||||
{ limit: 99, period: "all_time", artist_id: artistId },
|
||||
],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums featuring {name}</h2>
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{data.items.map((item) => (
|
||||
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
|
||||
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
|
||||
<div className="w-[180px] flex flex-col items-start gap-1">
|
||||
<p>{item.title}</p>
|
||||
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div>
|
||||
<h3>Albums From This Artist</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
<h3>Albums From This Artist</h3>
|
||||
<p className="error">Error:{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Albums featuring {name}</h3>
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{data.items.map((item) => (
|
||||
<Link
|
||||
to={`/album/${item.item.id}`}
|
||||
className="flex gap-2 items-start"
|
||||
>
|
||||
<img
|
||||
src={imageUrl(item.item.image, "medium")}
|
||||
alt={item.item.title}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<div className="w-[180px] flex flex-col items-start gap-1">
|
||||
<p>{item.item.title}</p>
|
||||
<p className="text-sm color-fg-secondary">
|
||||
{item.item.listen_count} play
|
||||
{item.item.listen_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,10 @@ import { useEffect } from 'react';
|
|||
|
||||
interface Props {
|
||||
itemType: string,
|
||||
id: number,
|
||||
onComplete: Function
|
||||
}
|
||||
|
||||
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
||||
export default function ImageDropHandler({ itemType, onComplete }: Props) {
|
||||
useEffect(() => {
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
console.log('dragover!!')
|
||||
|
|
@ -25,7 +24,11 @@ export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
|||
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
formData.append(itemType.toLowerCase()+'_id', String(id))
|
||||
const pathname = window.location.pathname;
|
||||
const segments = pathname.split('/');
|
||||
const filteredSegments = segments.filter(segment => segment !== '');
|
||||
const lastSegment = filteredSegments[filteredSegments.length - 1];
|
||||
formData.append(itemType.toLowerCase()+'_id', lastSegment)
|
||||
replaceImage(formData).then((r) => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
onComplete()
|
||||
|
|
|
|||
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 { useQuery } from "@tanstack/react-query"
|
||||
import { timeSince } from "~/utils/utils"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { timeSince } from "~/utils/utils";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import {
|
||||
deleteListen,
|
||||
getLastListens,
|
||||
getNowPlaying,
|
||||
type getItemsArgs,
|
||||
type Listen,
|
||||
type Track,
|
||||
} from "api/api";
|
||||
import { Link } from "react-router";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
|
||||
interface Props {
|
||||
limit: number
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
trackId?: number
|
||||
hideArtists?: boolean
|
||||
limit: number;
|
||||
artistId?: Number;
|
||||
albumId?: Number;
|
||||
trackId?: number;
|
||||
hideArtists?: boolean;
|
||||
showNowPlaying?: boolean;
|
||||
}
|
||||
|
||||
export default function LastPlays(props: Props) {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['last-listens', {
|
||||
limit: props.limit,
|
||||
period: 'all_time',
|
||||
artist_id: props.artistId,
|
||||
album_id: props.albumId,
|
||||
track_id: props.trackId
|
||||
}],
|
||||
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
const { user } = useAppContext();
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"last-listens",
|
||||
{
|
||||
limit: props.limit,
|
||||
period: "all_time",
|
||||
artist_id: props.artistId,
|
||||
album_id: props.albumId,
|
||||
track_id: props.trackId,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
||||
});
|
||||
const { data: npData } = useQuery({
|
||||
queryKey: ["now-playing"],
|
||||
queryFn: () => getNowPlaying(),
|
||||
});
|
||||
|
||||
const [items, setItems] = useState<Listen[] | null>(null)
|
||||
const header = "Last played";
|
||||
|
||||
const handleDelete = async (listen: Listen) => {
|
||||
if (!data) return
|
||||
try {
|
||||
const res = await deleteListen(listen)
|
||||
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
||||
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
|
||||
} else {
|
||||
console.error("Failed to delete listen:", res.status)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting listen:", err)
|
||||
}
|
||||
const [items, setItems] = useState<Listen[] | null>(null);
|
||||
|
||||
const handleDelete = async (listen: Listen) => {
|
||||
if (!data) return;
|
||||
try {
|
||||
const res = await deleteListen(listen);
|
||||
if (res.ok || (res.status >= 200 && res.status < 300)) {
|
||||
setItems((prev) =>
|
||||
(prev ?? data.items).filter((i) => i.time !== listen.time)
|
||||
);
|
||||
} else {
|
||||
console.error("Failed to delete listen:", res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting listen:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px] sm:w-[500px]">
|
||||
<h2>Last Played</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>
|
||||
}
|
||||
|
||||
const listens = items ?? data.items
|
||||
|
||||
let params = ''
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||
params += props.trackId ? `&track_id=${props.trackId}` : ''
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="text-sm sm:text-[16px]">
|
||||
<h2 className="hover:underline">
|
||||
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
||||
</h2>
|
||||
<table className="-ml-4">
|
||||
<tbody>
|
||||
{listens.map((item) => (
|
||||
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
||||
<td className="w-[1px] pr-2 align-middle">
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||
aria-label="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
||||
title={new Date(item.time).toString()}
|
||||
>
|
||||
{timeSince(new Date(item.time))}
|
||||
</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||
{props.hideArtists ? null : (
|
||||
<>
|
||||
<ArtistLinks artists={item.track.artists} /> –{' '}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
className="hover:text-[--color-fg-secondary]"
|
||||
to={`/track/${item.track.id}`}
|
||||
>
|
||||
{item.track.title}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
<div className="w-[300px] sm:w-[500px]">
|
||||
<h3>{header}</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
return (
|
||||
<div className="w-[300px] sm:w-[500px]">
|
||||
<h3>{header}</h3>
|
||||
<p className="error">Error: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const listens = items ?? data.items;
|
||||
|
||||
let params = "";
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : "";
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : "";
|
||||
params += props.trackId ? `&track_id=${props.trackId}` : "";
|
||||
|
||||
return (
|
||||
<div className="text-sm sm:text-[16px]">
|
||||
<h3 className="hover:underline">
|
||||
<Link to={`/listens?period=all_time${params}`}>{header}</Link>
|
||||
</h3>
|
||||
<table className="-ml-4">
|
||||
<tbody>
|
||||
{props.showNowPlaying && npData && npData.currently_playing && (
|
||||
<tr className="group hover:bg-[--color-bg-secondary]">
|
||||
<td className="w-[18px] pr-2 align-middle"></td>
|
||||
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
|
||||
Now Playing
|
||||
</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||
{props.hideArtists ? null : (
|
||||
<>
|
||||
<ArtistLinks artists={npData.track.artists} /> –{" "}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
className="hover:text-[--color-fg-secondary]"
|
||||
to={`/track/${npData.track.id}`}
|
||||
>
|
||||
{npData.track.title}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{listens.map((item) => (
|
||||
<tr
|
||||
key={`last_listen_${item.time}`}
|
||||
className="group hover:bg-[--color-bg-secondary]"
|
||||
>
|
||||
<td className="w-[18px] pr-2 align-middle">
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||
aria-label="Delete"
|
||||
hidden={user === null || user === undefined}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
||||
title={new Date(item.time).toString()}
|
||||
>
|
||||
{timeSince(new Date(item.time))}
|
||||
</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||
{props.hideArtists ? null : (
|
||||
<>
|
||||
<ArtistLinks artists={item.track.artists} /> –{" "}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
className="hover:text-[--color-fg-secondary]"
|
||||
to={`/track/${item.track.id}`}
|
||||
>
|
||||
{item.track.title}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,19 +16,19 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
|||
const selectItem = (title: string, id: number) => {
|
||||
if (selected === id) {
|
||||
setSelected(0)
|
||||
onSelect({id: id, title: title})
|
||||
onSelect({id: 0, title: ''})
|
||||
} else {
|
||||
setSelected(id)
|
||||
onSelect({id: id, title: title})
|
||||
}
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
if (!data) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="w-full">
|
||||
{ data.artists.length > 0 &&
|
||||
{ data.artists && data.artists.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Artists</h3>
|
||||
<div className={classes}>
|
||||
|
|
@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
|||
</div>
|
||||
</>
|
||||
}
|
||||
{ data.albums.length > 0 &&
|
||||
{ data.albums && data.albums.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Albums</h3>
|
||||
<div className={classes}>
|
||||
|
|
@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
|||
</div>
|
||||
</>
|
||||
}
|
||||
{ data.tracks.length > 0 &&
|
||||
{ data.tracks && data.tracks.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Tracks</h3>
|
||||
<div className={classes}>
|
||||
|
|
|
|||
|
|
@ -1,42 +1,68 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import TopItemList from "./TopItemList"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import {
|
||||
getTopAlbums,
|
||||
getTopTracks,
|
||||
imageUrl,
|
||||
type getItemsArgs,
|
||||
} from "api/api";
|
||||
import { Link } from "react-router";
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton";
|
||||
import TopItemList from "./TopItemList";
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
limit: number;
|
||||
period: string;
|
||||
artistId?: Number;
|
||||
}
|
||||
|
||||
export default function TopAlbums (props: Props) {
|
||||
export default function TopAlbums(props: Props) {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"top-albums",
|
||||
{
|
||||
limit: props.limit,
|
||||
period: props.period,
|
||||
artistId: props.artistId,
|
||||
page: 0,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
});
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Albums</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
const header = "Top albums";
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-albums?period=${props.period}${props.artistId ? `&artist_id=${props.artistId}` : ''}`}>Top Albums</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="album" data={data} />
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div className="w-[300px]">
|
||||
<h3>{header}</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h3>{header}</h3>
|
||||
<p className="error">Error: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="hover:underline">
|
||||
<Link
|
||||
to={`/chart/top-albums?period=${props.period}${
|
||||
props.artistId ? `&artist_id=${props.artistId}` : ""
|
||||
}`}
|
||||
>
|
||||
{header}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="album" data={data} />
|
||||
{data.items.length < 1 ? "Nothing to show" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +1,53 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import TopItemList from "./TopItemList"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api";
|
||||
import { Link } from "react-router";
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton";
|
||||
import TopItemList from "./TopItemList";
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
limit: number;
|
||||
period: string;
|
||||
artistId?: Number;
|
||||
albumId?: Number;
|
||||
}
|
||||
|
||||
export default function TopArtists (props: Props) {
|
||||
export default function TopArtists(props: Props) {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"top-artists",
|
||||
{ limit: props.limit, period: props.period, page: 0 },
|
||||
],
|
||||
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
|
||||
});
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
|
||||
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Artists</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
const header = "Top artists";
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-artists?period=${props.period}`}>Top Artists</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="artist" data={data} />
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div className="w-[300px]">
|
||||
<h3>{header}</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h3>{header}</h3>
|
||||
<p className="error">Error: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="hover:underline">
|
||||
<Link to={`/chart/top-artists?period=${props.period}`}>{header}</Link>
|
||||
</h3>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="artist" data={data} />
|
||||
{data.items.length < 1 ? "Nothing to show" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,142 +1,171 @@
|
|||
import { Link, useNavigate } from "react-router";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
|
||||
import {
|
||||
imageUrl,
|
||||
type Album,
|
||||
type Artist,
|
||||
type Track,
|
||||
type PaginatedResponse,
|
||||
type Ranked,
|
||||
} from "api/api";
|
||||
|
||||
type Item = Album | Track | Artist;
|
||||
|
||||
interface Props<T extends Item> {
|
||||
data: PaginatedResponse<T>
|
||||
separators?: ConstrainBoolean
|
||||
type: "album" | "track" | "artist";
|
||||
className?: string,
|
||||
interface Props<T extends Ranked<Item>> {
|
||||
data: PaginatedResponse<T>;
|
||||
separators?: ConstrainBoolean;
|
||||
ranked?: boolean;
|
||||
type: "album" | "track" | "artist";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TopItemList<T extends Item>({ data, separators, type, className }: Props<T>) {
|
||||
export default function TopItemList<T extends Ranked<Item>>({
|
||||
data,
|
||||
separators,
|
||||
type,
|
||||
className,
|
||||
ranked,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className} min-w-[200px]`}>
|
||||
{data.items.map((item, index) => {
|
||||
const key = `${type}-${item.item.id}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ fontSize: 12 }}
|
||||
className={`${
|
||||
separators && index !== data.items.length - 1
|
||||
? "border-b border-(--color-fg-tertiary) mb-1 pb-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ItemCard
|
||||
ranked={ranked}
|
||||
rank={item.rank}
|
||||
item={item.item}
|
||||
type={type}
|
||||
key={type + item.item.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className} min-w-[300px]`}>
|
||||
{data.items.map((item, index) => {
|
||||
const key = `${type}-${item.id}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ fontSize: 12 }}
|
||||
className={`${
|
||||
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
|
||||
}`}
|
||||
>
|
||||
<ItemCard item={item} type={type} key={type+item.id} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
function ItemCard({
|
||||
item,
|
||||
type,
|
||||
rank,
|
||||
ranked,
|
||||
}: {
|
||||
item: Item;
|
||||
type: "album" | "track" | "artist";
|
||||
rank: number;
|
||||
ranked?: boolean;
|
||||
}) {
|
||||
const itemClasses = `flex items-center gap-2`;
|
||||
|
||||
switch (type) {
|
||||
case "album": {
|
||||
const album = item as Album;
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||
<Link to={`/album/${album.id}`}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={imageUrl(album.image, "small")}
|
||||
alt={album.title}
|
||||
className="min-w-[48px]"
|
||||
/>
|
||||
</Link>
|
||||
<div>
|
||||
<Link
|
||||
to={`/album/${album.id}`}
|
||||
className="hover:text-(--color-fg-secondary)"
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>{album.title}</span>
|
||||
</Link>
|
||||
<br />
|
||||
{album.is_various_artists ? (
|
||||
<span className="color-fg-secondary">Various Artists</span>
|
||||
) : (
|
||||
<div>
|
||||
<ArtistLinks
|
||||
artists={
|
||||
album.artists
|
||||
? [album.artists[0]]
|
||||
: [{ id: 0, name: "Unknown Artist" }]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
|
||||
|
||||
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleItemClick = (type: string, id: number) => {
|
||||
navigate(`/${type.toLowerCase()}/${id}`);
|
||||
};
|
||||
|
||||
const handleArtistClick = (event: React.MouseEvent) => {
|
||||
// Stop the click from navigating to the album page
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Also stop keyboard events on the inner links from bubbling up
|
||||
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
);
|
||||
}
|
||||
case "track": {
|
||||
const track = item as Track;
|
||||
|
||||
switch (type) {
|
||||
case "album": {
|
||||
const album = item as Album;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("album", album.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("album", album.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View album: ${album.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(album.image, "small")} alt={album.title} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{album.title}</span>
|
||||
<br />
|
||||
{album.is_various_artists ?
|
||||
<span className="color-fg-secondary">Various Artists</span>
|
||||
:
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
}
|
||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "track": {
|
||||
const track = item as Track;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("track", track.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("track", track.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View track: ${track.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(track.image, "small")} alt={track.title} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{track.title}</span>
|
||||
<br />
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "artist": {
|
||||
const artist = item as Artist;
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
|
||||
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||
<Link to={`/track/${track.id}`}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={imageUrl(track.image, "small")}
|
||||
alt={track.title}
|
||||
className="min-w-[48px]"
|
||||
/>
|
||||
</Link>
|
||||
<div>
|
||||
<Link
|
||||
to={`/track/${track.id}`}
|
||||
className="hover:text-(--color-fg-secondary)"
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>{track.title}</span>
|
||||
</Link>
|
||||
<br />
|
||||
<div>
|
||||
<ArtistLinks
|
||||
artists={track.artists || [{ id: 0, Name: "Unknown Artist" }]}
|
||||
/>
|
||||
</div>
|
||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "artist": {
|
||||
const artist = item as Artist;
|
||||
return (
|
||||
<div style={{ fontSize: 12 }} className={itemClasses}>
|
||||
{ranked && <div className="w-7 text-end">{rank}</div>}
|
||||
<Link
|
||||
className={
|
||||
itemClasses + " mt-1 mb-[6px] hover:text-(--color-fg-secondary)"
|
||||
}
|
||||
to={`/artist/${artist.id}`}
|
||||
>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={imageUrl(artist.image, "small")}
|
||||
alt={artist.name}
|
||||
className="min-w-[48px]"
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontSize: 14 }}>{artist.name}</span>
|
||||
<div className="color-fg-secondary">
|
||||
{artist.listen_count} plays
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,43 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTopAlbums, type getItemsArgs } from "api/api"
|
||||
import AlbumDisplay from "./AlbumDisplay"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTopAlbums, type getItemsArgs } from "api/api";
|
||||
import AlbumDisplay from "./AlbumDisplay";
|
||||
|
||||
interface Props {
|
||||
period: string
|
||||
artistId?: Number
|
||||
vert?: boolean
|
||||
hideTitle?: boolean
|
||||
period: string;
|
||||
artistId?: Number;
|
||||
vert?: boolean;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export default function TopThreeAlbums(props: Props) {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"top-albums",
|
||||
{ limit: 3, period: props.period, artist_id: props.artistId, page: 0 },
|
||||
],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
});
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
console.log(data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!props.hideTitle && <h2>Top Three Albums</h2>}
|
||||
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
|
||||
{data.items.map((item, index) => (
|
||||
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
{!props.hideTitle && <h3>Top Three Albums</h3>}
|
||||
<div
|
||||
className={`flex ${props.vert ? "flex-col" : ""}`}
|
||||
style={{ gap: 15 }}
|
||||
>
|
||||
{data.items.map((item, index) => (
|
||||
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +1,69 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import { useEffect } from "react"
|
||||
import TopItemList from "./TopItemList"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api";
|
||||
import { Link } from "react-router";
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton";
|
||||
import { useEffect } from "react";
|
||||
import TopItemList from "./TopItemList";
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
limit: number;
|
||||
period: string;
|
||||
artistId?: Number;
|
||||
albumId?: Number;
|
||||
}
|
||||
|
||||
const TopTracks = (props: Props) => {
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"top-tracks",
|
||||
{
|
||||
limit: props.limit,
|
||||
period: props.period,
|
||||
artist_id: props.artistId,
|
||||
album_id: props.albumId,
|
||||
page: 0,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
|
||||
});
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Tracks</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
let params = ''
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||
const header = "Top tracks";
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-tracks?period=${props.period}${params}`}>Top Tracks</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="track" data={data}/>
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="w-[300px]">
|
||||
<h3>{header}</h3>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h3>{header}</h3>
|
||||
<p className="error">Error: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data.items) return;
|
||||
|
||||
export default TopTracks
|
||||
let params = "";
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : "";
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="hover:underline">
|
||||
<Link to={`/chart/top-tracks?period=${props.period}${params}`}>
|
||||
{header}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="track" data={data} />
|
||||
{data.items.length < 1 ? "Nothing to show" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopTracks;
|
||||
|
|
|
|||
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 { useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { useAppContext } from "~/providers/AppProvider"
|
||||
import { logout, updateUser } from "api/api";
|
||||
import { useState } from "react";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
|
||||
export default function Account() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const { user, setUsername: setCtxUsername } = useAppContext()
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPw, setConfirmPw] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const { user, setUsername: setCtxUsername } = useAppContext();
|
||||
|
||||
const logoutHandler = () => {
|
||||
setLoading(true)
|
||||
logout()
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
r.json().then(r => setError(r.error))
|
||||
}
|
||||
}).catch(err => setError(err))
|
||||
setLoading(false)
|
||||
}
|
||||
const updateHandler = () => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
if (password != "" && confirmPw === "") {
|
||||
setError("confirm your new password before submitting")
|
||||
return
|
||||
const logoutHandler = () => {
|
||||
setLoading(true);
|
||||
logout()
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setLoading(true)
|
||||
updateUser(username, password)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setSuccess("sucessfully updated user")
|
||||
if (username != "") {
|
||||
setCtxUsername(username)
|
||||
}
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setConfirmPw('')
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
}).catch(err => setError(err))
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => setError(err));
|
||||
setLoading(false);
|
||||
};
|
||||
const updateHandler = () => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
if (password != "" && confirmPw === "") {
|
||||
setError("confirm your new password before submitting");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setLoading(true);
|
||||
updateUser(username, password)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
setSuccess("sucessfully updated user");
|
||||
if (username != "") {
|
||||
setCtxUsername(username);
|
||||
}
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setConfirmPw("");
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Account</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You're logged in as <strong>{user?.username}</strong></p>
|
||||
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
||||
</div>
|
||||
<h2>Update User</h2>
|
||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
||||
</div>
|
||||
</form>
|
||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
||||
</div>
|
||||
</form>
|
||||
{success != "" && <p className="success">{success}</p>}
|
||||
{error != "" && <p className="error">{error}</p>}
|
||||
return (
|
||||
<>
|
||||
<h3>Account</h3>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>
|
||||
You're logged in as <strong>{user?.username}</strong>
|
||||
</p>
|
||||
<AsyncButton loading={loading} onClick={logoutHandler}>
|
||||
Logout
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
<h3>Update User</h3>
|
||||
<form
|
||||
action="#"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</form>
|
||||
<form
|
||||
action="#"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</form>
|
||||
{success != "" && <p className="success">{success}</p>}
|
||||
{error != "" && <p className="error">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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";
|
||||
|
||||
type CopiedState = {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export default function ApiKeysModal() {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading ] = useState(false)
|
||||
const [err, setError ] = useState<string>()
|
||||
const [displayData, setDisplayData] = useState<ApiKey[]>([])
|
||||
const [copied, setCopied] = useState<CopiedState | null>(null);
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setError] = useState<string>();
|
||||
const [displayData, setDisplayData] = useState<ApiKey[]>([]);
|
||||
const [copied, setCopied] = useState<CopiedState | null>(null);
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const handleRevealAndSelect = (key: string) => {
|
||||
setExpandedKey(key);
|
||||
setTimeout(() => {
|
||||
const el = textRefs.current[key];
|
||||
if (el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
const handleRevealAndSelect = (key: string) => {
|
||||
setExpandedKey(key);
|
||||
setTimeout(() => {
|
||||
const el = textRefs.current[key];
|
||||
if (el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'api-keys'
|
||||
],
|
||||
queryFn: () => {
|
||||
return getApiKeys();
|
||||
},
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ["api-keys"],
|
||||
queryFn: () => {
|
||||
return getApiKeys();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
|
||||
const parentRect = (
|
||||
e.currentTarget.closest(".relative") as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setCopied({
|
||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
|
||||
y: buttonRect.top - parentRect.top - 8,
|
||||
visible: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data)
|
||||
}
|
||||
}, [data])
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed"; // prevent scroll to bottom
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback: Copy failed", err);
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
const handleCreateApiKey = () => {
|
||||
setError(undefined);
|
||||
if (input === "") {
|
||||
setError("a label must be provided");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
createApiKey(input)
|
||||
.then((r) => {
|
||||
setDisplayData([r, ...displayData]);
|
||||
setInput("");
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
const handleDeleteApiKey = (id: number) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
deleteApiKey(id).then((r) => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.id != id));
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setCopied({
|
||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
|
||||
y: buttonRect.top - parentRect.top - 8,
|
||||
visible: true,
|
||||
});
|
||||
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed"; // prevent scroll to bottom
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback: Copy failed", err);
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
const handleCreateApiKey = () => {
|
||||
setError(undefined)
|
||||
if (input === "") {
|
||||
setError("a label must be provided")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
createApiKey(input)
|
||||
.then(r => {
|
||||
setDisplayData([r, ...displayData])
|
||||
setInput('')
|
||||
}).catch((err) => setError(err.message))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleDeleteApiKey = (id: number) => {
|
||||
setError(undefined)
|
||||
setLoading(true)
|
||||
deleteApiKey(id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.id != id))
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<h2>API Keys</h2>
|
||||
<div className="flex flex-col gap-4 relative">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2"><div
|
||||
key={v.key}
|
||||
ref={el => {
|
||||
textRefs.current[v.key] = el;
|
||||
}}
|
||||
onClick={() => handleRevealAndSelect(v.key)}
|
||||
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
|
||||
expandedKey === v.key ? '' : 'truncate'
|
||||
}`}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
title={v.key} // optional tooltip
|
||||
>
|
||||
{expandedKey === v.key ? v.key : `${v.key.slice(0, 8)}... ${v.label}`}
|
||||
</div>
|
||||
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
|
||||
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a label for a new API key"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
|
||||
return (
|
||||
<div className="">
|
||||
<h3>API Keys</h3>
|
||||
<div className="flex flex-col gap-4 relative">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
key={v.key}
|
||||
ref={(el) => {
|
||||
textRefs.current[v.key] = el;
|
||||
}}
|
||||
onClick={() => handleRevealAndSelect(v.key)}
|
||||
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
|
||||
expandedKey === v.key ? "" : "truncate"
|
||||
}`}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
title={v.key} // optional tooltip
|
||||
>
|
||||
{expandedKey === v.key
|
||||
? v.key
|
||||
: `${v.key.slice(0, 8)}... ${v.label}`}
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{copied?.visible && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: copied.y,
|
||||
left: copied.x,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
|
||||
>
|
||||
Copied!
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleCopy(e, v.key)}
|
||||
className="large-button px-5 rounded-md"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => handleDeleteApiKey(v.id)}
|
||||
confirm
|
||||
>
|
||||
<Trash size={16} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a label for a new API key"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleCreateApiKey}>
|
||||
Create
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
{err && <p className="error">{err}</p>}
|
||||
{copied?.visible && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: copied.y,
|
||||
left: copied.x,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
|
||||
>
|
||||
Copied!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +1,41 @@
|
|||
import { deleteItem } from "api/api"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { Modal } from "./Modal"
|
||||
import { useNavigate } from "react-router"
|
||||
import { useState } from "react"
|
||||
import { deleteItem } from "api/api";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { Modal } from "./Modal";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
title: string,
|
||||
id: number,
|
||||
type: string
|
||||
open: boolean;
|
||||
setOpen: Function;
|
||||
title: string;
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const doDelete = () => {
|
||||
setLoading(true)
|
||||
deleteItem(type.toLowerCase(), id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
navigate('/')
|
||||
} else {
|
||||
console.log(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
const doDelete = () => {
|
||||
setLoading(true);
|
||||
deleteItem(type.toLowerCase(), id).then((r) => {
|
||||
if (r.ok) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
console.log(r);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h2>Delete "{title}"?</h2>
|
||||
<p>This action is irreversible!</p>
|
||||
<div className="flex flex-col mt-3 items-center">
|
||||
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h3>Delete "{title}"?</h3>
|
||||
<p>This action is irreversible!</p>
|
||||
<div className="flex flex-col mt-3 items-center">
|
||||
<AsyncButton loading={loading} onClick={doDelete}>
|
||||
Yes, Delete It
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
id: number
|
||||
musicbrainzId?: string
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
type: string;
|
||||
id: number;
|
||||
musicbrainzId?: string;
|
||||
open: boolean;
|
||||
setOpen: Function;
|
||||
}
|
||||
|
||||
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
|
||||
export default function ImageReplaceModal({
|
||||
musicbrainzId,
|
||||
type,
|
||||
id,
|
||||
open,
|
||||
setOpen,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true);
|
||||
|
||||
const doImageReplace = (url: string) => {
|
||||
setLoading(true)
|
||||
const formData = new FormData
|
||||
formData.set(`${type.toLowerCase()}_id`, id.toString())
|
||||
formData.set("image_url", url)
|
||||
replaceImage(formData)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
console.log(r)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
const doImageReplace = (url: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const formData = new FormData();
|
||||
formData.set(`${type.toLowerCase()}_id`, id.toString());
|
||||
formData.set("image_url", url);
|
||||
replaceImage(formData)
|
||||
.then((r) => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err));
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
const closeModal = () => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeModal}>
|
||||
<h2>Replace Image</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Image URL`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeModal}>
|
||||
<h3>Replace Image</h3>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Enter image URL, or drag-and-drop a local file`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{query != "" ? (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<AsyncButton
|
||||
loading={loading}
|
||||
onClick={() => doImageReplace(query)}
|
||||
>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{type === "Album" && musicbrainzId ? (
|
||||
<>
|
||||
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
|
||||
<button
|
||||
className="mt-4"
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
doImageReplace(
|
||||
`https://coverartarchive.org/release/${musicbrainzId}/front`
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={`relative`}>
|
||||
{suggestedImgLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
|
||||
onLoad={() => setSuggestedImgLoading(false)}
|
||||
onError={() => setSuggestedImgLoading(false)}
|
||||
className={`block w-[130px] h-auto ${
|
||||
suggestedImgLoading ? "opacity-0" : "opacity-100"
|
||||
} transition-opacity duration-300`}
|
||||
/>
|
||||
{ query != "" ?
|
||||
<div className="flex gap-2 mt-4">
|
||||
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
|
||||
</div> :
|
||||
''}
|
||||
{ type === "Album" && musicbrainzId ?
|
||||
<>
|
||||
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
|
||||
<button
|
||||
className="mt-4"
|
||||
disabled={loading}
|
||||
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
|
||||
>
|
||||
<div className={`relative`}>
|
||||
{suggestedImgLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
|
||||
onLoad={() => setSuggestedImgLoading(false)}
|
||||
onError={() => setSuggestedImgLoading(false)}
|
||||
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,59 +1,74 @@
|
|||
import { login } from "api/api"
|
||||
import { useEffect, useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { login } from "api/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
|
||||
export default function LoginForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(false);
|
||||
|
||||
const loginHandler = () => {
|
||||
if (username && password) {
|
||||
setLoading(true)
|
||||
login(username, password, remember)
|
||||
.then(r => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
r.json().then(r => setError(r.error))
|
||||
}
|
||||
}).catch(err => setError(err))
|
||||
setLoading(false)
|
||||
} else if (username || password) {
|
||||
setError("username and password are required")
|
||||
}
|
||||
const loginHandler = () => {
|
||||
if (username && password) {
|
||||
setLoading(true);
|
||||
login(username, password, remember)
|
||||
.then((r) => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err));
|
||||
setLoading(false);
|
||||
} else if (username || password) {
|
||||
setError("username and password are required");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Log In</h2>
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
|
||||
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
|
||||
<input
|
||||
name="koito-username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
|
||||
<label htmlFor="kotio-remember">Remember me</label>
|
||||
</div>
|
||||
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
|
||||
</form>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<h3>Log In</h3>
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<p>
|
||||
Logging in gives you access to <strong>admin tools</strong>, such as
|
||||
updating images, merging items, deleting items, and more.
|
||||
</p>
|
||||
<form
|
||||
action="#"
|
||||
className="flex flex-col items-center gap-4 w-3/4"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<input
|
||||
name="koito-username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="koito-remember"
|
||||
id="koito-remember"
|
||||
onChange={() => setRemember(!remember)}
|
||||
/>
|
||||
<label htmlFor="kotio-remember">Remember me</label>
|
||||
</div>
|
||||
<AsyncButton loading={loading} onClick={loginHandler}>
|
||||
Login
|
||||
</AsyncButton>
|
||||
</form>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,124 +2,159 @@ import { useEffect, useState } from "react";
|
|||
import { Modal } from "./Modal";
|
||||
import { search, type SearchResponse } from "api/api";
|
||||
import SearchResults from "../SearchResults";
|
||||
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
|
||||
import type {
|
||||
MergeFunc,
|
||||
MergeSearchCleanerFunc,
|
||||
} from "~/routes/MediaItems/MediaLayout";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
type: string
|
||||
currentId: number
|
||||
currentTitle: string
|
||||
mergeFunc: MergeFunc
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
||||
open: boolean;
|
||||
setOpen: Function;
|
||||
type: string;
|
||||
currentId: number;
|
||||
currentTitle: string;
|
||||
mergeFunc: MergeFunc;
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc;
|
||||
}
|
||||
|
||||
export default function MergeModal(props: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
|
||||
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState(props.currentTitle);
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
|
||||
{ title: "", id: 0 }
|
||||
);
|
||||
const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
|
||||
const [replaceImage, setReplaceImage] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const closeMergeModal = () => {
|
||||
props.setOpen(false);
|
||||
setQuery("");
|
||||
setData(undefined);
|
||||
setMergeOrderReversed(false);
|
||||
setMergeTarget({ title: "", id: 0 });
|
||||
};
|
||||
|
||||
const closeMergeModal = () => {
|
||||
props.setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
setMergeOrderReversed(false)
|
||||
setMergeTarget({title: '', id: 0})
|
||||
const toggleSelect = ({ title, id }: { title: string; id: number }) => {
|
||||
setMergeTarget({ title: title, id: id });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log("mergeTarget", mergeTarget);
|
||||
}, [mergeTarget]);
|
||||
|
||||
const doMerge = () => {
|
||||
let from, to;
|
||||
if (!mergeOrderReversed) {
|
||||
from = mergeTarget;
|
||||
to = { id: props.currentId, title: props.currentTitle };
|
||||
} else {
|
||||
from = { id: props.currentId, title: props.currentTitle };
|
||||
to = mergeTarget;
|
||||
}
|
||||
|
||||
const toggleSelect = ({title, id}: {title: string, id: number}) => {
|
||||
if (mergeTarget.id === 0) {
|
||||
setMergeTarget({title: title, id: id})
|
||||
props
|
||||
.mergeFunc(from.id, to.id, replaceImage)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
if (mergeOrderReversed) {
|
||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
|
||||
closeMergeModal();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
setMergeTarget({title:"", id: 0})
|
||||
// TODO: handle error
|
||||
console.log(r);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === "") {
|
||||
setData(undefined);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
r = props.mergeCleanerFunc(r, props.currentId);
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(mergeTarget)
|
||||
}, [mergeTarget])
|
||||
|
||||
const doMerge = () => {
|
||||
let from, to
|
||||
if (!mergeOrderReversed) {
|
||||
from = mergeTarget
|
||||
to = {id: props.currentId, title: props.currentTitle}
|
||||
} else {
|
||||
from = {id: props.currentId, title: props.currentTitle}
|
||||
to = mergeTarget
|
||||
}
|
||||
props.mergeFunc(from.id, to.id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
if (mergeOrderReversed) {
|
||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`)
|
||||
closeMergeModal()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} else {
|
||||
// TODO: handle error
|
||||
console.log(r)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
r = props.mergeCleanerFunc(r, props.currentId)
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
return (
|
||||
<Modal isOpen={props.open} onClose={closeMergeModal}>
|
||||
<h2>Merge {props.type}s</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
|
||||
{ mergeTarget.id !== 0 ?
|
||||
<>
|
||||
{mergeOrderReversed ?
|
||||
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
|
||||
:
|
||||
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
|
||||
}
|
||||
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
|
||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||
<h3>Merge {props.type}s</h3>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
defaultValue={props.currentTitle}
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Search for a${props.type.toLowerCase()[0] === "a" ? "n" : ""
|
||||
} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onFocus={(e) => { setQuery(e.target.value); e.target.select()}}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<SearchResults selectorMode data={data} onSelect={toggleSelect} />
|
||||
{mergeTarget.id !== 0 ? (
|
||||
<>
|
||||
{mergeOrderReversed ? (
|
||||
<p className="mt-5">
|
||||
<strong>{props.currentTitle}</strong> will be merged into{" "}
|
||||
<strong>{mergeTarget.title}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-5">
|
||||
<strong>{mergeTarget.title}</strong> will be merged into{" "}
|
||||
<strong>{props.currentTitle}</strong>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)"
|
||||
onClick={doMerge}
|
||||
>
|
||||
Merge Items
|
||||
</button>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="reverse-merge-order"
|
||||
checked={mergeOrderReversed}
|
||||
onChange={() => setMergeOrderReversed(!mergeOrderReversed)}
|
||||
/>
|
||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||
</div>
|
||||
</> :
|
||||
''}
|
||||
</div>
|
||||
{(props.type.toLowerCase() === "album" ||
|
||||
props.type.toLowerCase() === "artist") && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="replace-image"
|
||||
checked={replaceImage}
|
||||
onChange={() => setReplaceImage(!replaceImage)}
|
||||
/>
|
||||
<label htmlFor="replace-image">Replace image</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,34 @@ export function Modal({
|
|||
}
|
||||
}, [isOpen, shouldRender]);
|
||||
|
||||
// Close on Escape key
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
// Close on Escape key
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
// Trap tab navigation to the modal
|
||||
} else if (e.key === 'Tab') {
|
||||
if (modalRef.current) {
|
||||
const focusableEls = modalRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstEl = focusableEls[0];
|
||||
const lastEl = focusableEls[focusableEls.length - 1];
|
||||
const activeEl = document.activeElement
|
||||
|
||||
if (e.shiftKey && activeEl === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && activeEl === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
} else if (!Array.from(focusableEls).find(node => node.isEqualNode(activeEl))) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
|
|
@ -70,13 +94,13 @@ export function Modal({
|
|||
}`}
|
||||
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
|
||||
>
|
||||
🞪
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
open: boolean;
|
||||
setOpen: Function;
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, setOpen }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
const [query, setQuery] = useState("");
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
|
||||
const closeSearchModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
const closeSearchModal = () => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setData(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === "") {
|
||||
setData(undefined);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeSearchModal}>
|
||||
<h2>Search</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search for an artist, album, or track"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="h-3/4 w-full">
|
||||
<SearchResults data={data} onSelect={closeSearchModal}/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeSearchModal}>
|
||||
<h3>Search</h3>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search for an artist, album, or track"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="h-3/4 w-full">
|
||||
<SearchResults data={data} onSelect={closeSearchModal} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
|
|||
import ThemeHelper from "../../routes/ThemeHelper";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import ApiKeysModal from "./ApiKeysModal";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import ExportModal from "./ExportModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -19,7 +21,7 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
|||
const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
|
||||
|
||||
return (
|
||||
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||
<Modal h={700} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||
<Tabs
|
||||
defaultValue="Appearance"
|
||||
orientation="vertical" // still vertical, but layout is responsive via Tailwind
|
||||
|
|
@ -29,9 +31,12 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
|||
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
||||
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
||||
{user && (
|
||||
<TabsTrigger className={triggerClasses} value="API Keys">
|
||||
API Keys
|
||||
</TabsTrigger>
|
||||
<>
|
||||
<TabsTrigger className={triggerClasses} value="API Keys">
|
||||
API Keys
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={triggerClasses} value="Export">Export</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
|||
<TabsContent value="API Keys" className={contentClasses}>
|
||||
<ApiKeysModal />
|
||||
</TabsContent>
|
||||
<TabsContent value="Export" className={contentClasses}>
|
||||
<ExportModal />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
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 SidebarItem from "./SidebarItem";
|
||||
import SidebarSettings from "./SidebarSettings";
|
||||
import { getRewindParams, getRewindYear } from "~/utils/utils";
|
||||
|
||||
export default function Sidebar() {
|
||||
const iconSize = 20;
|
||||
const iconSize = 20;
|
||||
|
||||
return (
|
||||
<div className="
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
z-50
|
||||
flex
|
||||
sm:flex-col
|
||||
|
|
@ -28,28 +30,44 @@ export default function Sidebar() {
|
|||
sm:px-1
|
||||
px-4
|
||||
bg-(--color-bg)
|
||||
">
|
||||
<div className="flex gap-4 sm:flex-col">
|
||||
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
|
||||
<Home size={iconSize} />
|
||||
</SidebarItem>
|
||||
<SidebarSearch size={iconSize} />
|
||||
</div>
|
||||
<div className="flex gap-4 sm:flex-col">
|
||||
<SidebarItem
|
||||
icon
|
||||
keyHint={<ExternalLink size={14} />}
|
||||
space={22}
|
||||
externalLink
|
||||
to="https://koito.io"
|
||||
name="About"
|
||||
onClick={() => {}}
|
||||
modal={<></>}
|
||||
>
|
||||
<Info size={iconSize} />
|
||||
</SidebarItem>
|
||||
<SidebarSettings size={iconSize} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
"
|
||||
>
|
||||
<div className="flex gap-4 sm:flex-col">
|
||||
<SidebarItem
|
||||
space={10}
|
||||
to="/"
|
||||
name="Home"
|
||||
onClick={() => {}}
|
||||
modal={<></>}
|
||||
>
|
||||
<Home size={iconSize} />
|
||||
</SidebarItem>
|
||||
<SidebarSearch size={iconSize} />
|
||||
<SidebarItem
|
||||
space={10}
|
||||
to="/rewind"
|
||||
name="Rewind"
|
||||
onClick={() => {}}
|
||||
modal={<></>}
|
||||
>
|
||||
<History size={iconSize} />
|
||||
</SidebarItem>
|
||||
</div>
|
||||
<div className="flex gap-4 sm:flex-col">
|
||||
<SidebarItem
|
||||
icon
|
||||
keyHint={<ExternalLink size={14} />}
|
||||
space={22}
|
||||
externalLink
|
||||
to="https://koito.io"
|
||||
name="About"
|
||||
onClick={() => {}}
|
||||
modal={<></>}
|
||||
>
|
||||
<Info size={iconSize} />
|
||||
</SidebarItem>
|
||||
<SidebarSettings size={iconSize} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,43 @@
|
|||
import type { Theme } from "~/providers/ThemeProvider";
|
||||
import type { Theme } from "~/styles/themes.css";
|
||||
|
||||
interface Props {
|
||||
theme: Theme
|
||||
setTheme: Function
|
||||
theme: Theme;
|
||||
themeName: string;
|
||||
setTheme: Function;
|
||||
}
|
||||
|
||||
export default function ThemeOption({ theme, setTheme }: Props) {
|
||||
export default function ThemeOption({ theme, themeName, setTheme }: Props) {
|
||||
const capitalizeFirstLetter = (s: string) => {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
};
|
||||
|
||||
const capitalizeFirstLetter = (s: string) => {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={() => setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
|
||||
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
onClick={() => setTheme(themeName)}
|
||||
className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-3 items-center border-2 justify-between"
|
||||
style={{
|
||||
background: theme.bg,
|
||||
color: theme.fg,
|
||||
borderColor: theme.bgSecondary,
|
||||
}}
|
||||
>
|
||||
<div className="text-xs sm:text-sm">
|
||||
{capitalizeFirstLetter(themeName)}
|
||||
</div>
|
||||
<div className="flex gap-2 w-full">
|
||||
<div
|
||||
className="w-2/7 max-w-[50px] h-[30px] rounded-md"
|
||||
style={{ background: theme.bgSecondary }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2/7 max-w-[50px] h-[30px] rounded-md"
|
||||
style={{ background: theme.fgSecondary }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2/7 max-w-[50px] h-[30px] rounded-md"
|
||||
style={{ background: theme.primary }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +1,78 @@
|
|||
// ThemeSwitcher.tsx
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { themes } from '~/providers/ThemeProvider';
|
||||
import ThemeOption from './ThemeOption';
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import themes from "~/styles/themes.css";
|
||||
import ThemeOption from "./ThemeOption";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { setTheme } = useTheme();
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
};
|
||||
|
||||
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||
const [custom, setCustom] = useState(
|
||||
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved && saved !== theme) {
|
||||
setTheme(saved);
|
||||
} else if (!saved) {
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}, []);
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom);
|
||||
try {
|
||||
const themeData = JSON.parse(custom);
|
||||
setCustomTheme(themeData);
|
||||
setCustom(JSON.stringify(themeData, null, " "));
|
||||
console.log(themeData);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Select Theme</h2>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{themes.map((t) => (
|
||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
||||
))}
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3>Select Theme</h3>
|
||||
<div className="mb-3">
|
||||
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 items-center gap-2">
|
||||
{Object.entries(themes).map(([name, themeData]) => (
|
||||
<ThemeOption
|
||||
setTheme={setTheme}
|
||||
key={name}
|
||||
theme={themeData}
|
||||
themeName={name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Use Custom Theme</h3>
|
||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||
<textarea
|
||||
name="custom-theme"
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
id="custom-theme-input"
|
||||
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
|
||||
value={custom}
|
||||
/>
|
||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { User } from "api/api";
|
||||
import { getCfg, type User } from "api/api";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface AppContextType {
|
||||
user: User | null | undefined;
|
||||
configurableHomeActivity: boolean;
|
||||
homeItems: number;
|
||||
defaultTheme: string;
|
||||
setConfigurableHomeActivity: (value: boolean) => void;
|
||||
setHomeItems: (value: number) => void;
|
||||
setUsername: (value: string) => void;
|
||||
|
|
@ -22,15 +23,19 @@ export const useAppContext = () => {
|
|||
|
||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null | undefined>(undefined);
|
||||
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
|
||||
const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [configurableHomeActivity, setConfigurableHomeActivity] =
|
||||
useState<boolean>(false);
|
||||
const [homeItems, setHomeItems] = useState<number>(0);
|
||||
|
||||
const setUsername = (value: string) => {
|
||||
if (!user) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setUser({...user, username: value})
|
||||
}
|
||||
setUser({ ...user, username: value });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/apis/web/v1/user/me")
|
||||
|
|
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
|||
|
||||
setConfigurableHomeActivity(true);
|
||||
setHomeItems(12);
|
||||
|
||||
getCfg().then((cfg) => {
|
||||
console.log(cfg);
|
||||
if (cfg.default_theme !== "") {
|
||||
setDefaultTheme(cfg.default_theme);
|
||||
} else {
|
||||
setDefaultTheme("yuu");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (user === undefined) {
|
||||
// Block rendering the app until config is loaded
|
||||
if (user === undefined || defaultTheme === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
|||
user,
|
||||
configurableHomeActivity,
|
||||
homeItems,
|
||||
defaultTheme,
|
||||
setConfigurableHomeActivity,
|
||||
setHomeItems,
|
||||
setUsername,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,259 +1,135 @@
|
|||
import { createContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
// a fair number of colors aren't actually used, but i'm keeping
|
||||
// them so that I don't have to worry about colors when adding new ui elements
|
||||
export type Theme = {
|
||||
name: string,
|
||||
bg: string
|
||||
bgSecondary: string
|
||||
bgTertiary: string
|
||||
fg: string
|
||||
fgSecondary: string
|
||||
fgTertiary: string
|
||||
primary: string
|
||||
primaryDim: string
|
||||
accent: string
|
||||
accentDim: string
|
||||
error: string
|
||||
warning: string
|
||||
info: string
|
||||
success: string
|
||||
}
|
||||
|
||||
export const themes: Theme[] = [
|
||||
{
|
||||
name: "yuu",
|
||||
bg: "#161312",
|
||||
bgSecondary: "#272120",
|
||||
bgTertiary: "#382F2E",
|
||||
fg: "#faf5f4",
|
||||
fgSecondary: "#CCC7C6",
|
||||
fgTertiary: "#B0A3A1",
|
||||
primary: "#ff826d",
|
||||
primaryDim: "#CE6654",
|
||||
accent: "#464DAE",
|
||||
accentDim: "#393D74",
|
||||
error: "#FF6247",
|
||||
warning: "#FFC107",
|
||||
success: "#3ECE5F",
|
||||
info: "#41C4D8",
|
||||
},
|
||||
{
|
||||
name: "varia",
|
||||
bg: "rgb(25, 25, 29)",
|
||||
bgSecondary: "#222222",
|
||||
bgTertiary: "#333333",
|
||||
fg: "#eeeeee",
|
||||
fgSecondary: "#aaaaaa",
|
||||
fgTertiary: "#888888",
|
||||
primary: "rgb(203, 110, 240)",
|
||||
primaryDim: "#c28379",
|
||||
accent: "#f0ad0a",
|
||||
accentDim: "#d08d08",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
bg: "rgb(8, 15, 24)",
|
||||
bgSecondary: "rgb(15, 27, 46)",
|
||||
bgTertiary: "rgb(15, 41, 70)",
|
||||
fg: "#dbdfe7",
|
||||
fgSecondary: "#9ea3a8",
|
||||
fgTertiary: "#74787c",
|
||||
primary: "#1a97eb",
|
||||
primaryDim: "#2680aa",
|
||||
accent: "#f0ad0a",
|
||||
accentDim: "#d08d08",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "catppuccin",
|
||||
bg: "#1e1e2e",
|
||||
bgSecondary: "#181825",
|
||||
bgTertiary: "#11111b",
|
||||
fg: "#cdd6f4",
|
||||
fgSecondary: "#a6adc8",
|
||||
fgTertiary: "#9399b2",
|
||||
primary: "#89b4fa",
|
||||
primaryDim: "#739df0",
|
||||
accent: "#f38ba8",
|
||||
accentDim: "#d67b94",
|
||||
error: "#f38ba8",
|
||||
warning: "#f9e2af",
|
||||
success: "#a6e3a1",
|
||||
info: "#89dceb",
|
||||
},
|
||||
{
|
||||
name: "autumn",
|
||||
bg: "rgb(44, 25, 18)",
|
||||
bgSecondary: "rgb(70, 40, 18)",
|
||||
bgTertiary: "#4b2f1c",
|
||||
fg: "#fef9f3",
|
||||
fgSecondary: "#dbc6b0",
|
||||
fgTertiary: "#a3917a",
|
||||
primary: "#d97706",
|
||||
primaryDim: "#b45309",
|
||||
accent: "#8c4c28",
|
||||
accentDim: "#6b3b1f",
|
||||
error: "#d1433f",
|
||||
warning: "#e38b29",
|
||||
success: "#6b8e23",
|
||||
info: "#c084fc",
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
bg: "#000000",
|
||||
bgSecondary: "#1a1a1a",
|
||||
bgTertiary: "#2a2a2a",
|
||||
fg: "#dddddd",
|
||||
fgSecondary: "#aaaaaa",
|
||||
fgTertiary: "#888888",
|
||||
primary: "#08c08c",
|
||||
primaryDim: "#08c08c",
|
||||
accent: "#f0ad0a",
|
||||
accentDim: "#d08d08",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "wine",
|
||||
bg: "#23181E",
|
||||
bgSecondary: "#2C1C25",
|
||||
bgTertiary: "#422A37",
|
||||
fg: "#FCE0B3",
|
||||
fgSecondary: "#C7AC81",
|
||||
fgTertiary: "#A78E64",
|
||||
primary: "#EA8A64",
|
||||
primaryDim: "#BD7255",
|
||||
accent: "#FAE99B",
|
||||
accentDim: "#C6B464",
|
||||
error: "#fca5a5",
|
||||
warning: "#fde68a",
|
||||
success: "#bbf7d0",
|
||||
info: "#bae6fd",
|
||||
},
|
||||
{
|
||||
name: "pearl",
|
||||
bg: "#FFFFFF",
|
||||
bgSecondary: "#EEEEEE",
|
||||
bgTertiary: "#E0E0E0",
|
||||
fg: "#333333",
|
||||
fgSecondary: "#555555",
|
||||
fgTertiary: "#777777",
|
||||
primary: "#007BFF",
|
||||
primaryDim: "#0056B3",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#DC3545",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "asuka",
|
||||
bg: "#3B1212",
|
||||
bgSecondary: "#471B1B",
|
||||
bgTertiary: "#020202",
|
||||
fg: "#F1E9E6",
|
||||
fgSecondary: "#CCB6AE",
|
||||
fgTertiary: "#9F8176",
|
||||
primary: "#F1E9E6",
|
||||
primaryDim: "#CCB6AE",
|
||||
accent: "#41CE41",
|
||||
accentDim: "#3BA03B",
|
||||
error: "#DC143C",
|
||||
warning: "#FFD700",
|
||||
success: "#32CD32",
|
||||
info: "#1E90FF",
|
||||
},
|
||||
{
|
||||
name: "urim",
|
||||
bg: "#101713",
|
||||
bgSecondary: "#1B2921",
|
||||
bgTertiary: "#273B30",
|
||||
fg: "#D2E79E",
|
||||
fgSecondary: "#B4DA55",
|
||||
fgTertiary: "#7E9F2A",
|
||||
primary: "#ead500",
|
||||
primaryDim: "#C1B210",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#EE5237",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "match",
|
||||
bg: "#071014",
|
||||
bgSecondary: "#0A181E",
|
||||
bgTertiary: "#112A34",
|
||||
fg: "#ebeaeb",
|
||||
fgSecondary: "#BDBDBD",
|
||||
fgTertiary: "#A2A2A2",
|
||||
primary: "#fda827",
|
||||
primaryDim: "#C78420",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "lemon",
|
||||
bg: "#1a171a",
|
||||
bgSecondary: "#2E272E",
|
||||
bgTertiary: "#443844",
|
||||
fg: "#E6E2DC",
|
||||
fgSecondary: "#B2ACA1",
|
||||
fgTertiary: "#968F82",
|
||||
primary: "#f5c737",
|
||||
primaryDim: "#C29D2F",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
];
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { type Theme, themes } from "~/styles/themes.css";
|
||||
import { themeVars } from "~/styles/vars.css";
|
||||
import { useAppContext } from "./AppProvider";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: string;
|
||||
themeName: string;
|
||||
theme: Theme;
|
||||
setTheme: (theme: string) => void;
|
||||
resetTheme: () => void;
|
||||
setCustomTheme: (theme: Theme) => void;
|
||||
getCustomTheme: () => Theme | undefined;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({
|
||||
theme: initialTheme,
|
||||
children,
|
||||
}: {
|
||||
theme: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [theme, setTheme] = useState(initialTheme);
|
||||
function toKebabCase(str: string) {
|
||||
return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
}
|
||||
|
||||
function applyCustomThemeVars(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === "name") continue;
|
||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCustomThemeVars() {
|
||||
for (const cssVar of Object.values(themeVars)) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredCustomTheme(): Theme | undefined {
|
||||
const themeStr = localStorage.getItem("custom-theme");
|
||||
if (!themeStr) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(themeStr);
|
||||
const { name, ...theme } = parsed;
|
||||
return theme as Theme;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
let defaultTheme = useAppContext().defaultTheme;
|
||||
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
|
||||
const [themeName, setThemeName] = useState(
|
||||
themes[initialTheme] ? initialTheme : defaultTheme
|
||||
);
|
||||
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
||||
if (initialTheme === "custom") {
|
||||
const customTheme = getStoredCustomTheme();
|
||||
return customTheme || themes[defaultTheme];
|
||||
}
|
||||
return themes[initialTheme] || themes[defaultTheme];
|
||||
});
|
||||
|
||||
const setTheme = (newThemeName: string) => {
|
||||
setThemeName(newThemeName);
|
||||
if (newThemeName === "custom") {
|
||||
const customTheme = getStoredCustomTheme();
|
||||
if (customTheme) {
|
||||
setCurrentTheme(customTheme);
|
||||
} else {
|
||||
// Fallback to default theme if no custom theme found
|
||||
setThemeName(defaultTheme);
|
||||
setCurrentTheme(themes[defaultTheme]);
|
||||
}
|
||||
} else {
|
||||
const foundTheme = themes[newThemeName];
|
||||
if (foundTheme) {
|
||||
localStorage.setItem("theme", newThemeName);
|
||||
setCurrentTheme(foundTheme);
|
||||
} else {
|
||||
setTheme(defaultTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetTheme = () => {
|
||||
setThemeName(defaultTheme);
|
||||
localStorage.removeItem("theme");
|
||||
setCurrentTheme(themes[defaultTheme]);
|
||||
};
|
||||
|
||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
|
||||
applyCustomThemeVars(customTheme);
|
||||
setThemeName("custom");
|
||||
localStorage.setItem("theme", "custom");
|
||||
setCurrentTheme(customTheme);
|
||||
}, []);
|
||||
|
||||
const getCustomTheme = (): Theme | undefined => {
|
||||
return getStoredCustomTheme();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
const root = document.documentElement;
|
||||
|
||||
root.setAttribute("data-theme", themeName);
|
||||
|
||||
if (themeName === "custom") {
|
||||
applyCustomThemeVars(currentTheme);
|
||||
} else {
|
||||
clearCustomThemeVars();
|
||||
}
|
||||
}, [theme]);
|
||||
}, [themeName, currentTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themeName,
|
||||
theme: currentTheme,
|
||||
setTheme,
|
||||
resetTheme,
|
||||
setCustomTheme,
|
||||
getCustomTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeContext }
|
||||
export { ThemeContext };
|
||||
|
|
|
|||
|
|
@ -9,16 +9,19 @@ import {
|
|||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import './themes.css'
|
||||
import "./themes.css";
|
||||
import "./app.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from './providers/ThemeProvider';
|
||||
import { ThemeProvider } from "./providers/ThemeProvider";
|
||||
import Sidebar from "./components/sidebar/Sidebar";
|
||||
import Footer from "./components/Footer";
|
||||
import { AppProvider } from "./providers/AppProvider";
|
||||
import { initTimezoneCookie } from "./tz";
|
||||
|
||||
initTimezoneCookie();
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient()
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
|
|
@ -35,14 +38,23 @@ export const links: Route.LinksFunction = () => [
|
|||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" style={{backgroundColor: 'black'}}>
|
||||
<html lang="en" style={{ backgroundColor: "black" }}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="Koito" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<Meta />
|
||||
|
|
@ -58,81 +70,73 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
let theme = localStorage.getItem('theme') ?? 'yuu'
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex-col flex sm:flex-row">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
<AppProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex-col flex sm:flex-row">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HydrateFallback() {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
const error = useRouteError();
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details = error.status === 404
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details =
|
||||
error.status === 404
|
||||
? "The requested page could not be found."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
} else if (import.meta.env.DEV && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
let theme = 'yuu'
|
||||
try {
|
||||
theme = localStorage.getItem('theme') ?? theme
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
const title = `${message} - Koito`;
|
||||
|
||||
const title = `${message} - Koito`
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<title>{title}</title>
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<div className="w-full flex flex-col">
|
||||
<main className="pt-16 p-4 container mx-auto flex-grow">
|
||||
<div className="flex gap-4 items-end">
|
||||
<img className="w-[200px] rounded" src="../yuu.jpg" />
|
||||
<div>
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
</div>
|
||||
</div>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeProvider>
|
||||
<title>{title}</title>
|
||||
<Sidebar />
|
||||
<div className="flex">
|
||||
<div className="w-full flex flex-col">
|
||||
<main className="pt-16 p-4 mx-auto flex-grow">
|
||||
<div className="md:flex gap-4">
|
||||
<img className="w-[200px] rounded mb-3" src="../yuu.jpg" />
|
||||
<div>
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
</div>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
index("routes/Home.tsx"),
|
||||
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
|
||||
route("/album/:id", "routes/MediaItems/Album.tsx"),
|
||||
route("/track/:id", "routes/MediaItems/Track.tsx"),
|
||||
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
|
||||
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
|
||||
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
|
||||
route("/listens", "routes/Charts/Listens.tsx"),
|
||||
route("/theme-helper", "routes/ThemeHelper.tsx"),
|
||||
index("routes/Home.tsx"),
|
||||
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
|
||||
route("/album/:id", "routes/MediaItems/Album.tsx"),
|
||||
route("/track/:id", "routes/MediaItems/Track.tsx"),
|
||||
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
|
||||
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
|
||||
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
|
||||
route("/listens", "routes/Charts/Listens.tsx"),
|
||||
route("/rewind", "routes/RewindPage.tsx"),
|
||||
route("/theme-helper", "routes/ThemeHelper.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type PaginatedResponse } from "api/api";
|
||||
import { type Album, type PaginatedResponse, type Ranked } from "api/api";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
url.searchParams.set("page", page);
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
|||
}
|
||||
|
||||
export default function AlbumChart() {
|
||||
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
|
||||
const { top_albums: initialData } = useLoaderData<{
|
||||
top_albums: PaginatedResponse<Ranked<Album>>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
|
|
@ -28,26 +30,35 @@ export default function AlbumChart() {
|
|||
initialData={initialData}
|
||||
endpoint="chart/top-albums"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
className="default"
|
||||
onClick={onNext}
|
||||
disabled={!data.has_next_page}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<TopItemList
|
||||
ranked
|
||||
separators
|
||||
data={data}
|
||||
className="w-[400px] sm:w-[600px]"
|
||||
className="w-11/12 sm:w-[600px]"
|
||||
type="album"
|
||||
/>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
<button
|
||||
className="default"
|
||||
onClick={onNext}
|
||||
disabled={!data.has_next_page}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type PaginatedResponse } from "api/api";
|
||||
import { type Album, type PaginatedResponse, type Ranked } from "api/api";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
url.searchParams.set("page", page);
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
||||
|
|
@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
|||
}
|
||||
|
||||
export default function Artist() {
|
||||
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
|
||||
const { top_artists: initialData } = useLoaderData<{
|
||||
top_artists: PaginatedResponse<Ranked<Album>>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
|
|
@ -28,26 +30,35 @@ export default function Artist() {
|
|||
initialData={initialData}
|
||||
endpoint="chart/top-artists"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
className="default"
|
||||
onClick={onNext}
|
||||
disabled={!data.has_next_page}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<TopItemList
|
||||
ranked
|
||||
separators
|
||||
data={data}
|
||||
className="w-[400px] sm:w-[600px]"
|
||||
className="w-11/12 sm:w-[600px]"
|
||||
type="artist"
|
||||
/>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
<button
|
||||
className="default"
|
||||
onClick={onNext}
|
||||
disabled={!data.has_next_page}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,264 +1,272 @@
|
|||
import {
|
||||
useFetcher,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { average } from "color.js"
|
||||
import { imageUrl, type PaginatedResponse } from "api/api"
|
||||
import PeriodSelector from "~/components/PeriodSelector"
|
||||
import { useFetcher, useLocation, useNavigate } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { average } from "color.js";
|
||||
import { imageUrl, type PaginatedResponse } from "api/api";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
|
||||
interface ChartLayoutProps<T> {
|
||||
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
|
||||
initialData: PaginatedResponse<T>
|
||||
endpoint: string
|
||||
render: (opts: {
|
||||
data: PaginatedResponse<T>
|
||||
page: number
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}) => React.ReactNode
|
||||
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played";
|
||||
initialData: PaginatedResponse<T>;
|
||||
endpoint: string;
|
||||
render: (opts: {
|
||||
data: PaginatedResponse<T>;
|
||||
page: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ChartLayout<T>({
|
||||
title,
|
||||
initialData,
|
||||
endpoint,
|
||||
render,
|
||||
title,
|
||||
initialData,
|
||||
endpoint,
|
||||
render,
|
||||
}: ChartLayoutProps<T>) {
|
||||
const pgTitle = `${title} - Koito`
|
||||
const pgTitle = `${title} - Koito`;
|
||||
|
||||
const fetcher = useFetcher()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const fetcher = useFetcher();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentParams = new URLSearchParams(location.search)
|
||||
const currentPage = parseInt(currentParams.get("page") || "1", 10)
|
||||
const currentParams = new URLSearchParams(location.search);
|
||||
const currentPage = parseInt(currentParams.get("page") || "1", 10);
|
||||
|
||||
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
||||
? fetcher.data[endpoint]
|
||||
: initialData
|
||||
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
||||
? fetcher.data[endpoint]
|
||||
: initialData;
|
||||
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)")
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||
|
||||
useEffect(() => {
|
||||
if ((data?.items?.length ?? 0) === 0) return
|
||||
useEffect(() => {
|
||||
if ((data?.items?.length ?? 0) === 0) return;
|
||||
|
||||
const img = (data.items[0] as any)?.image
|
||||
if (!img) return
|
||||
const img = (data.items[0] as any)?.item?.image;
|
||||
if (!img) return;
|
||||
|
||||
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
|
||||
})
|
||||
}, [data])
|
||||
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const period = currentParams.get("period") ?? "day"
|
||||
const year = currentParams.get("year")
|
||||
const month = currentParams.get("month")
|
||||
const week = currentParams.get("week")
|
||||
const period = currentParams.get("period") ?? "day";
|
||||
const year = currentParams.get("year");
|
||||
const month = currentParams.get("month");
|
||||
const week = currentParams.get("week");
|
||||
|
||||
const updateParams = (params: Record<string, string | null>) => {
|
||||
const nextParams = new URLSearchParams(location.search)
|
||||
const updateParams = (params: Record<string, string | null>) => {
|
||||
const nextParams = new URLSearchParams(location.search);
|
||||
|
||||
for (const key in params) {
|
||||
const val = params[key]
|
||||
if (val !== null) {
|
||||
nextParams.set(key, val)
|
||||
} else {
|
||||
nextParams.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/${endpoint}?${nextParams.toString()}`
|
||||
navigate(url, { replace: false })
|
||||
for (const key in params) {
|
||||
const val = params[key];
|
||||
if (val !== null) {
|
||||
nextParams.set(key, val);
|
||||
} else {
|
||||
nextParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetPeriod = (p: string) => {
|
||||
updateParams({
|
||||
period: p,
|
||||
page: "1",
|
||||
year: null,
|
||||
month: null,
|
||||
week: null,
|
||||
})
|
||||
}
|
||||
const handleSetYear = (val: string) => {
|
||||
if (val == "") {
|
||||
updateParams({
|
||||
period: period,
|
||||
page: "1",
|
||||
year: null,
|
||||
month: null,
|
||||
week: null
|
||||
})
|
||||
return
|
||||
}
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: val,
|
||||
})
|
||||
}
|
||||
const handleSetMonth = (val: string) => {
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: year ?? new Date().getFullYear().toString(),
|
||||
month: val,
|
||||
})
|
||||
}
|
||||
const handleSetWeek = (val: string) => {
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: year ?? new Date().getFullYear().toString(),
|
||||
month: null,
|
||||
week: val,
|
||||
})
|
||||
}
|
||||
const url = `/${endpoint}?${nextParams.toString()}`;
|
||||
navigate(url, { replace: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(`/${endpoint}?${currentParams.toString()}`)
|
||||
}, [location.search])
|
||||
const handleSetPeriod = (p: string) => {
|
||||
updateParams({
|
||||
period: p,
|
||||
page: "1",
|
||||
year: null,
|
||||
month: null,
|
||||
week: null,
|
||||
});
|
||||
};
|
||||
const handleSetYear = (val: string) => {
|
||||
if (val == "") {
|
||||
updateParams({
|
||||
period: period,
|
||||
page: "1",
|
||||
year: null,
|
||||
month: null,
|
||||
week: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: val,
|
||||
});
|
||||
};
|
||||
const handleSetMonth = (val: string) => {
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: year ?? new Date().getFullYear().toString(),
|
||||
month: val,
|
||||
});
|
||||
};
|
||||
const handleSetWeek = (val: string) => {
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: year ?? new Date().getFullYear().toString(),
|
||||
month: null,
|
||||
week: val,
|
||||
});
|
||||
};
|
||||
|
||||
const setPage = (nextPage: number) => {
|
||||
const nextParams = new URLSearchParams(location.search)
|
||||
nextParams.set("page", String(nextPage))
|
||||
const url = `/${endpoint}?${nextParams.toString()}`
|
||||
fetcher.load(url)
|
||||
navigate(url, { replace: false })
|
||||
}
|
||||
useEffect(() => {
|
||||
fetcher.load(`/${endpoint}?${currentParams.toString()}`);
|
||||
}, [location.search]);
|
||||
|
||||
const handleNextPage = () => setPage(currentPage + 1)
|
||||
const handlePrevPage = () => setPage(currentPage - 1)
|
||||
const setPage = (nextPage: number) => {
|
||||
const nextParams = new URLSearchParams(location.search);
|
||||
nextParams.set("page", String(nextPage));
|
||||
const url = `/${endpoint}?${nextParams.toString()}`;
|
||||
fetcher.load(url);
|
||||
navigate(url, { replace: false });
|
||||
};
|
||||
|
||||
const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`)
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`)
|
||||
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`)
|
||||
const handleNextPage = () => setPage(currentPage + 1);
|
||||
const handlePrevPage = () => setPage(currentPage - 1);
|
||||
|
||||
const getDateRange = (): string => {
|
||||
let from: Date
|
||||
let to: Date
|
||||
const yearOptions = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => `${new Date().getFullYear() - i}`
|
||||
);
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
|
||||
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`);
|
||||
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth() // 0-indexed
|
||||
const currentDate = now.getDate()
|
||||
const getDateRange = (): string => {
|
||||
let from: Date;
|
||||
let to: Date;
|
||||
|
||||
if (year && month) {
|
||||
from = new Date(parseInt(year), parseInt(month) - 1, 1)
|
||||
to = new Date(from)
|
||||
to.setMonth(from.getMonth() + 1)
|
||||
to.setDate(0)
|
||||
} else if (year && week) {
|
||||
const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
|
||||
const weekNumber = parseInt(week)
|
||||
from = new Date(base)
|
||||
from.setDate(base.getDate() + (weekNumber - 1) * 7)
|
||||
to = new Date(from)
|
||||
to.setDate(from.getDate() + 6)
|
||||
} else if (year) {
|
||||
from = new Date(parseInt(year), 0, 1)
|
||||
to = new Date(parseInt(year), 11, 31)
|
||||
} else {
|
||||
switch (period) {
|
||||
case "day":
|
||||
from = new Date(now)
|
||||
to = new Date(now)
|
||||
break
|
||||
case "week":
|
||||
to = new Date(now)
|
||||
from = new Date(now)
|
||||
from.setDate(to.getDate() - 6)
|
||||
break
|
||||
case "month":
|
||||
to = new Date(now)
|
||||
from = new Date(now)
|
||||
if (currentMonth === 0) {
|
||||
from = new Date(currentYear - 1, 11, currentDate)
|
||||
} else {
|
||||
from = new Date(currentYear, currentMonth - 1, currentDate)
|
||||
}
|
||||
break
|
||||
case "year":
|
||||
to = new Date(now)
|
||||
from = new Date(currentYear - 1, currentMonth, currentDate)
|
||||
break
|
||||
case "all_time":
|
||||
return "All Time"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth(); // 0-indexed
|
||||
const currentDate = now.getDate();
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
return `${formatter.format(from)} - ${formatter.format(to)}`
|
||||
if (year && month) {
|
||||
from = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||
to = new Date(from);
|
||||
to.setMonth(from.getMonth() + 1);
|
||||
to.setDate(0);
|
||||
} else if (year && week) {
|
||||
const base = new Date(parseInt(year), 0, 1); // Jan 1 of the year
|
||||
const weekNumber = parseInt(week);
|
||||
from = new Date(base);
|
||||
from.setDate(base.getDate() + (weekNumber - 1) * 7);
|
||||
to = new Date(from);
|
||||
to.setDate(from.getDate() + 6);
|
||||
} else if (year) {
|
||||
from = new Date(parseInt(year), 0, 1);
|
||||
to = new Date(parseInt(year), 11, 31);
|
||||
} else {
|
||||
switch (period) {
|
||||
case "day":
|
||||
from = new Date(now);
|
||||
to = new Date(now);
|
||||
break;
|
||||
case "week":
|
||||
to = new Date(now);
|
||||
from = new Date(now);
|
||||
from.setDate(to.getDate() - 6);
|
||||
break;
|
||||
case "month":
|
||||
to = new Date(now);
|
||||
from = new Date(now);
|
||||
if (currentMonth === 0) {
|
||||
from = new Date(currentYear - 1, 11, currentDate);
|
||||
} else {
|
||||
from = new Date(currentYear, currentMonth - 1, currentDate);
|
||||
}
|
||||
break;
|
||||
case "year":
|
||||
to = new Date(now);
|
||||
from = new Date(currentYear - 1, currentMonth, currentDate);
|
||||
break;
|
||||
case "all_time":
|
||||
return "All Time";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full min-h-screen"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
|
||||
transition: "1000",
|
||||
}}
|
||||
>
|
||||
<title>{pgTitle}</title>
|
||||
<meta property="og:title" content={pgTitle} />
|
||||
<meta name="description" content={pgTitle} />
|
||||
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
||||
<h1>{title}</h1>
|
||||
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
||||
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
|
||||
<div className="flex gap-5">
|
||||
<select
|
||||
value={year ?? ""}
|
||||
onChange={(e) => handleSetYear(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Year</option>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={month ?? ""}
|
||||
onChange={(e) => handleSetMonth(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Month</option>
|
||||
{monthOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={week ?? ""}
|
||||
onChange={(e) => handleSetWeek(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Week</option>
|
||||
{weekOptions.map((w) => (
|
||||
<option key={w} value={w}>{w}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
||||
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
|
||||
{render({
|
||||
data,
|
||||
page: currentPage,
|
||||
onNext: handleNextPage,
|
||||
onPrev: handlePrevPage,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return `${formatter.format(from)} - ${formatter.format(to)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full min-h-screen"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
|
||||
transition: "1000",
|
||||
}}
|
||||
>
|
||||
<title>{pgTitle}</title>
|
||||
<meta property="og:title" content={pgTitle} />
|
||||
<meta name="description" content={pgTitle} />
|
||||
<div className="w-19/20 sm:17/20 mx-auto pt-6 sm:pt-12">
|
||||
<h1>{title}</h1>
|
||||
<div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
|
||||
<PeriodSelector
|
||||
current={period}
|
||||
setter={handleSetPeriod}
|
||||
disableCache
|
||||
/>
|
||||
<div className="flex gap-5">
|
||||
<select
|
||||
value={year ?? ""}
|
||||
onChange={(e) => handleSetYear(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Year</option>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={month ?? ""}
|
||||
onChange={(e) => handleSetMonth(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Month</option>
|
||||
{monthOptions.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={week ?? ""}
|
||||
onChange={(e) => handleSetWeek(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Week</option>
|
||||
{weekOptions.map((w) => (
|
||||
<option key={w} value={w}>
|
||||
{w}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
||||
<div className="mt-10 sm:mt-20 flex mx-auto justify-between">
|
||||
{render({
|
||||
data,
|
||||
page: currentPage,
|
||||
onNext: handleNextPage,
|
||||
onPrev: handlePrevPage,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { deleteListen, type Listen, type PaginatedResponse } from "api/api";
|
|||
import { timeSince } from "~/utils/utils";
|
||||
import ArtistLinks from "~/components/ArtistLinks";
|
||||
import { useState } from "react";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
|
@ -25,6 +26,7 @@ export default function Listens() {
|
|||
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
||||
|
||||
const [items, setItems] = useState<Listen[] | null>(null)
|
||||
const { user } = useAppContext()
|
||||
|
||||
const handleDelete = async (listen: Listen) => {
|
||||
if (!initialData) return
|
||||
|
|
@ -61,11 +63,12 @@ export default function Listens() {
|
|||
<tbody>
|
||||
{listens.map((item) => (
|
||||
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
||||
<td className="w-[1px] pr-2 align-middle">
|
||||
<td className="w-[17px] pr-2 align-middle">
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||
aria-label="Delete"
|
||||
hidden={user === null || user === undefined}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type PaginatedResponse } from "api/api";
|
||||
import { type Track, type PaginatedResponse, type Ranked } from "api/api";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
url.searchParams.set("page", page);
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
||||
|
|
@ -15,12 +15,14 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
|
|||
throw new Response("Failed to load top tracks", { status: 500 });
|
||||
}
|
||||
|
||||
const top_tracks: PaginatedResponse<Album> = await res.json();
|
||||
const top_tracks: PaginatedResponse<Track> = await res.json();
|
||||
return { top_tracks };
|
||||
}
|
||||
|
||||
export default function TrackChart() {
|
||||
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
|
||||
const { top_tracks: initialData } = useLoaderData<{
|
||||
top_tracks: PaginatedResponse<Ranked<Track>>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
|
|
@ -28,26 +30,35 @@ export default function TrackChart() {
|
|||
initialData={initialData}
|
||||
endpoint="chart/top-tracks"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
className="default"
|
||||
onClick={onNext}
|
||||
disabled={!data.has_next_page}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<TopItemList
|
||||
ranked
|
||||
separators
|
||||
data={data}
|
||||
className="w-[400px] sm:w-[600px]"
|
||||
className="w-11/12 sm:w-[600px]"
|
||||
type="track"
|
||||
/>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
<button
|
||||
className="default"
|
||||
onClick={onNext}
|
||||
disabled={!data.has_next_page}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,30 +10,30 @@ import PeriodSelector from "~/components/PeriodSelector";
|
|||
import { useAppContext } from "~/providers/AppProvider";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Koito" },
|
||||
{ name: "description", content: "Koito" },
|
||||
];
|
||||
return [{ title: "Koito" }, { name: "description", content: "Koito" }];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [period, setPeriod] = useState('week')
|
||||
const [period, setPeriod] = useState("week");
|
||||
|
||||
const { homeItems } = useAppContext();
|
||||
|
||||
return (
|
||||
<main className="flex flex-grow justify-center pb-4">
|
||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
|
||||
<main className="flex flex-grow justify-center pb-4 w-full bg-linear-to-b to-(--color-bg) from-(--color-bg-secondary) to-60%">
|
||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 sm:mt-20 mt-10">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
|
||||
<AllTimeStats />
|
||||
<ActivityGrid />
|
||||
<ActivityGrid configurable />
|
||||
</div>
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
|
||||
<TopArtists period={period} limit={homeItems} />
|
||||
<TopAlbums period={period} limit={homeItems} />
|
||||
<TopTracks period={period} limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
<LastPlays
|
||||
showNowPlaying={true}
|
||||
limit={Math.floor(homeItems * 2.7)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import LastPlays from "~/components/LastPlays";
|
|||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
import { timeListenedString } from "~/utils/utils";
|
||||
import InterestGraph from "~/components/InterestGraph";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||
|
|
@ -18,40 +20,62 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
|
|||
|
||||
export default function Album() {
|
||||
const album = useLoaderData() as Album;
|
||||
const [period, setPeriod] = useState('week')
|
||||
const [period, setPeriod] = useState("week");
|
||||
|
||||
console.log(album)
|
||||
console.log(album);
|
||||
|
||||
return (
|
||||
<MediaLayout type="Album"
|
||||
title={album.title}
|
||||
img={album.image}
|
||||
id={album.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={album.id}
|
||||
mergeFunc={mergeAlbums}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.artists = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.albums.length; i ++) {
|
||||
if (r.albums[i].id === id) {
|
||||
delete r.albums[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
<MediaLayout
|
||||
type="Album"
|
||||
title={album.title}
|
||||
img={album.image}
|
||||
id={album.id}
|
||||
rank={album.all_time_rank}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={album.id}
|
||||
mergeFunc={mergeAlbums}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.artists = [];
|
||||
r.tracks = [];
|
||||
for (let i = 0; i < r.albums.length; i++) {
|
||||
if (r.albums[i].id === id) {
|
||||
delete r.albums[i];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
subContent={
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
{album.listen_count !== 0 && (
|
||||
<p>
|
||||
{album.listen_count} play{album.listen_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
{album.time_listened !== 0 && (
|
||||
<p title={Math.floor(album.time_listened / 60 / 60) + " hours"}>
|
||||
{timeListenedString(album.time_listened)}
|
||||
</p>
|
||||
)}
|
||||
{album.first_listen > 0 && (
|
||||
<p title={new Date(album.first_listen * 1000).toLocaleString()}>
|
||||
Listening since{" "}
|
||||
{new Date(album.first_listen * 1000).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-20 mt-10">
|
||||
<LastPlays limit={30} albumId={album.id} />
|
||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||
<ActivityGrid autoAdjust configurable albumId={album.id} />
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-20 mt-10">
|
||||
<LastPlays limit={30} albumId={album.id} />
|
||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<ActivityGrid configurable albumId={album.id} />
|
||||
<InterestGraph albumId={album.id} />
|
||||
</div>
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import PeriodSelector from "~/components/PeriodSelector";
|
|||
import MediaLayout from "./MediaLayout";
|
||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
import { timeListenedString } from "~/utils/utils";
|
||||
import InterestGraph from "~/components/InterestGraph";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||
|
|
@ -19,48 +21,70 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
|
|||
|
||||
export default function Artist() {
|
||||
const artist = useLoaderData() as Artist;
|
||||
const [period, setPeriod] = useState('week')
|
||||
const [period, setPeriod] = useState("week");
|
||||
|
||||
// remove canonical name from alias list
|
||||
console.log(artist.aliases)
|
||||
console.log(artist.aliases);
|
||||
let index = artist.aliases.indexOf(artist.name);
|
||||
if (index !== -1) {
|
||||
artist.aliases.splice(index, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaLayout type="Artist"
|
||||
title={artist.name}
|
||||
img={artist.image}
|
||||
id={artist.id}
|
||||
musicbrainzId={artist.musicbrainz_id}
|
||||
imgItemId={artist.id}
|
||||
mergeFunc={mergeArtists}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.artists.length; i ++) {
|
||||
if (r.artists[i].id === id) {
|
||||
delete r.artists[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
<MediaLayout
|
||||
type="Artist"
|
||||
title={artist.name}
|
||||
img={artist.image}
|
||||
id={artist.id}
|
||||
rank={artist.all_time_rank}
|
||||
musicbrainzId={artist.musicbrainz_id}
|
||||
imgItemId={artist.id}
|
||||
mergeFunc={mergeArtists}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = [];
|
||||
r.tracks = [];
|
||||
for (let i = 0; i < r.artists.length; i++) {
|
||||
if (r.artists[i].id === id) {
|
||||
delete r.artists[i];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
subContent={
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
{artist.listen_count && (
|
||||
<p>
|
||||
{artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
{artist.time_listened !== 0 && (
|
||||
<p title={Math.floor(artist.time_listened / 60 / 60) + " hours"}>
|
||||
{timeListenedString(artist.time_listened)}
|
||||
</p>
|
||||
)}
|
||||
{artist.first_listen > 0 && (
|
||||
<p title={new Date(artist.first_listen * 1000).toLocaleString()}>
|
||||
Listening since{" "}
|
||||
{new Date(artist.first_listen * 1000).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-20">
|
||||
<div className="flex gap-15 mt-10 flex-wrap">
|
||||
<LastPlays limit={20} artistId={artist.id} />
|
||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||
<ActivityGrid configurable autoAdjust artistId={artist.id} />
|
||||
</div>
|
||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-20">
|
||||
<div className="flex gap-15 mt-10 flex-wrap">
|
||||
<LastPlays limit={20} artistId={artist.id} />
|
||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<ActivityGrid configurable artistId={artist.id} />
|
||||
<InterestGraph artistId={artist.id} />
|
||||
</div>
|
||||
</div>
|
||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,96 +2,208 @@ import React, { useEffect, useState } from "react";
|
|||
import { average } from "color.js";
|
||||
import { imageUrl, type SearchResponse } from "api/api";
|
||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
||||
import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import MergeModal from "~/components/modals/MergeModal";
|
||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||
import DeleteModal from "~/components/modals/DeleteModal";
|
||||
import RenameModal from "~/components/modals/RenameModal";
|
||||
import RenameModal from "~/components/modals/EditModal/EditModal";
|
||||
import EditModal from "~/components/modals/EditModal/EditModal";
|
||||
import AddListenModal from "~/components/modals/AddListenModal";
|
||||
import MbzIcon from "~/components/icons/MbzIcon";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export type MergeFunc = (from: number, to: number) => Promise<Response>
|
||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||
export type MergeFunc = (
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
) => Promise<Response>;
|
||||
export type MergeSearchCleanerFunc = (
|
||||
r: SearchResponse,
|
||||
id: number
|
||||
) => SearchResponse;
|
||||
|
||||
interface Props {
|
||||
type: "Track" | "Album" | "Artist"
|
||||
title: string
|
||||
img: string
|
||||
id: number
|
||||
musicbrainzId: string
|
||||
imgItemId: number
|
||||
mergeFunc: MergeFunc
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
||||
children: React.ReactNode
|
||||
subContent: React.ReactNode
|
||||
type: "Track" | "Album" | "Artist";
|
||||
title: string;
|
||||
img: string;
|
||||
id: number;
|
||||
rank: number;
|
||||
musicbrainzId: string;
|
||||
imgItemId: number;
|
||||
mergeFunc: MergeFunc;
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc;
|
||||
children: React.ReactNode;
|
||||
subContent: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MediaLayout(props: Props) {
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const { user } = useAppContext();
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||
});
|
||||
}, [props.img]);
|
||||
useEffect(() => {
|
||||
average(imageUrl(props.img, "small"), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||
});
|
||||
}, [props.img]);
|
||||
|
||||
const replaceImageCallback = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
const replaceImageCallback = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const title = `${props.title} - Koito`
|
||||
const title = `${props.title} - Koito`;
|
||||
|
||||
const mobileIconSize = 22
|
||||
const normalIconSize = 30
|
||||
const mobileIconSize = 22;
|
||||
const normalIconSize = 30;
|
||||
|
||||
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
|
||||
let vw = Math.max(
|
||||
document.documentElement.clientWidth || 0,
|
||||
window.innerWidth || 0
|
||||
);
|
||||
|
||||
let iconSize = vw > 768 ? normalIconSize : mobileIconSize
|
||||
let iconSize = vw > 768 ? normalIconSize : mobileIconSize;
|
||||
|
||||
return (
|
||||
<main
|
||||
className="w-full flex flex-col flex-grow"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
|
||||
transition: '1000',
|
||||
}}
|
||||
>
|
||||
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta
|
||||
name="description"
|
||||
content={title}
|
||||
/>
|
||||
<div className="w-19/20 mx-auto pt-12">
|
||||
<div className="flex gap-8 flex-wrap relative">
|
||||
<div className="flex flex-col justify-around">
|
||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<h3>{props.type}</h3>
|
||||
<h1>{props.title}</h1>
|
||||
{props.subContent}
|
||||
</div>
|
||||
{ user &&
|
||||
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
||||
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
||||
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={iconSize} /></button>
|
||||
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
|
||||
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
|
||||
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
|
||||
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{props.children}
|
||||
console.log("MBZ:", props.musicbrainzId);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="w-full flex flex-col flex-grow"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 700px)`,
|
||||
transition: "1000",
|
||||
}}
|
||||
>
|
||||
<ImageDropHandler
|
||||
itemType={props.type.toLowerCase() === "artist" ? "artist" : "album"}
|
||||
onComplete={replaceImageCallback}
|
||||
/>
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta name="description" content={title} />
|
||||
<div className="w-19/20 mx-auto pt-12">
|
||||
<div className="flex gap-8 flex-wrap md:flex-nowrap relative">
|
||||
<div className="flex flex-col justify-around">
|
||||
<img
|
||||
style={{ zIndex: 5 }}
|
||||
src={imageUrl(props.img, "large")}
|
||||
alt={props.title}
|
||||
className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<h3>{props.type}</h3>
|
||||
<div className="flex">
|
||||
<h1>
|
||||
{props.title}
|
||||
<span className="text-xl font-medium text-(--color-fg-secondary)">
|
||||
{" "}
|
||||
#{props.rank}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
{props.subContent}
|
||||
</div>
|
||||
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
||||
{props.musicbrainzId && (
|
||||
<Link
|
||||
title="View on MusicBrainz"
|
||||
target="_blank"
|
||||
to={`https://musicbrainz.org/${props.type.toLowerCase()}/${
|
||||
props.musicbrainzId
|
||||
}`}
|
||||
>
|
||||
<MbzIcon size={iconSize} hover />
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<>
|
||||
{props.type === "Track" && (
|
||||
<>
|
||||
<button
|
||||
title="Add Listen"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => setAddListenModalOpen(true)}
|
||||
>
|
||||
<Plus size={iconSize} />
|
||||
</button>
|
||||
<AddListenModal
|
||||
open={addListenModalOpen}
|
||||
setOpen={setAddListenModalOpen}
|
||||
trackid={props.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
title="Edit Item"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => setRenameModalOpen(true)}
|
||||
>
|
||||
<Edit size={iconSize} />
|
||||
</button>
|
||||
|
||||
{props.type !== "Track" && (
|
||||
<button
|
||||
title="Replace Image"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => setImageModalOpen(true)}
|
||||
>
|
||||
<ImageIcon size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title="Merge Items"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
>
|
||||
<Merge size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
title="Delete Item"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash size={iconSize} />
|
||||
</button>
|
||||
<EditModal
|
||||
open={renameModalOpen}
|
||||
setOpen={setRenameModalOpen}
|
||||
type={props.type.toLowerCase()}
|
||||
id={props.id}
|
||||
/>
|
||||
<ImageReplaceModal
|
||||
open={imageModalOpen}
|
||||
setOpen={setImageModalOpen}
|
||||
id={props.imgItemId}
|
||||
musicbrainzId={props.musicbrainzId}
|
||||
type={props.type === "Track" ? "Album" : props.type}
|
||||
/>
|
||||
<MergeModal
|
||||
currentTitle={props.title}
|
||||
mergeFunc={props.mergeFunc}
|
||||
mergeCleanerFunc={props.mergeCleanerFunc}
|
||||
type={props.type}
|
||||
currentId={props.id}
|
||||
open={mergeModalOpen}
|
||||
setOpen={setMergeModalOpen}
|
||||
/>
|
||||
<DeleteModal
|
||||
open={deleteModalOpen}
|
||||
setOpen={setDeleteModalOpen}
|
||||
title={props.title}
|
||||
id={props.id}
|
||||
type={props.type}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,55 +5,86 @@ import LastPlays from "~/components/LastPlays";
|
|||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
import { timeListenedString } from "~/utils/utils";
|
||||
import InterestGraph from "~/components/InterestGraph";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load track", { status: res.status });
|
||||
}
|
||||
const track: Track = await res.json();
|
||||
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album for track", { status: res.status })
|
||||
}
|
||||
const album: Album = await res.json()
|
||||
return {track: track, album: album};
|
||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load track", { status: res.status });
|
||||
}
|
||||
const track: Track = await res.json();
|
||||
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album for track", {
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
const album: Album = await res.json();
|
||||
return { track: track, album: album };
|
||||
}
|
||||
|
||||
export default function Track() {
|
||||
const { track, album } = useLoaderData();
|
||||
const [period, setPeriod] = useState('week')
|
||||
const { track, album } = useLoaderData();
|
||||
const [period, setPeriod] = useState("week");
|
||||
|
||||
return (
|
||||
<MediaLayout type="Track"
|
||||
title={track.title}
|
||||
img={track.image}
|
||||
id={track.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={track.album_id}
|
||||
mergeFunc={mergeTracks}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.artists = []
|
||||
for (let i = 0; i < r.tracks.length; i ++) {
|
||||
if (r.tracks[i].id === id) {
|
||||
delete r.tracks[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<div className="flex flex-col gap-4 items-start">
|
||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-20 mt-10">
|
||||
<LastPlays limit={20} trackId={track.id}/>
|
||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
)
|
||||
return (
|
||||
<MediaLayout
|
||||
type="Track"
|
||||
title={track.title}
|
||||
img={track.image}
|
||||
id={track.id}
|
||||
rank={track.all_time_rank}
|
||||
musicbrainzId={track.musicbrainz_id}
|
||||
imgItemId={track.album_id}
|
||||
mergeFunc={mergeTracks}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = [];
|
||||
r.artists = [];
|
||||
for (let i = 0; i < r.tracks.length; i++) {
|
||||
if (r.tracks[i].id === id) {
|
||||
delete r.tracks[i];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
subContent={
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<p>
|
||||
Appears on{" "}
|
||||
<Link className="hover:underline" to={`/album/${track.album_id}`}>
|
||||
{album.title}
|
||||
</Link>
|
||||
</p>
|
||||
{track.listen_count !== 0 && (
|
||||
<p>
|
||||
{track.listen_count} play{track.listen_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
{track.time_listened !== 0 && (
|
||||
<p title={Math.floor(track.time_listened / 60 / 60) + " hours"}>
|
||||
{timeListenedString(track.time_listened)}
|
||||
</p>
|
||||
)}
|
||||
{track.first_listen > 0 && (
|
||||
<p title={new Date(track.first_listen * 1000).toLocaleString()}>
|
||||
Listening since{" "}
|
||||
{new Date(track.first_listen * 1000).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-20 mt-10">
|
||||
<LastPlays limit={20} trackId={track.id} />
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<ActivityGrid configurable trackId={track.id} />
|
||||
<InterestGraph trackId={track.id} />
|
||||
</div>
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 TopArtists from "~/components/TopArtists"
|
||||
import TopTracks from "~/components/TopTracks"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import { themes, type Theme } from "~/styles/themes.css"
|
||||
|
||||
export default function ThemeHelper() {
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
}
|
||||
|
||||
const [custom, setCustom] = useState(JSON.stringify(initialTheme, null, " "))
|
||||
const { setCustomTheme } = useTheme()
|
||||
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom)
|
||||
try {
|
||||
const theme = JSON.parse(custom) as Theme
|
||||
console.log(theme)
|
||||
setCustomTheme(theme)
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
const homeItems = 3
|
||||
|
||||
|
|
@ -24,43 +56,49 @@ export default function ThemeHelper() {
|
|||
<TopTracks period="all_time" limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You're logged in as <strong>Example User</strong></p>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
||||
<div className="flex gap-10">
|
||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) w-[300px] p-5 h-full rounded-md" value={custom} />
|
||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You"re logged in as <strong>Example User</strong></p>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
|
||||
<label htmlFor="reverse-merge-order">Example checkbox</label>
|
||||
</div>
|
||||
<p className="success">successfully displayed example text</p>
|
||||
<p className="error">this is an example of error text</p>
|
||||
<p className="info">here is an informational example</p>
|
||||
<p className="warning">heed this warning, traveller</p>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
|
||||
<label htmlFor="reverse-merge-order">Example checkbox</label>
|
||||
</div>
|
||||
<p className="success">successfully displayed example text</p>
|
||||
<p className="error">this is an example of error text</p>
|
||||
<p className="info">here is an informational example</p>
|
||||
<p className="warning">heed this warning, traveller</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
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 */
|
||||
|
||||
[data-theme="varia"]{
|
||||
/* Backgrounds */
|
||||
--color-bg:rgb(25, 25, 29);
|
||||
--color-bg-secondary: #222222;
|
||||
--color-bg-tertiary: #333333;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #eeeeee;
|
||||
--color-fg-secondary: #aaaaaa;
|
||||
--color-fg-tertiary: #888888;
|
||||
|
||||
/* Accents */
|
||||
--color-primary:rgb(203, 110, 240);
|
||||
--color-primary-dim: #c28379;
|
||||
--color-accent: #f0ad0a;
|
||||
--color-accent-dim: #d08d08;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
--color-success: #4caf50;
|
||||
--color-info: #2196f3;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="wine"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #23181E;
|
||||
--color-bg-secondary: #2C1C25;
|
||||
--color-bg-tertiary: #422A37;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #FCE0B3;
|
||||
--color-fg-secondary:#C7AC81;
|
||||
--color-fg-tertiary:#A78E64;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #EA8A64;
|
||||
--color-primary-dim: #BD7255;
|
||||
--color-accent: #FAE99B;
|
||||
--color-accent-dim: #C6B464;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #fca5a5;
|
||||
--color-warning: #fde68a;
|
||||
--color-success: #bbf7d0;
|
||||
--color-info: #bae6fd;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="asuka"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #3B1212;
|
||||
--color-bg-secondary: #471B1B;
|
||||
--color-bg-tertiary: #020202;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #F1E9E6;
|
||||
--color-fg-secondary: #CCB6AE;
|
||||
--color-fg-tertiary: #9F8176;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #F1E9E6;
|
||||
--color-primary-dim: #CCB6AE;
|
||||
--color-accent: #41CE41;
|
||||
--color-accent-dim: #3BA03B;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #EB97A8;
|
||||
--color-warning: #FFD700;
|
||||
--color-success: #32CD32;
|
||||
--color-info: #1E90FF;
|
||||
|
||||
/* Borders and Shadows (derived from existing colors for consistency) */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1); /* Slightly more prominent shadow for contrast */
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="midnight"] {
|
||||
/* Backgrounds */
|
||||
--color-bg:rgb(8, 15, 24);
|
||||
--color-bg-secondary:rgb(15, 27, 46);
|
||||
--color-bg-tertiary:rgb(15, 41, 70);
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #dbdfe7;
|
||||
--color-fg-secondary: #9ea3a8;
|
||||
--color-fg-tertiary: #74787c;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #1a97eb;
|
||||
--color-primary-dim: #2680aa;
|
||||
--color-accent: #f0ad0a;
|
||||
--color-accent-dim: #d08d08;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
--color-success: #4caf50;
|
||||
--color-info: #2196f3;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
/* TODO: Adjust */
|
||||
[data-theme="catppuccin"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #1e1e2e;
|
||||
--color-bg-secondary: #181825;
|
||||
--color-bg-tertiary: #11111b;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #cdd6f4;
|
||||
--color-fg-secondary: #a6adc8;
|
||||
--color-fg-tertiary: #9399b2;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #cba6f7;
|
||||
--color-primary-dim: #739df0;
|
||||
--color-accent: #f38ba8;
|
||||
--color-accent-dim: #d67b94;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f38ba8;
|
||||
--color-warning: #f9e2af;
|
||||
--color-success: #a6e3a1;
|
||||
--color-info: #89dceb;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="pearl"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #FFFFFF;
|
||||
--color-bg-secondary: #EEEEEE;
|
||||
--color-bg-tertiary: #E0E0E0;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #333333;
|
||||
--color-fg-secondary: #555555;
|
||||
--color-fg-tertiary: #777777;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #007BFF;
|
||||
--color-primary-dim: #0056B3;
|
||||
--color-accent: #28A745;
|
||||
--color-accent-dim: #1E7E34;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #DC3545;
|
||||
--color-warning: #CE9B00;
|
||||
--color-success: #099B2B;
|
||||
--color-info: #02B3CE;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="urim"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #101713;
|
||||
--color-bg-secondary: #1B2921;
|
||||
--color-bg-tertiary: #273B30;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #D2E79E;
|
||||
--color-fg-secondary: #B4DA55;
|
||||
--color-fg-tertiary: #7E9F2A;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #ead500;
|
||||
--color-primary-dim: #C1B210;
|
||||
--color-accent: #28A745;
|
||||
--color-accent-dim: #1E7E34;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #EE5237;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #28A745;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="yuu"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #161312;
|
||||
--color-bg-secondary: #272120;
|
||||
--color-bg-tertiary: #382F2E;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #faf5f4;
|
||||
--color-fg-secondary: #CCC7C6;
|
||||
--color-fg-tertiary: #B0A3A1;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #ff826d;
|
||||
--color-primary-dim: #CE6654;
|
||||
--color-accent: #464DAE;
|
||||
--color-accent-dim: #393D74;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #FF6247;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #3ECE5F;
|
||||
--color-info: #41C4D8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="match"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #071014;
|
||||
--color-bg-secondary: #0A181E;
|
||||
--color-bg-tertiary: #112A34;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #ebeaeb;
|
||||
--color-fg-secondary: #BDBDBD;
|
||||
--color-fg-tertiary: #A2A2A2;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #fda827;
|
||||
--color-primary-dim: #C78420;
|
||||
--color-accent: #277CFD;
|
||||
--color-accent-dim: #1F60C1;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #F14426;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #28A745;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="lemon"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #1a171a;
|
||||
--color-bg-secondary: #2E272E;
|
||||
--color-bg-tertiary: #443844;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #E6E2DC;
|
||||
--color-fg-secondary: #B2ACA1;
|
||||
--color-fg-tertiary: #968F82;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #f5c737;
|
||||
--color-primary-dim: #C29D2F;
|
||||
--color-accent: #277CFD;
|
||||
--color-accent-dim: #1F60C1;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #F14426;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #28A745;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="autumn"] {
|
||||
/* Backgrounds */
|
||||
--color-bg:rgb(44, 25, 18);
|
||||
--color-bg-secondary:rgb(70, 40, 18);
|
||||
--color-bg-tertiary: #4b2f1c;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #fef9f3;
|
||||
--color-fg-secondary: #dbc6b0;
|
||||
--color-fg-tertiary: #a3917a;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #d97706;
|
||||
--color-primary-dim: #b45309;
|
||||
--color-accent: #8c4c28;
|
||||
--color-accent-dim: #6b3b1f;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #d1433f;
|
||||
--color-warning: #e38b29;
|
||||
--color-success: #6b8e23;
|
||||
--color-info: #c084fc;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="black"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #000000;
|
||||
--color-bg-secondary: #1a1a1a;
|
||||
--color-bg-tertiary: #2a2a2a;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #dddddd;
|
||||
--color-fg-secondary: #aaaaaa;
|
||||
--color-fg-tertiary: #888888;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #08c08c;
|
||||
--color-primary-dim: #08c08c;
|
||||
--color-accent: #f0ad0a;
|
||||
--color-accent-dim: #d08d08;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
--color-success: #4caf50;
|
||||
--color-info: #2196f3;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: #0af0af;
|
||||
--color-link-hover: #08c08c;
|
||||
}
|
||||
|
||||
|
||||
/* Theme Helper Classes */
|
||||
|
||||
/* Foreground Text */
|
||||
|
|
|
|||
10
client/app/tz.ts
Normal file
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 => {
|
||||
switch (timeframe) {
|
||||
case Timeframe.Day:
|
||||
return "1 day"
|
||||
case Timeframe.Week:
|
||||
return "1 week"
|
||||
case Timeframe.Month:
|
||||
return "1 month"
|
||||
case Timeframe.Year:
|
||||
return "1 year"
|
||||
case Timeframe.AllTime:
|
||||
return "99 years"
|
||||
}
|
||||
}
|
||||
|
||||
function timeSince(date: Date) {
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
const intervals = [
|
||||
{ label: 'year', seconds: 31536000 },
|
||||
{ label: 'month', seconds: 2592000 },
|
||||
{ label: 'week', seconds: 604800 },
|
||||
{ label: 'day', seconds: 86400 },
|
||||
{ label: 'hour', seconds: 3600 },
|
||||
{ label: 'minute', seconds: 60 },
|
||||
{ label: 'second', seconds: 1 },
|
||||
];
|
||||
|
||||
for (const interval of intervals) {
|
||||
const count = Math.floor(seconds / interval.seconds);
|
||||
if (count >= 1) {
|
||||
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
export { timeSince }
|
||||
|
||||
type hsl = {
|
||||
h: number,
|
||||
s: number,
|
||||
l: number,
|
||||
}
|
||||
|
||||
const hexToHSL = (hex: string): hsl => {
|
||||
let r = 0, g = 0, b = 0;
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
if (hex.length === 3) {
|
||||
r = parseInt(hex[0] + hex[0], 16);
|
||||
g = parseInt(hex[1] + hex[1], 16);
|
||||
b = parseInt(hex[2] + hex[2], 16);
|
||||
} else if (hex.length === 6) {
|
||||
r = parseInt(hex.substring(0, 2), 16);
|
||||
g = parseInt(hex.substring(2, 4), 16);
|
||||
b = parseInt(hex.substring(4, 6), 16);
|
||||
}
|
||||
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
|
||||
case g: h = ((b - r) / d + 2); break;
|
||||
case b: h = ((r - g) / d + 4); break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100)
|
||||
};
|
||||
switch (timeframe) {
|
||||
case Timeframe.Day:
|
||||
return "1 day";
|
||||
case Timeframe.Week:
|
||||
return "1 week";
|
||||
case Timeframe.Month:
|
||||
return "1 month";
|
||||
case Timeframe.Year:
|
||||
return "1 year";
|
||||
case Timeframe.AllTime:
|
||||
return "99 years";
|
||||
}
|
||||
};
|
||||
|
||||
export {hexToHSL}
|
||||
export type {hsl}
|
||||
const getRewindYear = (): number => {
|
||||
return new Date().getFullYear() - 1;
|
||||
};
|
||||
|
||||
const getRewindParams = (): { month: number; year: number } => {
|
||||
const today = new Date();
|
||||
if (today.getMonth() == 0) {
|
||||
return { month: 0, year: today.getFullYear() - 1 };
|
||||
} else {
|
||||
return { month: today.getMonth(), year: today.getFullYear() };
|
||||
}
|
||||
};
|
||||
|
||||
function timeSince(date: Date) {
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
const intervals = [
|
||||
{ label: "year", seconds: 31536000 },
|
||||
{ label: "month", seconds: 2592000 },
|
||||
{ label: "week", seconds: 604800 },
|
||||
{ label: "day", seconds: 86400 },
|
||||
{ label: "hour", seconds: 3600 },
|
||||
{ label: "minute", seconds: 60 },
|
||||
{ label: "second", seconds: 1 },
|
||||
];
|
||||
|
||||
for (const interval of intervals) {
|
||||
const count = Math.floor(seconds / interval.seconds);
|
||||
if (count >= 1) {
|
||||
return `${count} ${interval.label}${count !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
return "just now";
|
||||
}
|
||||
|
||||
export { timeSince };
|
||||
|
||||
type hsl = {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
};
|
||||
|
||||
const hexToHSL = (hex: string): hsl => {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
hex = hex.replace("#", "");
|
||||
|
||||
if (hex.length === 3) {
|
||||
r = parseInt(hex[0] + hex[0], 16);
|
||||
g = parseInt(hex[1] + hex[1], 16);
|
||||
b = parseInt(hex[2] + hex[2], 16);
|
||||
} else if (hex.length === 6) {
|
||||
r = parseInt(hex.substring(0, 2), 16);
|
||||
g = parseInt(hex.substring(2, 4), 16);
|
||||
b = parseInt(hex.substring(4, 6), 16);
|
||||
}
|
||||
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
let h = 0,
|
||||
s = 0,
|
||||
l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
};
|
||||
|
||||
const timeListenedString = (seconds: number) => {
|
||||
if (!seconds) return "";
|
||||
|
||||
let minutes = Math.floor(seconds / 60);
|
||||
return `${minutes} minutes listened`;
|
||||
};
|
||||
|
||||
export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
|
||||
export type { hsl };
|
||||
|
|
|
|||
|
|
@ -13,13 +13,17 @@
|
|||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@recharts/devtools": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@vanilla-extract/css": "^1.17.4",
|
||||
"color.js": "^1.2.0",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.513.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.5.3"
|
||||
"react-is": "^19.2.3",
|
||||
"react-router": "^7.5.3",
|
||||
"recharts": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
|
|
@ -27,6 +31,7 @@
|
|||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vanilla-extract/vite-plugin": "^5.0.6",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "Koito",
|
||||
"short_name": "Koito",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { reactRouter } from "@react-router/dev/vite";
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
|
||||
|
||||
const isDocker = process.env.BUILD_TARGET === 'docker';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), vanillaExtractPlugin()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/apis': {
|
||||
|
|
|
|||
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"
|
||||
integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==
|
||||
|
||||
"@babel/core@^7.21.8", "@babel/core@^7.23.7":
|
||||
"@babel/core@^7.21.8", "@babel/core@^7.23.7", "@babel/core@^7.23.9":
|
||||
version "7.27.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
|
||||
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-syntax-typescript@^7.27.1":
|
||||
"@babel/plugin-syntax-typescript@^7.23.3", "@babel/plugin-syntax-typescript@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
|
||||
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
|
||||
|
|
@ -222,6 +222,11 @@
|
|||
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
|
||||
"@babel/plugin-transform-typescript" "^7.27.1"
|
||||
|
||||
"@babel/runtime@^7.12.5":
|
||||
version "7.27.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
||||
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
||||
|
||||
"@babel/template@^7.27.2":
|
||||
version "7.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
||||
|
|
@ -274,6 +279,11 @@
|
|||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emotion/hash@^0.9.0":
|
||||
version "0.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b"
|
||||
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
|
||||
|
||||
"@esbuild/aix-ppc64@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
|
||||
|
|
@ -679,6 +689,23 @@
|
|||
morgan "^1.10.0"
|
||||
source-map-support "^0.5.21"
|
||||
|
||||
"@recharts/devtools@^0.0.7":
|
||||
version "0.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@recharts/devtools/-/devtools-0.0.7.tgz#a909d102efd76fc45bc2b7a150e67a02da04b4c1"
|
||||
integrity sha512-ud66rUf3FYf1yQLGSCowI50EQyC/rcZblvDgNvfUIVaEXyQtr5K2DFgwegziqbVclsVBQLTxyntVViJN5H4oWQ==
|
||||
|
||||
"@reduxjs/toolkit@1.x.x || 2.x.x":
|
||||
version "2.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
|
||||
integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==
|
||||
dependencies:
|
||||
"@standard-schema/spec" "^1.0.0"
|
||||
"@standard-schema/utils" "^0.3.0"
|
||||
immer "^11.0.0"
|
||||
redux "^5.0.1"
|
||||
redux-thunk "^3.1.0"
|
||||
reselect "^5.1.0"
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@4.42.0":
|
||||
version "4.42.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
|
||||
|
|
@ -779,6 +806,16 @@
|
|||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
|
||||
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
|
||||
|
||||
"@standard-schema/spec@^1.0.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
|
||||
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
|
||||
|
||||
"@standard-schema/utils@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@tailwindcss/node@4.1.8":
|
||||
version "4.1.8"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
|
||||
|
|
@ -908,11 +945,69 @@
|
|||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@types/d3-array@^3.0.3":
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c"
|
||||
integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==
|
||||
|
||||
"@types/d3-color@*":
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
|
||||
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
|
||||
|
||||
"@types/d3-ease@^3.0.0":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
|
||||
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
|
||||
|
||||
"@types/d3-interpolate@^3.0.1":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||
dependencies:
|
||||
"@types/d3-color" "*"
|
||||
|
||||
"@types/d3-path@*":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a"
|
||||
integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==
|
||||
|
||||
"@types/d3-scale@^4.0.2":
|
||||
version "4.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb"
|
||||
integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==
|
||||
dependencies:
|
||||
"@types/d3-time" "*"
|
||||
|
||||
"@types/d3-shape@^3.1.0":
|
||||
version "3.1.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3"
|
||||
integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==
|
||||
dependencies:
|
||||
"@types/d3-path" "*"
|
||||
|
||||
"@types/d3-time@*", "@types/d3-time@^3.0.0":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f"
|
||||
integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==
|
||||
|
||||
"@types/d3-timer@^3.0.0":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
|
||||
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
|
||||
|
||||
"@types/estree@1.0.7":
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
||||
|
||||
"@types/node@*":
|
||||
version "24.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab"
|
||||
integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==
|
||||
dependencies:
|
||||
undici-types "~7.8.0"
|
||||
|
||||
"@types/node@^20":
|
||||
version "20.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
|
||||
|
|
@ -932,6 +1027,75 @@
|
|||
dependencies:
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/use-sync-external-store@^0.0.6":
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
|
||||
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
|
||||
|
||||
"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
|
||||
integrity sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==
|
||||
dependencies:
|
||||
"@babel/core" "^7.23.9"
|
||||
|
||||
"@vanilla-extract/compiler@^0.2.3":
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/compiler/-/compiler-0.2.3.tgz#97c4bb989aea92ee8329f1ad0a3ec01bf3aa8479"
|
||||
integrity sha512-SFEDLbvd5rhpjhrLp9BtvvVNHNxWupiUht/yrsHQ7xfkpEn4xg45gbfma7aX9fsOpi82ebqFmowHd/g6jHDQnA==
|
||||
dependencies:
|
||||
"@vanilla-extract/css" "^1.17.4"
|
||||
"@vanilla-extract/integration" "^8.0.4"
|
||||
vite "^5.0.0 || ^6.0.0"
|
||||
vite-node "^3.2.2"
|
||||
|
||||
"@vanilla-extract/css@^1.17.4":
|
||||
version "1.17.4"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/css/-/css-1.17.4.tgz#c73353992b8243e8ab140582bf6d673ebc709b0a"
|
||||
integrity sha512-m3g9nQDWPtL+sTFdtCGRMI1Vrp86Ay4PBYq1Bo7Bnchj5ElNtAJpOqD+zg+apthVA4fB7oVpMWNjwpa6ElDWFQ==
|
||||
dependencies:
|
||||
"@emotion/hash" "^0.9.0"
|
||||
"@vanilla-extract/private" "^1.0.9"
|
||||
css-what "^6.1.0"
|
||||
cssesc "^3.0.0"
|
||||
csstype "^3.0.7"
|
||||
dedent "^1.5.3"
|
||||
deep-object-diff "^1.1.9"
|
||||
deepmerge "^4.2.2"
|
||||
lru-cache "^10.4.3"
|
||||
media-query-parser "^2.0.2"
|
||||
modern-ahocorasick "^1.0.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
"@vanilla-extract/integration@^8.0.4":
|
||||
version "8.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/integration/-/integration-8.0.4.tgz#eb176376b3b03c44713bf596cc41d6d97ba9f5d3"
|
||||
integrity sha512-cmOb7tR+g3ulKvFtSbmdw3YUyIS1d7MQqN+FcbwNhdieyno5xzUyfDCMjeWJhmCSMvZ6WlinkrOkgs6SHB+FRg==
|
||||
dependencies:
|
||||
"@babel/core" "^7.23.9"
|
||||
"@babel/plugin-syntax-typescript" "^7.23.3"
|
||||
"@vanilla-extract/babel-plugin-debug-ids" "^1.2.2"
|
||||
"@vanilla-extract/css" "^1.17.4"
|
||||
dedent "^1.5.3"
|
||||
esbuild "npm:esbuild@>=0.17.6 <0.26.0"
|
||||
eval "0.1.8"
|
||||
find-up "^5.0.0"
|
||||
javascript-stringify "^2.0.1"
|
||||
mlly "^1.4.2"
|
||||
|
||||
"@vanilla-extract/private@^1.0.9":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.9.tgz#bb8aaf72d2e04439792f2e389d9b705cfe691bc0"
|
||||
integrity sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==
|
||||
|
||||
"@vanilla-extract/vite-plugin@^5.0.6":
|
||||
version "5.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/vite-plugin/-/vite-plugin-5.0.6.tgz#00084be8e872519dde5152d92241ad8ad1e85396"
|
||||
integrity sha512-9dSPIuxR2NULvVk9bqCoTaZz3CtfBrvo5hImWaiWCblWZXzCcD7jIg7Nbcpdz9MvytO+mNta82/qCWj1G9mEMQ==
|
||||
dependencies:
|
||||
"@vanilla-extract/compiler" "^0.2.3"
|
||||
"@vanilla-extract/integration" "^8.0.4"
|
||||
|
||||
accepts@~1.3.8:
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
||||
|
|
@ -940,6 +1104,11 @@ accepts@~1.3.8:
|
|||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
acorn@^8.14.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
|
|
@ -1077,6 +1246,11 @@ chownr@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
|
||||
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
|
||||
|
||||
clsx@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||
|
|
@ -1114,6 +1288,11 @@ compression@^1.7.4:
|
|||
safe-buffer "5.2.1"
|
||||
vary "~1.1.2"
|
||||
|
||||
confbox@^0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
||||
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
|
||||
|
||||
content-disposition@0.5.4:
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||
|
|
@ -1155,11 +1334,92 @@ cross-spawn@^7.0.6:
|
|||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
csstype@^3.0.2:
|
||||
css-what@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
|
||||
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
|
||||
|
||||
cssesc@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
csstype@^3.0.2, csstype@^3.0.7:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
|
||||
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
|
||||
dependencies:
|
||||
internmap "1 - 2"
|
||||
|
||||
"d3-color@1 - 3":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
|
||||
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
|
||||
|
||||
d3-ease@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
|
||||
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||
|
||||
"d3-format@1 - 3":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
|
||||
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
|
||||
|
||||
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
|
||||
d3-path@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
|
||||
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
|
||||
|
||||
d3-scale@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
|
||||
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
|
||||
dependencies:
|
||||
d3-array "2.10.0 - 3"
|
||||
d3-format "1 - 3"
|
||||
d3-interpolate "1.2.0 - 3"
|
||||
d3-time "2.1.1 - 3"
|
||||
d3-time-format "2 - 4"
|
||||
|
||||
d3-shape@^3.1.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
|
||||
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
|
||||
dependencies:
|
||||
d3-path "^3.1.0"
|
||||
|
||||
"d3-time-format@2 - 4":
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
|
||||
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
|
||||
dependencies:
|
||||
d3-time "1 - 3"
|
||||
|
||||
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
|
||||
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
|
||||
dependencies:
|
||||
d3-array "2 - 3"
|
||||
|
||||
d3-timer@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
||||
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
|
|
@ -1174,11 +1434,26 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.1:
|
|||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
decimal.js-light@^2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
|
||||
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
|
||||
|
||||
dedent@^1.5.3:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
|
||||
integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==
|
||||
|
||||
deep-object-diff@^1.1.9:
|
||||
version "1.1.9"
|
||||
resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595"
|
||||
integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==
|
||||
|
||||
deepmerge@^4.2.2:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
||||
|
||||
depd@2.0.0, depd@~2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||
|
|
@ -1273,7 +1548,12 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
|||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
|
||||
esbuild@^0.25.0:
|
||||
es-toolkit@^1.39.3:
|
||||
version "1.43.0"
|
||||
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.43.0.tgz#2c278d55ffeb30421e6e73a009738ed37b10ef61"
|
||||
integrity sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==
|
||||
|
||||
esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
||||
integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==
|
||||
|
|
@ -1319,6 +1599,19 @@ etag@~1.8.1:
|
|||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
||||
|
||||
eval@0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.8.tgz#2b903473b8cc1d1989b83a1e7923f883eb357f85"
|
||||
integrity sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
require-like ">= 0.1.1"
|
||||
|
||||
eventemitter3@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
|
||||
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
|
||||
|
||||
exit-hook@2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
|
||||
|
|
@ -1379,6 +1672,14 @@ finalhandler@1.3.1:
|
|||
statuses "2.0.1"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
find-up@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
|
||||
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
|
||||
dependencies:
|
||||
locate-path "^6.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
||||
|
|
@ -1519,11 +1820,26 @@ iconv-lite@0.4.24:
|
|||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
immer@^10.1.1:
|
||||
version "10.2.0"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1"
|
||||
integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==
|
||||
|
||||
immer@^11.0.0:
|
||||
version "11.1.3"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
|
||||
integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
|
||||
|
||||
inherits@2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
"internmap@1 - 2":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
|
||||
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
version "1.9.1"
|
||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||
|
|
@ -1560,6 +1876,11 @@ jackspeak@^3.1.2:
|
|||
optionalDependencies:
|
||||
"@pkgjs/parseargs" "^0.11.0"
|
||||
|
||||
javascript-stringify@^2.0.1:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
|
||||
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
|
||||
|
||||
jiti@^2.4.2:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
||||
|
|
@ -1667,12 +1988,19 @@ lightningcss@1.30.1:
|
|||
lightningcss-win32-arm64-msvc "1.30.1"
|
||||
lightningcss-win32-x64-msvc "1.30.1"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
lru-cache@^10.2.0:
|
||||
lru-cache@^10.2.0, lru-cache@^10.4.3:
|
||||
version "10.4.3"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||
|
|
@ -1706,6 +2034,13 @@ math-intrinsics@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
media-query-parser@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29"
|
||||
integrity sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
|
||||
media-typer@0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
|
|
@ -1767,6 +2102,21 @@ mkdirp@^3.0.1:
|
|||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
|
||||
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
|
||||
|
||||
mlly@^1.4.2, mlly@^1.7.4:
|
||||
version "1.7.4"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
|
||||
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
|
||||
dependencies:
|
||||
acorn "^8.14.0"
|
||||
pathe "^2.0.1"
|
||||
pkg-types "^1.3.0"
|
||||
ufo "^1.5.4"
|
||||
|
||||
modern-ahocorasick@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz#9b1fa15d4f654be20a2ad7ecc44ec9d7645bb420"
|
||||
integrity sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==
|
||||
|
||||
morgan@^1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
|
||||
|
|
@ -1874,6 +2224,20 @@ on-headers@~1.0.2:
|
|||
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
|
||||
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
|
||||
|
||||
p-limit@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||
dependencies:
|
||||
yocto-queue "^0.1.0"
|
||||
|
||||
p-locate@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
|
||||
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
|
||||
dependencies:
|
||||
p-limit "^3.0.2"
|
||||
|
||||
package-json-from-dist@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
||||
|
|
@ -1884,6 +2248,11 @@ parseurl@~1.3.3:
|
|||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
|
||||
|
||||
path-key@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
|
|
@ -1907,12 +2276,12 @@ pathe@^1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
|
||||
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||
|
||||
pathe@^2.0.3:
|
||||
pathe@^2.0.1, pathe@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||
|
||||
picocolors@^1.1.1:
|
||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
|
@ -1922,6 +2291,15 @@ picomatch@^4.0.2:
|
|||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
|
||||
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
|
||||
|
||||
pkg-types@^1.3.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
|
||||
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
|
||||
dependencies:
|
||||
confbox "^0.1.8"
|
||||
mlly "^1.7.4"
|
||||
pathe "^2.0.1"
|
||||
|
||||
postcss@^8.5.3:
|
||||
version "8.5.4"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
|
||||
|
|
@ -1991,6 +2369,19 @@ react-dom@^19.1.0:
|
|||
dependencies:
|
||||
scheduler "^0.26.0"
|
||||
|
||||
react-is@^19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
|
||||
integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==
|
||||
|
||||
"react-redux@8.x.x || 9.x.x":
|
||||
version "9.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"
|
||||
integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==
|
||||
dependencies:
|
||||
"@types/use-sync-external-store" "^0.0.6"
|
||||
use-sync-external-store "^1.4.0"
|
||||
|
||||
react-refresh@^0.14.0:
|
||||
version "0.14.2"
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
||||
|
|
@ -2014,6 +2405,43 @@ readdirp@^4.0.1:
|
|||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||
|
||||
recharts@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.6.0.tgz#403f0606581153601857e46733277d1411633df3"
|
||||
integrity sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==
|
||||
dependencies:
|
||||
"@reduxjs/toolkit" "1.x.x || 2.x.x"
|
||||
clsx "^2.1.1"
|
||||
decimal.js-light "^2.5.1"
|
||||
es-toolkit "^1.39.3"
|
||||
eventemitter3 "^5.0.1"
|
||||
immer "^10.1.1"
|
||||
react-redux "8.x.x || 9.x.x"
|
||||
reselect "5.1.1"
|
||||
tiny-invariant "^1.3.3"
|
||||
use-sync-external-store "^1.2.2"
|
||||
victory-vendor "^37.0.2"
|
||||
|
||||
redux-thunk@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
|
||||
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
|
||||
|
||||
redux@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
|
||||
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
|
||||
|
||||
"require-like@>= 0.1.1":
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
|
||||
integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
|
||||
|
||||
reselect@5.1.1, reselect@^5.1.0:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
|
||||
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
|
||||
|
||||
retry@^0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
||||
|
|
@ -2298,6 +2726,11 @@ tar@^7.4.3:
|
|||
mkdirp "^3.0.1"
|
||||
yallist "^5.0.0"
|
||||
|
||||
tiny-invariant@^1.3.3:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
|
||||
|
||||
tinyglobby@^0.2.13:
|
||||
version "0.2.14"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
|
||||
|
|
@ -2334,11 +2767,21 @@ typescript@^5.8.3:
|
|||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
|
||||
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
|
||||
|
||||
ufo@^1.5.4:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
|
||||
integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
|
||||
undici-types@~7.8.0:
|
||||
version "7.8.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
|
||||
integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
|
||||
|
||||
undici@^6.19.2:
|
||||
version "6.21.3"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
|
||||
|
|
@ -2362,6 +2805,11 @@ update-browserslist-db@^1.1.3:
|
|||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||
|
||||
utils-merge@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
|
|
@ -2390,7 +2838,27 @@ vary@~1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||
|
||||
vite-node@^3.1.4:
|
||||
victory-vendor@^37.0.2:
|
||||
version "37.3.6"
|
||||
resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da"
|
||||
integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==
|
||||
dependencies:
|
||||
"@types/d3-array" "^3.0.3"
|
||||
"@types/d3-ease" "^3.0.0"
|
||||
"@types/d3-interpolate" "^3.0.1"
|
||||
"@types/d3-scale" "^4.0.2"
|
||||
"@types/d3-shape" "^3.1.0"
|
||||
"@types/d3-time" "^3.0.0"
|
||||
"@types/d3-timer" "^3.0.0"
|
||||
d3-array "^3.1.6"
|
||||
d3-ease "^3.0.1"
|
||||
d3-interpolate "^3.0.1"
|
||||
d3-scale "^4.0.2"
|
||||
d3-shape "^3.1.0"
|
||||
d3-time "^3.0.0"
|
||||
d3-timer "^3.0.1"
|
||||
|
||||
vite-node@^3.1.4, vite-node@^3.2.2:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
|
||||
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
|
||||
|
|
@ -2410,7 +2878,7 @@ vite-tsconfig-paths@^5.1.4:
|
|||
globrex "^0.1.2"
|
||||
tsconfck "^3.0.3"
|
||||
|
||||
"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
|
||||
"vite@^5.0.0 || ^6.0.0", "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
|
||||
version "6.3.5"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
|
||||
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
|
||||
|
|
@ -2465,3 +2933,8 @@ yallist@^5.0.0:
|
|||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
|
||||
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"log"
|
||||
|
||||
"github.com/gabehf/koito/engine"
|
||||
)
|
||||
|
|
@ -11,7 +13,7 @@ var Version = "dev"
|
|||
|
||||
func main() {
|
||||
if err := engine.Run(
|
||||
os.Getenv,
|
||||
readEnvOrFile,
|
||||
os.Stdout,
|
||||
Version,
|
||||
); err != nil {
|
||||
|
|
@ -19,3 +21,23 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func readEnvOrFile(envName string) string {
|
||||
envContent := os.Getenv(envName)
|
||||
|
||||
if envContent == "" {
|
||||
filename := os.Getenv(envName + "_FILE")
|
||||
|
||||
if filename != "" {
|
||||
b, err := os.ReadFile(filename)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load file for %s_FILE (%s): %s", envName, filename, err)
|
||||
}
|
||||
|
||||
envContent = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
return envContent
|
||||
}
|
||||
|
|
|
|||
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
|
||||
SELECT
|
||||
a.*
|
||||
a.*,
|
||||
at.is_primary as is_primary
|
||||
FROM artists_with_name a
|
||||
LEFT JOIN artist_tracks at ON a.id = at.artist_id
|
||||
WHERE at.track_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary;
|
||||
|
||||
-- name: GetArtistByImage :one
|
||||
SELECT * FROM artists WHERE image = $1 LIMIT 1;
|
||||
|
||||
-- name: GetReleaseArtists :many
|
||||
SELECT
|
||||
a.*
|
||||
a.*,
|
||||
ar.is_primary as is_primary
|
||||
FROM artists_with_name a
|
||||
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary;
|
||||
|
||||
-- name: GetArtistByName :one
|
||||
WITH artist_with_aliases AS (
|
||||
|
|
@ -54,28 +56,77 @@ LEFT JOIN artist_aliases aa ON a.id = aa.artist_id
|
|||
WHERE a.musicbrainz_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||
|
||||
-- name: GetArtistsWithoutImages :many
|
||||
SELECT
|
||||
*
|
||||
FROM artists_with_name
|
||||
WHERE image IS NULL
|
||||
AND id > $2
|
||||
ORDER BY id ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: GetTopArtistsPaginated :many
|
||||
SELECT
|
||||
x.id,
|
||||
x.name,
|
||||
x.musicbrainz_id,
|
||||
x.image,
|
||||
x.listen_count,
|
||||
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||
FROM (
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
a.musicbrainz_id,
|
||||
a.image,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON at.track_id = t.id
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
ORDER BY listen_count DESC
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON at.track_id = t.id
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY a.id, a.name, a.musicbrainz_id, a.image
|
||||
) x
|
||||
ORDER BY x.listen_count DESC, x.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetArtistAllTimeRank :one
|
||||
SELECT
|
||||
artist_id,
|
||||
rank
|
||||
FROM (
|
||||
SELECT
|
||||
x.artist_id,
|
||||
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||
FROM (
|
||||
SELECT
|
||||
at.artist_id,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
GROUP BY at.artist_id
|
||||
) x
|
||||
)
|
||||
WHERE artist_id = $1;
|
||||
|
||||
-- name: CountTopArtists :one
|
||||
SELECT COUNT(DISTINCT at.artist_id) AS total_count
|
||||
FROM listens l
|
||||
JOIN artist_tracks at ON l.track_id = at.track_id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2;
|
||||
|
||||
-- name: CountNewArtists :one
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM (
|
||||
SELECT at.artist_id
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
GROUP BY at.artist_id
|
||||
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
|
||||
) first_appearances;
|
||||
|
||||
-- name: UpdateArtistMbzID :exec
|
||||
UPDATE artists SET musicbrainz_id = $2
|
||||
WHERE id = $1;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ DO $$
|
|||
BEGIN
|
||||
DELETE FROM tracks WHERE id NOT IN (SELECT l.track_id FROM listens l);
|
||||
DELETE FROM releases WHERE id NOT IN (SELECT t.release_id FROM tracks t);
|
||||
-- DELETE FROM releases WHERE release_group_id NOT IN (SELECT t.release_group_id FROM tracks t);
|
||||
-- DELETE FROM releases WHERE release_group_id NOT IN (SELECT rg.id FROM release_groups rg);
|
||||
DELETE FROM artists WHERE id NOT IN (SELECT at.artist_id FROM artist_tracks at);
|
||||
DELETE FROM artist_releases ar
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM artist_tracks at
|
||||
JOIN tracks t ON at.track_id = t.id
|
||||
WHERE at.artist_id = ar.artist_id
|
||||
AND t.release_id = ar.release_id
|
||||
);
|
||||
END $$;
|
||||
|
|
|
|||
139
db/queries/interest.sql
Normal file
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.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -25,12 +20,7 @@ SELECT
|
|||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
|
|
@ -39,17 +29,22 @@ WHERE at.artist_id = $5
|
|||
ORDER BY l.listened_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetFirstListenFromArtist :one
|
||||
SELECT
|
||||
l.*
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
WHERE at.artist_id = $1
|
||||
ORDER BY l.listened_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetLastListensFromReleasePaginated :many
|
||||
SELECT
|
||||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -57,17 +52,21 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
|||
ORDER BY l.listened_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetFirstListenFromRelease :one
|
||||
SELECT
|
||||
l.*
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE t.release_id = $1
|
||||
ORDER BY l.listened_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetLastListensFromTrackPaginated :many
|
||||
SELECT
|
||||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -75,6 +74,22 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
|||
ORDER BY l.listened_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetFirstListenFromTrack :one
|
||||
SELECT
|
||||
l.*
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE t.id = $1
|
||||
ORDER BY l.listened_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetFirstListen :one
|
||||
SELECT
|
||||
*
|
||||
FROM listens
|
||||
ORDER BY listened_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CountListens :one
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM listens l
|
||||
|
|
@ -129,90 +144,51 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
|||
AND t.id = $3;
|
||||
|
||||
-- name: ListenActivity :many
|
||||
WITH buckets AS (
|
||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
||||
),
|
||||
bucketed_listens AS (
|
||||
SELECT
|
||||
b.bucket_start,
|
||||
COUNT(l.listened_at) AS listen_count
|
||||
FROM buckets b
|
||||
LEFT JOIN listens l
|
||||
ON l.listened_at >= b.bucket_start
|
||||
AND l.listened_at < b.bucket_start + $3::interval
|
||||
GROUP BY b.bucket_start
|
||||
ORDER BY b.bucket_start
|
||||
)
|
||||
SELECT * FROM bucketed_listens;
|
||||
SELECT
|
||||
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens
|
||||
WHERE listened_at >= $2
|
||||
AND listened_at < $3
|
||||
GROUP BY day
|
||||
ORDER BY day;
|
||||
|
||||
-- name: ListenActivityForArtist :many
|
||||
WITH buckets AS (
|
||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
||||
),
|
||||
filtered_listens AS (
|
||||
SELECT l.*
|
||||
FROM listens l
|
||||
JOIN artist_tracks t ON l.track_id = t.track_id
|
||||
WHERE t.artist_id = $4
|
||||
),
|
||||
bucketed_listens AS (
|
||||
SELECT
|
||||
b.bucket_start,
|
||||
COUNT(l.listened_at) AS listen_count
|
||||
FROM buckets b
|
||||
LEFT JOIN filtered_listens l
|
||||
ON l.listened_at >= b.bucket_start
|
||||
AND l.listened_at < b.bucket_start + $3::interval
|
||||
GROUP BY b.bucket_start
|
||||
ORDER BY b.bucket_start
|
||||
)
|
||||
SELECT * FROM bucketed_listens;
|
||||
SELECT
|
||||
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
WHERE l.listened_at >= $2
|
||||
AND l.listened_at < $3
|
||||
AND at.artist_id = $4
|
||||
GROUP BY day
|
||||
ORDER BY day;
|
||||
|
||||
-- name: ListenActivityForRelease :many
|
||||
WITH buckets AS (
|
||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
||||
),
|
||||
filtered_listens AS (
|
||||
SELECT l.*
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE t.release_id = $4
|
||||
),
|
||||
bucketed_listens AS (
|
||||
SELECT
|
||||
b.bucket_start,
|
||||
COUNT(l.listened_at) AS listen_count
|
||||
FROM buckets b
|
||||
LEFT JOIN filtered_listens l
|
||||
ON l.listened_at >= b.bucket_start
|
||||
AND l.listened_at < b.bucket_start + $3::interval
|
||||
GROUP BY b.bucket_start
|
||||
ORDER BY b.bucket_start
|
||||
)
|
||||
SELECT * FROM bucketed_listens;
|
||||
SELECT
|
||||
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE l.listened_at >= $2
|
||||
AND l.listened_at < $3
|
||||
AND t.release_id = $4
|
||||
GROUP BY day
|
||||
ORDER BY day;
|
||||
|
||||
-- name: ListenActivityForTrack :many
|
||||
WITH buckets AS (
|
||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
||||
),
|
||||
filtered_listens AS (
|
||||
SELECT l.*
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE t.id = $4
|
||||
),
|
||||
bucketed_listens AS (
|
||||
SELECT
|
||||
b.bucket_start,
|
||||
COUNT(l.listened_at) AS listen_count
|
||||
FROM buckets b
|
||||
LEFT JOIN filtered_listens l
|
||||
ON l.listened_at >= b.bucket_start
|
||||
AND l.listened_at < b.bucket_start + $3::interval
|
||||
GROUP BY b.bucket_start
|
||||
ORDER BY b.bucket_start
|
||||
)
|
||||
SELECT * FROM bucketed_listens;
|
||||
SELECT
|
||||
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE l.listened_at >= $2
|
||||
AND l.listened_at < $3
|
||||
AND t.id = $4
|
||||
GROUP BY day
|
||||
ORDER BY day;
|
||||
|
||||
-- name: UpdateTrackIdForListens :exec
|
||||
UPDATE listens SET track_id = $2
|
||||
|
|
@ -220,3 +196,70 @@ WHERE track_id = $1;
|
|||
|
||||
-- name: DeleteListen :exec
|
||||
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 *;
|
||||
|
||||
-- name: GetRelease :one
|
||||
SELECT * FROM releases_with_title
|
||||
SELECT
|
||||
*,
|
||||
get_artists_for_release(id) AS artists
|
||||
FROM releases_with_title
|
||||
WHERE id = $1 LIMIT 1;
|
||||
|
||||
-- name: GetReleaseByMbzID :one
|
||||
|
|
@ -29,44 +32,76 @@ JOIN artist_releases ar ON r.id = ar.release_id
|
|||
WHERE r.title = ANY ($1::TEXT[]) AND ar.artist_id = $2
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetReleaseByArtistAndTitlesNoMbzID :one
|
||||
SELECT r.*
|
||||
FROM releases_with_title r
|
||||
JOIN artist_releases ar ON r.id = ar.release_id
|
||||
WHERE r.title = ANY ($1::TEXT[])
|
||||
AND ar.artist_id = $2
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM releases r2
|
||||
WHERE r2.id = r.id
|
||||
AND r2.musicbrainz_id IS NULL
|
||||
);
|
||||
|
||||
-- name: GetTopReleasesFromArtist :many
|
||||
SELECT
|
||||
r.*,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
JOIN artist_releases ar ON r.id = ar.release_id
|
||||
WHERE ar.artist_id = $5
|
||||
AND l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
ORDER BY listen_count DESC
|
||||
x.*,
|
||||
get_artists_for_release(x.id) AS artists,
|
||||
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||
FROM (
|
||||
SELECT
|
||||
r.*,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
JOIN artist_releases ar ON r.id = ar.release_id
|
||||
WHERE ar.artist_id = $5
|
||||
AND l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
) x
|
||||
ORDER BY listen_count DESC, x.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetTopReleasesPaginated :many
|
||||
SELECT
|
||||
r.*,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
ORDER BY listen_count DESC
|
||||
x.*,
|
||||
get_artists_for_release(x.id) AS artists,
|
||||
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||
FROM (
|
||||
SELECT
|
||||
r.*,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
) x
|
||||
ORDER BY listen_count DESC, x.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetReleaseAllTimeRank :one
|
||||
SELECT
|
||||
release_id,
|
||||
rank
|
||||
FROM (
|
||||
SELECT
|
||||
x.release_id,
|
||||
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||
FROM (
|
||||
SELECT
|
||||
t.release_id,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
GROUP BY t.release_id
|
||||
) x
|
||||
)
|
||||
WHERE release_id = $1;
|
||||
|
||||
-- name: CountTopReleases :one
|
||||
SELECT COUNT(DISTINCT r.id) AS total_count
|
||||
FROM listens l
|
||||
|
|
@ -80,20 +115,25 @@ FROM releases r
|
|||
JOIN artist_releases ar ON r.id = ar.release_id
|
||||
WHERE ar.artist_id = $1;
|
||||
|
||||
-- name: CountNewReleases :one
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM (
|
||||
SELECT t.release_id
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
GROUP BY t.release_id
|
||||
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
|
||||
) first_appearances;
|
||||
|
||||
-- name: AssociateArtistToRelease :exec
|
||||
INSERT INTO artist_releases (artist_id, release_id)
|
||||
VALUES ($1, $2)
|
||||
INSERT INTO artist_releases (artist_id, release_id, is_primary)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- name: GetReleasesWithoutImages :many
|
||||
SELECT
|
||||
r.*,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM releases_with_title r
|
||||
WHERE r.image IS NULL
|
||||
AND r.id > $2
|
||||
|
|
@ -104,6 +144,14 @@ LIMIT $1;
|
|||
UPDATE releases SET musicbrainz_id = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateReleaseVariousArtists :exec
|
||||
UPDATE releases SET various_artists = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateReleasePrimaryArtist :exec
|
||||
UPDATE artist_releases SET is_primary = $3
|
||||
WHERE artist_id = $1 AND release_id = $2;
|
||||
|
||||
-- name: UpdateReleaseImage :exec
|
||||
UPDATE releases SET image = $2, image_source = $3
|
||||
WHERE id = $1;
|
||||
|
|
|
|||
|
|
@ -42,12 +42,7 @@ SELECT
|
|||
ranked.release_id,
|
||||
ranked.image,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_track(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
|
|
@ -74,12 +69,7 @@ SELECT
|
|||
ranked.release_id,
|
||||
ranked.image,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_track(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
|
|
@ -106,12 +96,7 @@ SELECT
|
|||
ranked.image,
|
||||
ranked.various_artists,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_release(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
r.id,
|
||||
|
|
@ -137,12 +122,7 @@ SELECT
|
|||
ranked.image,
|
||||
ranked.various_artists,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_release(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
r.id,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ VALUES ($1, $2, $3)
|
|||
RETURNING *;
|
||||
|
||||
-- name: AssociateArtistToTrack :exec
|
||||
INSERT INTO artist_tracks (artist_id, track_id)
|
||||
VALUES ($1, $2)
|
||||
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- name: GetTrack :one
|
||||
SELECT
|
||||
t.*,
|
||||
get_artists_for_track(t.id) AS artists,
|
||||
r.image
|
||||
FROM tracks_with_title t
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -26,83 +27,112 @@ FROM tracks_with_title t
|
|||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
WHERE at.artist_id = $1;
|
||||
|
||||
-- name: GetTrackByTitleAndArtists :one
|
||||
-- name: GetTrackByTrackInfo :one
|
||||
SELECT t.*
|
||||
FROM tracks_with_title t
|
||||
JOIN artist_tracks at ON at.track_id = t.id
|
||||
WHERE t.title = $1
|
||||
AND at.artist_id = ANY($2::int[])
|
||||
AND at.artist_id = ANY($3::int[])
|
||||
AND t.release_id = $2
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.duration, t.release_id
|
||||
HAVING COUNT(DISTINCT at.artist_id) = cardinality($2::int[]);
|
||||
HAVING COUNT(DISTINCT at.artist_id) = cardinality($3::int[]);
|
||||
|
||||
-- name: GetTopTracksPaginated :many
|
||||
SELECT
|
||||
t.id,
|
||||
x.track_id AS id,
|
||||
t.title,
|
||||
t.musicbrainz_id,
|
||||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
x.listen_count,
|
||||
get_artists_for_track(x.track_id) AS artists,
|
||||
x.rank
|
||||
FROM (
|
||||
SELECT
|
||||
track_id,
|
||||
COUNT(*) AS listen_count,
|
||||
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
|
||||
FROM listens
|
||||
WHERE listened_at BETWEEN $1 AND $2
|
||||
GROUP BY track_id
|
||||
ORDER BY listen_count DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
) x
|
||||
JOIN tracks_with_title t ON x.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
ORDER BY x.listen_count DESC, x.track_id;
|
||||
|
||||
-- name: GetTopTracksByArtistPaginated :many
|
||||
SELECT
|
||||
t.id,
|
||||
x.track_id AS id,
|
||||
t.title,
|
||||
t.musicbrainz_id,
|
||||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at2
|
||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
||||
WHERE at2.track_id = t.id
|
||||
) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
x.listen_count,
|
||||
get_artists_for_track(x.track_id) AS artists,
|
||||
x.rank
|
||||
FROM (
|
||||
SELECT
|
||||
l.track_id,
|
||||
COUNT(*) AS listen_count,
|
||||
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
|
||||
FROM listens l
|
||||
JOIN artist_tracks at ON l.track_id = at.track_id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND at.artist_id = $5
|
||||
GROUP BY l.track_id
|
||||
ORDER BY listen_count DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
) x
|
||||
JOIN tracks_with_title t ON x.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
JOIN artist_tracks at ON at.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND at.artist_id = $5
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
ORDER BY x.listen_count DESC, x.track_id;
|
||||
|
||||
-- name: GetTopTracksInReleasePaginated :many
|
||||
SELECT
|
||||
t.id,
|
||||
x.track_id AS id,
|
||||
t.title,
|
||||
t.musicbrainz_id,
|
||||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at2
|
||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
||||
WHERE at2.track_id = t.id
|
||||
) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
x.listen_count,
|
||||
get_artists_for_track(x.track_id) AS artists,
|
||||
x.rank
|
||||
FROM (
|
||||
SELECT
|
||||
l.track_id,
|
||||
COUNT(*) AS listen_count,
|
||||
RANK() OVER (ORDER BY COUNT(*) DESC) as rank
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND t.release_id = $5
|
||||
GROUP BY l.track_id
|
||||
ORDER BY listen_count DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
) x
|
||||
JOIN tracks_with_title t ON x.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND t.release_id = $5
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
ORDER BY x.listen_count DESC, x.track_id;
|
||||
|
||||
-- name: GetTrackAllTimeRank :one
|
||||
SELECT
|
||||
id,
|
||||
rank
|
||||
FROM (
|
||||
SELECT
|
||||
x.id,
|
||||
RANK() OVER (ORDER BY x.listen_count DESC) AS rank
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
COUNT(*) AS listen_count
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
GROUP BY t.id) x
|
||||
) y
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CountTopTracks :one
|
||||
SELECT COUNT(DISTINCT l.track_id) AS total_count
|
||||
|
|
@ -123,6 +153,15 @@ JOIN tracks t ON l.track_id = t.id
|
|||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND t.release_id = $3;
|
||||
|
||||
-- name: CountNewTracks :one
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM (
|
||||
SELECT track_id
|
||||
FROM listens
|
||||
GROUP BY track_id
|
||||
HAVING MIN(listened_at) BETWEEN $1 AND $2
|
||||
) first_appearances;
|
||||
|
||||
-- name: UpdateTrackMbzID :exec
|
||||
UPDATE tracks SET musicbrainz_id = $2
|
||||
WHERE id = $1;
|
||||
|
|
@ -135,5 +174,19 @@ WHERE id = $1;
|
|||
UPDATE tracks SET release_id = $2
|
||||
WHERE release_id = $1;
|
||||
|
||||
-- name: UpdateTrackPrimaryArtist :exec
|
||||
UPDATE artist_tracks SET is_primary = $3
|
||||
WHERE artist_id = $1 AND track_id = $2;
|
||||
|
||||
-- name: DeleteTrack :exec
|
||||
DELETE FROM tracks WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlight from "@astrojs/starlight";
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
starlight({
|
||||
head: [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: {
|
||||
src: 'https://static.cloudflareinsights.com/beacon.min.js',
|
||||
'data-cf-beacon': '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
|
||||
defer: true,
|
||||
}
|
||||
}
|
||||
],
|
||||
title: 'Koito',
|
||||
logo: {
|
||||
src: './src/assets/logo_text.png',
|
||||
replacesTitle: true,
|
||||
starlight({
|
||||
head: [
|
||||
{
|
||||
tag: "script",
|
||||
attrs: {
|
||||
src: "https://static.cloudflareinsights.com/beacon.min.js",
|
||||
"data-cf-beacon": '{"token": "1948caaaba10463fa1d310ee02b0951c"}',
|
||||
defer: true,
|
||||
},
|
||||
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gabehf/koito' }],
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Guides',
|
||||
items: [
|
||||
// Each item here is one entry in the navigation menu.
|
||||
{ label: 'Installation', slug: 'guides/installation' },
|
||||
{ label: 'Importing Data', slug: 'guides/importing' },
|
||||
{ label: 'Setting up the Scrobbler', slug: 'guides/scrobbler' },
|
||||
{ label: 'Editing Data', slug: 'guides/editing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reference',
|
||||
items: [
|
||||
{ label: 'Configuration Options', slug: 'reference/configuration' },
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
title: "Koito",
|
||||
logo: {
|
||||
src: "./src/assets/logo_text.png",
|
||||
replacesTitle: true,
|
||||
},
|
||||
social: [
|
||||
{
|
||||
icon: "github",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/gabehf/koito",
|
||||
},
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
label: "Guides",
|
||||
items: [
|
||||
// Each item here is one entry in the navigation menu.
|
||||
{ label: "Installation", slug: "guides/installation" },
|
||||
{ label: "Importing Data", slug: "guides/importing" },
|
||||
{ label: "Setting up the Scrobbler", slug: "guides/scrobbler" },
|
||||
{ label: "Editing Data", slug: "guides/editing" },
|
||||
],
|
||||
customCss: [
|
||||
// Path to your Tailwind base styles:
|
||||
'./src/styles/global.css',
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Quickstart",
|
||||
items: [
|
||||
{ label: "Setup with Navidrome", slug: "quickstart/navidrome" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
items: [
|
||||
{ label: "Configuration Options", slug: "reference/configuration" },
|
||||
],
|
||||
},
|
||||
],
|
||||
customCss: [
|
||||
// Path to your Tailwind base styles:
|
||||
"./src/styles/global.css",
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
site: "https://koito.io",
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
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
|
||||
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
|
||||
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
|
||||
means that artist aliases will not be automatically fetched for imported artists. This also means that artists will not be associated with their MusicBrainz IDs internally,
|
||||
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
|
||||
means that artist aliases will not be automatically fetched for imported artists. You can also use
|
||||
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
|
||||
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: Setting up the Scrobber
|
|||
description: How to relay listens submitted to Koito to another ListenBrainz compatible server.
|
||||
---
|
||||
|
||||
To use the ListenBrainz API, you need to get your generated api key from the UI.
|
||||
To use the ListenBrainz API, you need to get your generated API key from the UI. The API key is what you will use as the ListenBrainz token.
|
||||
|
||||
First, open the settings in your Koito instance by clicking on the settings icon or pressing `\`.
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ After logging in, open the settings menu again and find the `API Keys` tab. On t
|
|||
If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it.
|
||||
:::
|
||||
|
||||
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` and provide the api key from the UI as the token.
|
||||
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` (or `{your_koito_address}/apis/listenbrainz` for some applications) and provide the API key from the UI as the token.
|
||||
|
||||
## Set up a relay
|
||||
|
||||
|
|
@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
|
|||
|
||||
:::note
|
||||
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
|
||||
For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -28,12 +28,12 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
|||
Koito can be connected to any music server or client that allows for custom ListenBrainz URLs.
|
||||
</Card>
|
||||
<Card title="Scrobbler relay" icon="rocket">
|
||||
Automatically relay listens submitted to your Koito instance to other ListenBrainz compatble servers.
|
||||
Automatically relay listens submitted to your Koito instance to other ListenBrainz compatible servers.
|
||||
</Card>
|
||||
<Card title="Automatic data fetching" icon="download">
|
||||
Koito automatically fetches data from MusicBrainz and images from Deezer and Cover Art Archive to compliment what is provided by your music server.
|
||||
</Card>
|
||||
<Card title="Themeable" icon="seti:css">
|
||||
Koito ships with twelve different themes, with custom theme options to be added soon™.
|
||||
Koito ships with twelve different themes, now with support for custom themes!
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
|
|
|||
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.
|
||||
|
||||
The suffix `_FILE` is also supported for every environment variable. This allows the use of Docker secrets, for example: `KOITO_DATABASE_URL_FILE=/run/secrets/database-url` will load the content of the file at `/run/secrets/database-url` for the environment variable `KOITO_DATABASE_URL`.
|
||||
|
||||
:::caution
|
||||
If the environment variable is defined without **and** with the suffix at the same time, the content of the environment variable without the `_FILE` suffix will have the higher priority.
|
||||
:::
|
||||
|
||||
##### KOITO_DATABASE_URL
|
||||
- Required: `true`
|
||||
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
|
||||
|
|
@ -17,6 +23,12 @@ Koito is configured using **environment variables**. This is the full list of co
|
|||
##### KOITO_DEFAULT_PASSWORD
|
||||
- Default: `changeme`
|
||||
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
|
||||
##### KOITO_DEFAULT_THEME
|
||||
- Default: `yuu`
|
||||
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
|
||||
##### KOITO_LOGIN_GATE
|
||||
- Default: `false`
|
||||
- Description: When `true`, Koito will not show any statistics unless the user is logged in.
|
||||
##### KOITO_BIND_ADDR
|
||||
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
|
||||
##### KOITO_LISTEN_PORT
|
||||
|
|
@ -31,6 +43,9 @@ Koito is configured using **environment variables**. This is the full list of co
|
|||
##### KOITO_LOG_LEVEL
|
||||
- Default: `info`
|
||||
- Description: One of `debug | info | warn | error | fatal`
|
||||
##### KOITO_ARTIST_SEPARATORS_REGEX
|
||||
- Default: `\s+·\s+`
|
||||
- Description: The list of regex patterns Koito will use to separate artist strings, separated by two semicolons (`;;`).
|
||||
##### KOITO_MUSICBRAINZ_URL
|
||||
- Default: `https://musicbrainz.org`
|
||||
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.
|
||||
|
|
@ -49,6 +64,8 @@ Koito is configured using **environment variables**. This is the full list of co
|
|||
##### KOITO_CONFIG_DIR
|
||||
- Default: `/etc/koito`
|
||||
- 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
|
||||
- Default: `false`
|
||||
- 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.
|
||||
##### KOITO_DISABLE_MUSICBRAINZ
|
||||
- Default: `false`
|
||||
##### KOITO_SUBSONIC_URL
|
||||
- Required: `true` if KOITO_SUBSONIC_PARAMS is set
|
||||
- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`.
|
||||
##### KOITO_SUBSONIC_PARAMS
|
||||
- Required: `true` if KOITO_SUBSONIC_URL is set
|
||||
- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request.
|
||||
:::caution
|
||||
If Koito is unable to validate your Subsonic configuration, it will fail to start. If you notice your container isn't running after
|
||||
changing these parameters, check the logs!
|
||||
:::
|
||||
##### KOITO_LASTFM_API_KEY
|
||||
- Required: `false`
|
||||
- Description: Your LastFM API key, which will be used for fetching images if provided. You can get an API key [here](https://www.last.fm/api/authentication),
|
||||
##### KOITO_SKIP_IMPORT
|
||||
- Default: `false`
|
||||
- Description: Skips running the importer on startup.
|
||||
|
|
@ -70,6 +100,9 @@ Koito is configured using **environment variables**. This is the full list of co
|
|||
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
|
||||
##### KOITO_IMPORT_AFTER_UNIX
|
||||
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
|
||||
##### KOITO_FETCH_IMAGES_DURING_IMPORT
|
||||
- Default: `false`
|
||||
- Description: When true, images will be downloaded and cached during imports.
|
||||
##### KOITO_CORS_ALLOWED_ORIGINS
|
||||
- Default: No CORS policy
|
||||
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
||||
|
|
@ -2,6 +2,7 @@ package engine
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -95,6 +96,10 @@ func Run(
|
|||
defer store.Close(ctx)
|
||||
l.Info().Msg("Engine: Database connection established")
|
||||
|
||||
if cfg.ForceTZ() != nil {
|
||||
l.Debug().Msgf("Engine: Forcing the use of timezone '%s'", cfg.ForceTZ().String())
|
||||
}
|
||||
|
||||
l.Debug().Msg("Engine: Initializing MusicBrainz client")
|
||||
var mbzC mbz.MusicBrainzCaller
|
||||
if !cfg.MusicBrainzDisabled() {
|
||||
|
|
@ -105,11 +110,39 @@ func Run(
|
|||
l.Warn().Msg("Engine: MusicBrainz client disabled")
|
||||
}
|
||||
|
||||
if cfg.SubsonicEnabled() {
|
||||
l.Debug().Msg("Engine: Checking Subsonic configuration")
|
||||
pingURL := cfg.SubsonicUrl() + "/rest/ping.view?" + cfg.SubsonicParams() + "&f=json&v=1&c=koito"
|
||||
|
||||
resp, err := http.Get(pingURL)
|
||||
if err != nil {
|
||||
l.Fatal().Err(err).Msg("Engine: Failed to contact Subsonic server! Ensure the provided URL is correct")
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Response struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"subsonic-response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
l.Fatal().Err(err).Msg("Engine: Failed to parse Subsonic response")
|
||||
} else if result.Response.Status != "ok" {
|
||||
l.Fatal().Msg("Engine: Provided Subsonic credentials are invalid")
|
||||
} else {
|
||||
l.Info().Msg("Engine: Subsonic credentials validated successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
l.Debug().Msg("Engine: Initializing image sources")
|
||||
images.Initialize(images.ImageSourceOpts{
|
||||
UserAgent: cfg.UserAgent(),
|
||||
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
||||
EnableDeezer: !cfg.DeezerDisabled(),
|
||||
UserAgent: cfg.UserAgent(),
|
||||
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
||||
EnableDeezer: !cfg.DeezerDisabled(),
|
||||
EnableSubsonic: cfg.SubsonicEnabled(),
|
||||
EnableLastFM: cfg.LastFMApiKey() != "",
|
||||
})
|
||||
l.Info().Msg("Engine: Image sources initialized")
|
||||
|
||||
|
|
@ -183,6 +216,8 @@ func Run(
|
|||
}
|
||||
}()
|
||||
|
||||
l.Info().Msg("Engine: Beginning startup tasks...")
|
||||
|
||||
l.Debug().Msg("Engine: Checking import configuration")
|
||||
if !cfg.SkipImport() {
|
||||
go func() {
|
||||
|
|
@ -192,6 +227,12 @@ func Run(
|
|||
|
||||
l.Info().Msg("Engine: Pruning orphaned images")
|
||||
go catalog.PruneOrphanedImages(logger.NewContext(l), store)
|
||||
l.Info().Msg("Engine: Running duration backfill task")
|
||||
go catalog.BackfillTrackDurationsFromMusicBrainz(ctx, store, mbzC)
|
||||
l.Info().Msg("Engine: Attempting to fetch missing artist images")
|
||||
go catalog.FetchMissingArtistImages(ctx, store)
|
||||
l.Info().Msg("Engine: Attempting to fetch missing album images")
|
||||
go catalog.FetchMissingAlbumImages(ctx, store)
|
||||
|
||||
l.Info().Msg("Engine: Initialization finished")
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
|
@ -212,19 +253,19 @@ func Run(
|
|||
}
|
||||
|
||||
func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
||||
l.Debug().Msg("Checking for import files...")
|
||||
l.Debug().Msg("Importer: Checking for import files...")
|
||||
files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import"))
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to read files from import dir")
|
||||
l.Err(err).Msg("Importer: Failed to read files from import dir")
|
||||
}
|
||||
if len(files) > 0 {
|
||||
l.Info().Msg("Files found in import directory. Attempting to import...")
|
||||
l.Info().Msg("Importer: Files found in import directory. Attempting to import...")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
l.Error().Interface("recover", r).Msg("Panic when importing files")
|
||||
l.Error().Interface("recover", r).Msg("Importer: Panic when importing files")
|
||||
}
|
||||
}()
|
||||
for _, file := range files {
|
||||
|
|
@ -232,31 +273,37 @@ func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
|||
continue
|
||||
}
|
||||
if strings.Contains(file.Name(), "Streaming_History_Audio") {
|
||||
l.Info().Msgf("Import file %s detecting as being Spotify export", file.Name())
|
||||
l.Info().Msgf("Importer: Import file %s detecting as being Spotify export", file.Name())
|
||||
err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name())
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
||||
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else if strings.Contains(file.Name(), "maloja") {
|
||||
l.Info().Msgf("Import file %s detecting as being Maloja export", file.Name())
|
||||
l.Info().Msgf("Importer: Import file %s detecting as being Maloja export", file.Name())
|
||||
err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name())
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
||||
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else if strings.Contains(file.Name(), "recenttracks") {
|
||||
l.Info().Msgf("Import file %s detecting as being ghan.nl LastFM export", file.Name())
|
||||
l.Info().Msgf("Importer: Import file %s detecting as being ghan.nl LastFM export", file.Name())
|
||||
err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name())
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
||||
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else if strings.Contains(file.Name(), "listenbrainz") {
|
||||
l.Info().Msgf("Import file %s detecting as being ListenBrainz export", file.Name())
|
||||
l.Info().Msgf("Importer: Import file %s detecting as being ListenBrainz export", file.Name())
|
||||
err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name())
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
||||
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else if strings.Contains(file.Name(), "koito") {
|
||||
l.Info().Msgf("Importer: Import file %s detecting as being Koito export", file.Name())
|
||||
err := importer.ImportKoitoFile(logger.NewContext(l), store, file.Name())
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Importer: Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else {
|
||||
l.Warn().Msgf("File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
|
||||
l.Warn().Msgf("Importer: File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue