Compare commits

..

No commits in common. "main" and "v0.0.2" have entirely different histories.
main ... v0.0.2

217 changed files with 5712 additions and 15797 deletions

View file

@ -1,5 +0,0 @@
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
View file

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

View file

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

View file

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

View file

@ -1,15 +1,14 @@
name: Deploy to GitHub Pages name: Deploy to GitHub Pages
on: on:
# Trigger the workflow every time you push to the `main` branch
# Using a different branch name? Replace `main` with your branchs name
push: push:
tags: branches: [main]
- "v*" # Allows you to run this workflow manually from the Actions tab on GitHub.
paths:
- "docs/**"
- ".github/workflows/**"
workflow_dispatch: workflow_dispatch:
# Allow this job to clone the repo and create a page deployment
permissions: permissions:
contents: read contents: read
pages: write pages: write
@ -24,9 +23,9 @@ jobs:
- name: Install, build, and upload your site output - name: Install, build, and upload your site output
uses: withastro/action@v4 uses: withastro/action@v4
with: with:
path: ./docs # The root location of your Astro project inside the repository. (optional) path: ./docs # The root location of your Astro project inside the repository. (optional)
node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional) node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional)
package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional) package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
deploy: deploy:
needs: build needs: build

View file

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

View file

@ -1,32 +0,0 @@
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
View file

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

14
CHANGELOG.md Normal file
View file

@ -0,0 +1,14 @@
# v0.0.2
## Features
- Configurable CORS policy via KOITO_CORS_ALLOWED_ORIGINS
- A baseline mobile UI
## Enhancements
- The import source is now saved as the client for the imported listen.
## Fixes
- Account update form now works on enter key
## Updates
- Non-sensitive query parameters are logged with requests
- Koito version number is embedded through tags

View file

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

View file

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

View file

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

Binary file not shown.

Binary file not shown.

87
client/README.md Normal file
View file

@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
To build and run using Docker:
```bash
docker build -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ export default function PeriodSelector({ setter, current, disableCache = false }
}, []); }, []);
return ( return (
<div className="flex gap-2 grow-0 text-sm sm:text-[16px]"> <div className="flex gap-2">
<p>Showing stats for:</p> <p>Showing stats for:</p>
{periods.map((p, i) => ( {periods.map((p, i) => (
<div key={`period_setter_${p}`}> <div key={`period_setter_${p}`}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,73 +1,36 @@
import { ExternalLink, History, Home, Info } from "lucide-react"; import { ExternalLink, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch"; import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem"; import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings"; import SidebarSettings from "./SidebarSettings";
import { getRewindParams, getRewindYear } from "~/utils/utils";
export default function Sidebar() { export default function Sidebar() {
const iconSize = 20; const iconSize = 20;
return ( return (
<div <div className="overflow-x-hidden w-full sm:w-auto">
className=" <div className="z-50 flex sm:flex-col justify-between sm:h-screen h-auto sm:w-auto w-full border-b sm:border-b-0 sm:border-r border-(--color-bg-tertiary) pt-2 sm:py-10 sm:px-1 px-4 sticky top-0 sm:left-0 bg-(--color-bg)">
z-50 <div className="flex gap-4 sm:flex-col">
flex <SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
sm:flex-col <Home size={iconSize} />
justify-between </SidebarItem>
sm:fixed <SidebarSearch size={iconSize} />
sm:top-0 </div>
sm:left-0 <div className="flex gap-4 sm:flex-col">
sm:h-screen <SidebarItem
h-auto icon
sm:w-auto keyHint={<ExternalLink size={14} />}
w-full space={22}
border-b externalLink
sm:border-b-0 to="https://koito.io"
sm:border-r name="About"
border-(--color-bg-tertiary) onClick={() => {}}
pt-2 modal={<></>}
sm:py-10 >
sm:px-1 <Info size={iconSize} />
px-4 </SidebarItem>
bg-(--color-bg) <SidebarSettings size={iconSize} />
" </div>
> </div>
<div className="flex gap-4 sm:flex-col"> </div>
<SidebarItem );
space={10}
to="/"
name="Home"
onClick={() => {}}
modal={<></>}
>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
<SidebarItem
space={10}
to="/rewind"
name="Rewind"
onClick={() => {}}
modal={<></>}
>
<History size={iconSize} />
</SidebarItem>
</div>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
);
} }

View file

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

View file

@ -1,78 +1,36 @@
import { useState } from "react"; // ThemeSwitcher.tsx
import { useTheme } from "../../hooks/useTheme"; import { useEffect } from 'react';
import themes from "~/styles/themes.css"; import { useTheme } from '../../hooks/useTheme';
import ThemeOption from "./ThemeOption"; import { themes } from '~/providers/ThemeProvider';
import { AsyncButton } from "../AsyncButton"; import ThemeOption from './ThemeOption';
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { setTheme } = useTheme(); const { theme, 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, " ")
);
const handleCustomTheme = () => { useEffect(() => {
console.log(custom); const saved = localStorage.getItem('theme');
try { if (saved && saved !== theme) {
const themeData = JSON.parse(custom); setTheme(saved);
setCustomTheme(themeData); } else if (!saved) {
setCustom(JSON.stringify(themeData, null, " ")); localStorage.setItem('theme', theme)
console.log(themeData); }
} catch (err) { }, []);
console.log(err);
}
};
return ( useEffect(() => {
<div className="flex flex-col gap-10"> if (theme) {
<div> localStorage.setItem('theme', theme)
<div className="flex items-center gap-3"> }
<h3>Select Theme</h3> }, [theme]);
<div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton> return (
</div> <>
<h2>Select Theme</h2>
<div className="grid grid-cols-2 items-center gap-2">
{themes.map((t) => (
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
))}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 items-center gap-2"> </>
{Object.entries(themes).map(([name, themeData]) => ( );
<ThemeOption
setTheme={setTheme}
key={name}
theme={themeData}
themeName={name}
/>
))}
</div>
</div>
<div>
<h3>Use Custom Theme</h3>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea
name="custom-theme"
onChange={(e) => setCustom(e.target.value)}
id="custom-theme-input"
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
value={custom}
/>
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div>
</div>
</div>
);
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,107 +1,66 @@
import ChartLayout from "./ChartLayout"; import ChartLayout from "./ChartLayout";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router"; import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { deleteListen, type Listen, type PaginatedResponse } from "api/api"; import { type Album, type Listen, type PaginatedResponse } from "api/api";
import { timeSince } from "~/utils/utils"; import { timeSince } from "~/utils/utils";
import ArtistLinks from "~/components/ArtistLinks"; import ArtistLinks from "~/components/ArtistLinks";
import { useState } from "react";
import { useAppContext } from "~/providers/AppProvider";
export async function clientLoader({ request }: LoaderFunctionArgs) { export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const page = url.searchParams.get("page") || "0"; const page = url.searchParams.get("page") || "0";
url.searchParams.set('page', page) url.searchParams.set('page', page)
const res = await fetch( const res = await fetch(
`/apis/web/v1/listens?${url.searchParams.toString()}` `/apis/web/v1/listens?${url.searchParams.toString()}`
); );
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load top tracks", { status: 500 }); throw new Response("Failed to load top tracks", { status: 500 });
} }
const listens: PaginatedResponse<Listen> = await res.json(); const listens: PaginatedResponse<Album> = await res.json();
return { listens }; return { listens };
} }
export default function Listens() { export default function Listens() {
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>(); const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
const [items, setItems] = useState<Listen[] | null>(null)
const { user } = useAppContext()
const handleDelete = async (listen: Listen) => {
if (!initialData) return
try {
const res = await deleteListen(listen)
if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? initialData.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 listens = items ?? initialData.items
return ( return (
<ChartLayout <ChartLayout
title="Last Played" title="Last Played"
initialData={initialData} initialData={initialData}
endpoint="listens" endpoint="listens"
render={({ data, page, onNext, onPrev }) => ( render={({ data, page, onNext, onPrev }) => (
<div className="flex flex-col gap-5 text-sm md:text-[16px]"> <div className="flex flex-col gap-5">
<div className="flex gap-15 mx-auto"> <div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page <= 1}> <button className="default" onClick={onPrev} disabled={page <= 1}>
Prev Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
<table>
<tbody>
{data.items.map((item) => (
<tr key={`last_listen_${item.time}`}>
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
<td className="text-ellipsis overflow-hidden w-[700px]">
<ArtistLinks artists={item.track.artists} />{' - '}
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
</button> </button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}> <button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next Next
</button> </button>
</div> </div>
<table className="-ml-4"> </div>
<tbody> )}
{listens.map((item) => ( />
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> );
<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>
</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]">
<ArtistLinks artists={item.track.artists} /> {' '}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${item.track.id}`}
>
{item.track.title}
</Link>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex gap-15 mx-auto">
<button className="default" onClick={onPrev} disabled={page === 0}>
Prev
</button>
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
Next
</button>
</div>
</div>
)}
/>
);
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,43 @@
import { isRouteErrorResponse, Outlet } from "react-router";
import Footer from "~/components/Footer";
import type { Route } from "../+types/root";
export default function Root() {
return (
<div className="flex flex-col items-center mx-auto w-full">
<Outlet />
<Footer />
</div>
)
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto scroll-smooth">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View file

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

View file

@ -1,241 +0,0 @@
import { globalStyle } from "@vanilla-extract/css";
import { themeVars } from "./vars.css";
export type Theme = {
bg: string;
bgSecondary: string;
bgTertiary: string;
fg: string;
fgSecondary: string;
fgTertiary: string;
primary: string;
primaryDim: string;
accent: string;
accentDim: string;
error: string;
warning: string;
info: string;
success: string;
};
export const THEME_KEYS = ["--color"];
export const themes: Record<string, Theme> = {
yuu: {
bg: "#1e1816",
bgSecondary: "#2f2623",
bgTertiary: "#453733",
fg: "#f8f3ec",
fgSecondary: "#d6ccc2",
fgTertiary: "#b4a89c",
primary: "#fc9174",
primaryDim: "#d88b65",
accent: "#f9db6d",
accentDim: "#d9bc55",
error: "#e26c6a",
warning: "#f5b851",
success: "#8fc48f",
info: "#87b8dd",
},
varia: {
bg: "rgb(25, 25, 29)",
bgSecondary: "#222222",
bgTertiary: "#333333",
fg: "#eeeeee",
fgSecondary: "#aaaaaa",
fgTertiary: "#888888",
primary: "rgb(203, 110, 240)",
primaryDim: "#c28379",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
midnight: {
bg: "rgb(8, 15, 24)",
bgSecondary: "rgb(15, 27, 46)",
bgTertiary: "rgb(15, 41, 70)",
fg: "#dbdfe7",
fgSecondary: "#9ea3a8",
fgTertiary: "#74787c",
primary: "#1a97eb",
primaryDim: "#2680aa",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
catppuccin: {
bg: "#1e1e2e",
bgSecondary: "#181825",
bgTertiary: "#11111b",
fg: "#cdd6f4",
fgSecondary: "#a6adc8",
fgTertiary: "#9399b2",
primary: "#89b4fa",
primaryDim: "#739df0",
accent: "#f38ba8",
accentDim: "#d67b94",
error: "#f38ba8",
warning: "#f9e2af",
success: "#a6e3a1",
info: "#89dceb",
},
autumn: {
bg: "rgb(44, 25, 18)",
bgSecondary: "rgb(70, 40, 18)",
bgTertiary: "#4b2f1c",
fg: "#fef9f3",
fgSecondary: "#dbc6b0",
fgTertiary: "#a3917a",
primary: "#F0850A",
primaryDim: "#b45309",
accent: "#8c4c28",
accentDim: "#6b3b1f",
error: "#d1433f",
warning: "#e38b29",
success: "#6b8e23",
info: "#c084fc",
},
black: {
bg: "#000000",
bgSecondary: "#1a1a1a",
bgTertiary: "#2a2a2a",
fg: "#dddddd",
fgSecondary: "#aaaaaa",
fgTertiary: "#888888",
primary: "#08c08c",
primaryDim: "#08c08c",
accent: "#f0ad0a",
accentDim: "#d08d08",
error: "#f44336",
warning: "#ff9800",
success: "#4caf50",
info: "#2196f3",
},
wine: {
bg: "#23181E",
bgSecondary: "#2C1C25",
bgTertiary: "#422A37",
fg: "#FCE0B3",
fgSecondary: "#C7AC81",
fgTertiary: "#A78E64",
primary: "#EA8A64",
primaryDim: "#BD7255",
accent: "#FAE99B",
accentDim: "#C6B464",
error: "#fca5a5",
warning: "#fde68a",
success: "#bbf7d0",
info: "#bae6fd",
},
pearl: {
bg: "#FFFFFF",
bgSecondary: "#EEEEEE",
bgTertiary: "#E0E0E0",
fg: "#333333",
fgSecondary: "#555555",
fgTertiary: "#777777",
primary: "#007BFF",
primaryDim: "#0056B3",
accent: "#28A745",
accentDim: "#1E7E34",
error: "#DC3545",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
rosebud: {
bg: "#260d19",
bgSecondary: "#3A1325",
bgTertiary: "#45182D",
fg: "#F3CAD8",
fgSecondary: "#C88B99",
fgTertiary: "#B2677D",
primary: "#d76fa2",
primaryDim: "#b06687",
accent: "#e79cb8",
accentDim: "#c27d8c",
error: "#e84b73",
warning: "#f2b38c",
success: "#6FC4A6",
info: "#6BAEDC",
},
urim: {
bg: "#101713",
bgSecondary: "#1B2921",
bgTertiary: "#273B30",
fg: "#D2E79E",
fgSecondary: "#B4DA55",
fgTertiary: "#7E9F2A",
primary: "#ead500",
primaryDim: "#C1B210",
accent: "#28A745",
accentDim: "#1E7E34",
error: "#EE5237",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
match: {
bg: "#071014",
bgSecondary: "#0A181E",
bgTertiary: "#112A34",
fg: "#ebeaeb",
fgSecondary: "#BDBDBD",
fgTertiary: "#A2A2A2",
primary: "#fda827",
primaryDim: "#C78420",
accent: "#277CFD",
accentDim: "#1F60C1",
error: "#F14426",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
lemon: {
bg: "#1a171a",
bgSecondary: "#2E272E",
bgTertiary: "#443844",
fg: "#E6E2DC",
fgSecondary: "#B2ACA1",
fgTertiary: "#968F82",
primary: "#f5c737",
primaryDim: "#C29D2F",
accent: "#277CFD",
accentDim: "#1F60C1",
error: "#F14426",
warning: "#FFC107",
success: "#28A745",
info: "#17A2B8",
},
};
export default themes;
Object.entries(themes).forEach(([name, theme]) => {
const selector = `[data-theme="${name}"]`;
globalStyle(selector, {
vars: {
[themeVars.bg]: theme.bg,
[themeVars.bgSecondary]: theme.bgSecondary,
[themeVars.bgTertiary]: theme.bgTertiary,
[themeVars.fg]: theme.fg,
[themeVars.fgSecondary]: theme.fgSecondary,
[themeVars.fgTertiary]: theme.fgTertiary,
[themeVars.primary]: theme.primary,
[themeVars.primaryDim]: theme.primaryDim,
[themeVars.accent]: theme.accent,
[themeVars.accentDim]: theme.accentDim,
[themeVars.error]: theme.error,
[themeVars.warning]: theme.warning,
[themeVars.success]: theme.success,
[themeVars.info]: theme.info,
},
});
});

View file

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

View file

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

View file

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

View file

@ -1,121 +1,90 @@
import Timeframe from "~/types/timeframe"; import Timeframe from "~/types/timeframe"
const timeframeToInterval = (timeframe: Timeframe): string => { const timeframeToInterval = (timeframe: Timeframe): string => {
switch (timeframe) { switch (timeframe) {
case Timeframe.Day: case Timeframe.Day:
return "1 day"; return "1 day"
case Timeframe.Week: case Timeframe.Week:
return "1 week"; return "1 week"
case Timeframe.Month: case Timeframe.Month:
return "1 month"; return "1 month"
case Timeframe.Year: case Timeframe.Year:
return "1 year"; return "1 year"
case Timeframe.AllTime: case Timeframe.AllTime:
return "99 years"; return "99 years"
}
};
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 }; 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 = { type hsl = {
h: number; h: number,
s: number; s: number,
l: number; l: number,
}; }
const hexToHSL = (hex: string): hsl => { const hexToHSL = (hex: string): hsl => {
let r = 0, let r = 0, g = 0, b = 0;
g = 0, hex = hex.replace('#', '');
b = 0;
hex = hex.replace("#", "");
if (hex.length === 3) { if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16); r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16); g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16); b = parseInt(hex[2] + hex[2], 16);
} else if (hex.length === 6) { } else if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16); r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16); g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 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 { r /= 255;
h: Math.round(h * 360), g /= 255;
s: Math.round(s * 100), b /= 255;
l: Math.round(l * 100),
}; 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) => { export {hexToHSL}
if (!seconds) return ""; export type {hsl}
let minutes = Math.floor(seconds / 60);
return `${minutes} minutes listened`;
};
export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
export type { hsl };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,374 +0,0 @@
-- 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;

54
docs/README.md Normal file
View file

@ -0,0 +1,54 @@
# Starlight Starter Kit: Basics
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
yarn create astro@latest -- --template starlight
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── content/
│ │ ├── docs/
│ └── content.config.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
```
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `yarn install` | Installs dependencies |
| `yarn dev` | Starts local dev server at `localhost:4321` |
| `yarn build` | Build your production site to `./dist/` |
| `yarn preview` | Preview your build locally, before deploying |
| `yarn astro ...` | Run CLI commands like `astro add`, `astro check` |
| `yarn astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [Starlights docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

View file

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

View file

@ -12,7 +12,8 @@ Koito currently supports the following sources to import data from:
:::note :::note
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
means that artist aliases will not be automatically fetched for imported artists. You can also use 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
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and [your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster. [disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
::: :::

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