diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5ed451 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +KOITO_ALLOWED_HOSTS=* +KOITO_LOG_LEVEL=debug +KOITO_CONFIG_DIR=test_config_dir +KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable +TZ=Etc/UTC diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..fbf205d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +ko_fi: gabehf diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..677ad0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug] " +labels: bug +assignees: gabehf + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots/Logs** +If applicable, add screenshots to help explain your problem and any relevant logs with `KOITO_LOG_LEVEL=debug` if possible. + +**Version (please complete the following information):** + - Koito version: v0.0.X + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..34ecdf9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Enhancement] " +labels: enhancement +assignees: gabehf + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Why would you like this feature to be added?** +A clear description of why this feature might be useful for you will help inform development decisions. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/astro.yml b/.github/workflows/astro.yml index 71ee903..428b7b8 100644 --- a/.github/workflows/astro.yml +++ b/.github/workflows/astro.yml @@ -1,14 +1,15 @@ name: Deploy to GitHub Pages on: - # Trigger the workflow every time you push to the `main` branch - # Using a different branch name? Replace `main` with your branch’s name push: - branches: [main] - # Allows you to run this workflow manually from the Actions tab on GitHub. + tags: + - "v*" + paths: + - "docs/**" + - ".github/workflows/**" + workflow_dispatch: -# Allow this job to clone the repo and create a page deployment permissions: contents: read pages: write @@ -23,9 +24,9 @@ jobs: - name: Install, build, and upload your site output uses: withastro/action@v4 with: - path: ./docs # The root location of your Astro project inside the repository. (optional) - node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional) - package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional) + path: ./docs # The root location of your Astro project inside the repository. (optional) + node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 22. (optional) + package-manager: yarn@1.22.22 # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional) deploy: needs: build @@ -36,4 +37,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0df4d53..466a4f6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,68 +11,65 @@ name: Publish Docker image on: push: - branches: [main] tags: - - 'v*' + - "v*" + branches: + - main + paths-ignore: + - "docs/**" + - "README.md" + + workflow_dispatch: jobs: test: name: Go Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - - name: Install libvips - run: | - sudo apt-get update - sudo apt-get install -y libvips-dev + - name: Install libvips + run: | + sudo apt-get update + sudo apt-get install -y libvips-dev - - name: Verify libvips install - run: vips --version + - name: Verify libvips install + run: vips --version - - name: Build - run: go build -v ./... + - name: Build + run: go build -v ./... - - name: Test - uses: robherley/go-test-action@v0 + - name: Test + uses: robherley/go-test-action@v0 push_to_registry: - name: Push Docker image to Docker Hub + name: Push Docker image to Docker Hub (release) + if: startsWith(github.ref, 'refs/tags/') needs: test runs-on: ubuntu-latest - permissions: - packages: write - contents: read - attestations: write - id-token: write steps: - - name: Check out the repo - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Log in to Docker Hub - uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: gabehf/koito + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Extract tag version - id: extract_version run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Build and push Docker image + - name: Build and push release image id: push - uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile @@ -82,10 +79,34 @@ jobs: gabehf/koito:${{ env.KOITO_VERSION }} build-args: | KOITO_VERSION=${{ env.KOITO_VERSION }} + platforms: linux/amd64,linux/arm64 - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v2 + push_dev: + name: Push Docker image (dev branch) + if: github.ref == 'refs/heads/main' + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 with: - subject-name: index.docker.io/gabehf/koito - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push dev image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + gabehf/koito:dev + gabehf/koito:dev-${{ github.sha }} + build-args: | + KOITO_VERSION=dev + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f37ea6a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test + +on: + pull_request: + branches: + - main + +jobs: + test: + name: Go Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install libvips + run: | + sudo apt-get update + sudo apt-get install -y libvips-dev + + - name: Verify libvips install + run: vips --version + + - name: Build + run: go build -v ./... + + - name: Test + uses: robherley/go-test-action@v0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..083bb78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +test_config_dir +.env diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 1a3357e..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,14 +0,0 @@ -# v0.0.3 -## Features -- Delete listens from the UI - -## Enhancements -- Better behaved mobile UI -- Search now returns 8 items per category instead of 5 - -## Fixes -- Many mobile UI fixes - -## Updates -- Refuses a config that changes the MusicBrainz rate limit while using the official MusicBrainz URL -- Warns when enabling ListenBrainz relay with missing configuration \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3c95c9d..72fd522 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY ./client . RUN yarn run build -FROM golang:1.23 AS backend +FROM golang:1.24 AS backend ARG KOITO_VERSION ENV CGO_ENABLED=1 diff --git a/Makefile b/Makefile index 78c1fb0..99455ac 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ +ifneq (,$(wildcard ./.env)) + include .env + export +endif + .PHONY: all test clean client postgres.schemadump: @@ -10,7 +15,10 @@ postgres.schemadump: -v --dbname="koitodb" -f "/tmp/dump/schema.sql" postgres.run: - docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres + docker run --name koito-db -p 5432:5432 -v koito_dev_db:/var/lib/postgresql -e POSTGRES_PASSWORD=secret -d postgres + +postgres.run-scratch: + docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres postgres.start: docker start koito-db @@ -18,8 +26,17 @@ postgres.start: postgres.stop: docker stop koito-db -api.debug: - KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go +postgres.remove: + docker stop koito-db && docker rm koito-db + +postgres.remove-scratch: + docker stop koito-scratch && docker rm koito-scratch + +api.debug: postgres.start + go run cmd/api/main.go + +api.scratch: postgres.run-scratch + KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go api.test: go test ./... -timeout 60s @@ -33,7 +50,7 @@ client.dev: docs.dev: cd docs && yarn dev -client.deps: +client.deps: cd client && yarn install client.build: client.deps @@ -41,4 +58,4 @@ client.build: client.deps test: api.test -build: api.build client.build \ No newline at end of file +build: api.build client.build diff --git a/README.md b/README.md index 2bc10ce..b51b2ff 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,21 @@ -# 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".* + +
+ +
+ + [![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/gabehf) + +
Koito is a modern, themeable ListenBrainz-compatible scrobbler for self-hosters who want control over their data and insights into their listening habits. It supports relaying to other compatible scrobblers, so you can try it safely without replacing your current setup. -> This project is currently pre-release, and therefore you can expect rapid development and some bugs. If you don't want to replace your current scrobbler +> This project is under active development and still considered "unstable", and therefore you can expect some bugs. If you don't want to replace your current scrobbler with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible scrobbler. This is what I've been doing for the entire development of this app and it hasn't failed me once. Or, you can always use something like [multi-scrobbler](https://github.com/FoxxMD/multi-scrobbler). @@ -23,8 +35,9 @@ You can view my public instance with my listening data at https://koito.mnrva.de ## Screenshots ![screenshot one](assets/screenshot1.png) -![screenshot two](assets/screenshot2.png) -![screenshot three](assets/screenshot3.png) +image +image + ## Installation @@ -75,6 +88,16 @@ There are currently some known issues that I am actively working on, in addition If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next. +## Star History + + + + + + Star History Chart + + + ## Albums that fueled development + notes More relevant here than any of my other projects... @@ -84,5 +107,4 @@ Not just during development, you can see my complete listening data on my [live #### Random notes - I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to... -- It's funny how you can see the days in my listening history when I was just working on this project because they have way more listens than other days. -- About 50% of the reason I built this was minor/not-so-minor greivances with Maloja. Could I have just contributed to Maloja? Maybe, but I like building stuff and I like Koito's UI a lot more anyways. \ No newline at end of file +- 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. diff --git a/assets/Jost-Regular.ttf b/assets/Jost-Regular.ttf new file mode 100644 index 0000000..3269563 Binary files /dev/null and b/assets/Jost-Regular.ttf differ diff --git a/assets/LeagueSpartan-Medium.ttf b/assets/LeagueSpartan-Medium.ttf new file mode 100644 index 0000000..c701d88 Binary files /dev/null and b/assets/LeagueSpartan-Medium.ttf differ diff --git a/client/api/api.ts b/client/api/api.ts index ec79aa0..bd2430b 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -1,287 +1,501 @@ interface getItemsArgs { - limit: number, - period: string, - page: number, - artist_id?: number, - album_id?: number, - track_id?: number + limit: number; + period: string; + page: number; + artist_id?: number; + album_id?: number; + track_id?: number; } interface getActivityArgs { - step: string - range: number - month: number - year: number - artist_id: number - album_id: number - track_id: number + step: string; + range: number; + month: number; + year: number; + artist_id: number; + album_id: number; + track_id: number; +} +interface timeframe { + week?: number; + month?: number; + year?: number; + from?: number; + to?: number; + period?: string; +} +interface getInterestArgs { + buckets: number; + artist_id: number; + album_id: number; + track_id: number; } -function getLastListens(args: getItemsArgs): Promise> { - 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>) +async function handleJson(r: Response): Promise { + if (!r.ok) { + const err = await r.json(); + throw Error(err.error); + } + return (await r.json()) as T; +} +async function getLastListens( + args: getItemsArgs +): Promise> { + 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>(r); } -function getTopTracks(args: getItemsArgs): Promise> { - if (args.artist_id) { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise>) - } 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>) - } else { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise>) - } +async function getTopTracks( + args: getItemsArgs +): Promise>> { + let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`; + + if (args.artist_id) url += `&artist_id=${args.artist_id}`; + else if (args.album_id) url += `&album_id=${args.album_id}`; + + const r = await fetch(url); + return handleJson>>(r); } -function getTopAlbums(args: getItemsArgs): Promise> { - const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` - if (args.artist_id) { - return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise>) - } else { - return fetch(baseUri).then(r => r.json() as Promise>) - } +async function getTopAlbums( + args: getItemsArgs +): Promise>> { + let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`; + if (args.artist_id) url += `&artist_id=${args.artist_id}`; + + const r = await fetch(url); + return handleJson>>(r); } -function getTopArtists(args: getItemsArgs): Promise> { - const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` - return fetch(baseUri).then(r => r.json() as Promise>) +async function getTopArtists( + args: getItemsArgs +): Promise>> { + const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`; + const r = await fetch(url); + return handleJson>>(r); } -function getActivity(args: getActivityArgs): Promise { - 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) +async function getActivity( + args: getActivityArgs +): Promise { + 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(r); } -function getStats(period: string): Promise { - return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise) +async function getInterest(args: getInterestArgs): Promise { + const r = await fetch( + `/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}` + ); + return handleJson(r); +} + +async function getStats(period: string): Promise { + const r = await fetch(`/apis/web/v1/stats?period=${period}`); + + return handleJson(r); } function search(q: string): Promise { - return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise) + q = encodeURIComponent(q); + return fetch(`/apis/web/v1/search?q=${q}`).then( + (r) => r.json() as Promise + ); } function imageUrl(id: string, size: string) { - if (!id) { - id = 'default' - } - return `/images/${size}/${id}` + if (!id) { + id = "default"; + } + return `/images/${size}/${id}`; } function replaceImage(form: FormData): Promise { - return fetch(`/apis/web/v1/replace-image`, { - method: "POST", - body: form, - }) + return fetch(`/apis/web/v1/replace-image`, { + method: "POST", + body: form, + }); } function mergeTracks(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { - method: "POST", - }) + return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { + method: "POST", + }); } -function mergeAlbums(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, { - method: "POST", - }) +function mergeAlbums( + from: number, + to: number, + replaceImage: boolean +): Promise { + return fetch( + `/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, + { + method: "POST", + } + ); } -function mergeArtists(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, { - method: "POST", - }) +function mergeArtists( + from: number, + to: number, + replaceImage: boolean +): Promise { + return fetch( + `/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, + { + method: "POST", + } + ); } -function login(username: string, password: string, remember: boolean): Promise { - return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, { - method: "POST", - }) +function login( + username: string, + password: string, + remember: boolean +): Promise { + 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 { - return fetch(`/apis/web/v1/logout`, { - method: "POST", - }) + return fetch(`/apis/web/v1/logout`, { + method: "POST", + }); +} + +function getCfg(): Promise { + return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise); +} + +function submitListen(id: string, ts: Date): Promise { + 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 { - return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise) + return fetch(`/apis/web/v1/user/apikeys`).then( + (r) => r.json() as Promise + ); } const createApiKey = async (label: string): Promise => { - const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, { - method: "POST" - }); - if (!r.ok) { - let errorMessage = `error: ${r.status}`; - try { - const errorData: ApiError = await r.json(); - if (errorData && typeof errorData.error === 'string') { - errorMessage = errorData.error; - } - } catch (e) { - console.error("unexpected api error:", e); - } - throw new Error(errorMessage); + const form = new URLSearchParams(); + form.append("label", label); + const r = await fetch(`/apis/web/v1/user/apikeys`, { + method: "POST", + body: form, + }); + if (!r.ok) { + let errorMessage = `error: ${r.status}`; + try { + const errorData: ApiError = await r.json(); + if (errorData && typeof errorData.error === "string") { + errorMessage = errorData.error; + } + } catch (e) { + console.error("unexpected api error:", e); } - const data: ApiKey = await r.json(); - return data; + throw new Error(errorMessage); + } + const data: ApiKey = await r.json(); + return data; }; function deleteApiKey(id: number): Promise { - return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { - method: "DELETE" - }) + return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { + method: "DELETE", + }); } function updateApiKeyLabel(id: number, label: string): Promise { - return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, { - method: "PATCH" - }) + const form = new URLSearchParams(); + form.append("id", String(id)); + form.append("label", label); + return fetch(`/apis/web/v1/user/apikeys`, { + method: "PATCH", + body: form, + }); } function deleteItem(itemType: string, id: number): Promise { - return fetch(`/apis/web/v1/${itemType}?id=${id}`, { - method: "DELETE" - }) + return fetch(`/apis/web/v1/${itemType}?id=${id}`, { + method: "DELETE", + }); } function updateUser(username: string, password: string) { - return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, { - method: "PATCH" - }) + const form = new URLSearchParams(); + form.append("username", username); + form.append("password", password); + return fetch(`/apis/web/v1/user`, { + method: "PATCH", + body: form, + }); } function getAliases(type: string, id: number): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise) + return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then( + (r) => r.json() as Promise + ); } -function createAlias(type: string, id: number, alias: string): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { - method: 'POST' - }) +function createAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases`, { + method: "POST", + body: form, + }); } -function deleteAlias(type: string, id: number, alias: string): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { - method: "DELETE" - }) +function deleteAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases/delete`, { + method: "POST", + body: form, + }); } -function setPrimaryAlias(type: string, id: number, alias: string): Promise { - return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, { - method: "POST" - }) +function setPrimaryAlias( + type: string, + id: number, + alias: string +): Promise { + 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 { + 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 { + return fetch(`/apis/web/v1/album?id=${id}`).then( + (r) => r.json() as Promise + ); } function deleteListen(listen: Listen): Promise { - const ms = new Date(listen.time).getTime() - const unix= Math.floor(ms / 1000); - return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { - method: "DELETE" - }) + const ms = new Date(listen.time).getTime(); + const unix = Math.floor(ms / 1000); + return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { + method: "DELETE", + }); +} +function getExport() {} + +function getNowPlaying(): Promise { + return fetch("/apis/web/v1/now-playing").then((r) => r.json()); +} + +async function getRewindStats(args: timeframe): Promise { + 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(r); } export { - getLastListens, - getTopTracks, - getTopAlbums, - getTopArtists, - getActivity, - getStats, - search, - replaceImage, - mergeTracks, - mergeAlbums, - mergeArtists, - imageUrl, - login, - logout, - deleteItem, - updateUser, - getAliases, - createAlias, - deleteAlias, - setPrimaryAlias, - getApiKeys, - createApiKey, - deleteApiKey, - updateApiKeyLabel, - deleteListen, -} + getLastListens, + getTopTracks, + getTopAlbums, + getTopArtists, + getActivity, + getInterest, + getStats, + search, + replaceImage, + mergeTracks, + mergeAlbums, + mergeArtists, + imageUrl, + login, + logout, + getCfg, + deleteItem, + updateUser, + getAliases, + createAlias, + deleteAlias, + setPrimaryAlias, + updateMbzId, + getApiKeys, + createApiKey, + deleteApiKey, + updateApiKeyLabel, + deleteListen, + getAlbum, + getExport, + submitListen, + getNowPlaying, + getRewindStats, +}; type Track = { - id: number - title: string - artists: SimpleArtists[] - listen_count: number - image: string - album_id: number - musicbrainz_id: string -} + id: number; + title: string; + artists: SimpleArtists[]; + listen_count: number; + image: string; + album_id: number; + musicbrainz_id: string; + time_listened: number; + first_listen: number; + all_time_rank: number; +}; type Artist = { - id: number - name: string - image: string, - aliases: string[] - listen_count: number - musicbrainz_id: string -} + id: number; + name: string; + image: string; + aliases: string[]; + listen_count: number; + musicbrainz_id: string; + time_listened: number; + first_listen: number; + is_primary: boolean; + all_time_rank: number; +}; type Album = { - id: number, - title: string - image: string - listen_count: number - is_various_artists: boolean - artists: SimpleArtists[] - musicbrainz_id: string -} + id: number; + title: string; + image: string; + listen_count: number; + is_various_artists: boolean; + artists: SimpleArtists[]; + musicbrainz_id: string; + time_listened: number; + first_listen: number; + all_time_rank: number; +}; type Alias = { - id: number - alias: string - source: string - is_primary: boolean -} + id: number; + alias: string; + source: string; + is_primary: boolean; +}; type Listen = { - time: string, - track: Track, -} + time: string; + track: Track; +}; type PaginatedResponse = { - items: T[], - total_record_count: number, - has_next_page: boolean, - current_page: number, - items_per_page: number, -} + items: T[]; + total_record_count: number; + has_next_page: boolean; + current_page: number; + items_per_page: number; +}; +type Ranked = { + item: T; + rank: number; +}; type ListenActivityItem = { - start_time: Date, - listens: number -} + start_time: Date; + listens: number; +}; +type InterestBucket = { + bucket_start: Date; + bucket_end: Date; + listen_count: number; +}; type SimpleArtists = { - name: string - id: number -} + name: string; + id: number; +}; type Stats = { - listen_count: number - track_count: number - album_count: number - artist_count: number - hours_listened: number -} + listen_count: number; + track_count: number; + album_count: number; + artist_count: number; + minutes_listened: number; +}; type SearchResponse = { - albums: Album[] - artists: Artist[] - tracks: Track[] -} + albums: Album[]; + artists: Artist[]; + tracks: Track[]; +}; type User = { - id: number - username: string - role: 'user' | 'admin' -} + id: number; + username: string; + role: "user" | "admin"; +}; type ApiKey = { - id: number - key: string - label: string - created_at: Date -} + id: number; + key: string; + label: string; + created_at: Date; +}; type ApiError = { - error: string -} + error: string; +}; +type Config = { + default_theme: string; +}; +type NowPlaying = { + currently_playing: boolean; + track: Track; +}; +type RewindStats = { + title: string; + top_artists: Ranked[]; + top_albums: Ranked[]; + top_tracks: Ranked[]; + minutes_listened: number; + avg_minutes_listened_per_day: number; + plays: number; + avg_plays_per_day: number; + unique_tracks: number; + unique_albums: number; + unique_artists: number; + new_tracks: number; + new_albums: number; + new_artists: number; +}; export type { - getItemsArgs, - getActivityArgs, - Track, - Artist, - Album, - Listen, - SearchResponse, - PaginatedResponse, - ListenActivityItem, - User, - Alias, - ApiKey, - ApiError -} + getItemsArgs, + getActivityArgs, + getInterestArgs, + Track, + Artist, + Album, + Listen, + SearchResponse, + PaginatedResponse, + Ranked, + ListenActivityItem, + InterestBucket, + User, + Alias, + ApiKey, + ApiError, + Config, + NowPlaying, + Stats, + RewindStats, +}; diff --git a/client/app/app.css b/client/app/app.css index f0b786e..15cfbc0 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -1,59 +1,56 @@ -@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap"); @import "tailwindcss"; @theme { --font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --animate-fade-in-scale: fade-in-scale 0.1s ease forwards; - --animate-fade-out-scale: fade-out-scale 0.1s ease forwards; - - @keyframes fade-in-scale { - 0% { - opacity: 0; - transform: scale(0.95); - } - 100% { - opacity: 1; - transform: scale(1); - } + --animate-fade-in-scale: fade-in-scale 0.1s ease forwards; + --animate-fade-out-scale: fade-out-scale 0.1s ease forwards; + + @keyframes fade-in-scale { + 0% { + opacity: 0; + transform: scale(0.95); } - - @keyframes fade-out-scale { - 0% { - opacity: 1; - transform: scale(1); - } - 100% { - opacity: 0; - transform: scale(0.95); - } + 100% { + opacity: 1; + transform: scale(1); } - - --animate-fade-in: fade-in 0.1s ease forwards; - --animate-fade-out: fade-out 0.1s ease forwards; - - @keyframes fade-in { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } + } + + @keyframes fade-out-scale { + 0% { + opacity: 1; + transform: scale(1); } - - @keyframes fade-out { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } + 100% { + opacity: 0; + transform: scale(0.95); } - + } + + --animate-fade-in: fade-in 0.1s ease forwards; + --animate-fade-out: fade-out 0.1s ease forwards; + + @keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + + @keyframes fade-out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } + } } - - :root { --header-xl: 36px; --header-lg: 28px; @@ -61,20 +58,21 @@ --header-sm: 16px; --header-xl-weight: 600; --header-weight: 600; + --header-line-height: 3rem; } @media (min-width: 60rem) { :root { --header-xl: 78px; - --header-lg: 28px; + --header-lg: 36px; --header-md: 22px; --header-sm: 16px; --header-xl-weight: 600; --header-weight: 600; + --header-line-height: 1.3em; } } - html, body { background-color: var(--color-bg); @@ -102,21 +100,24 @@ h1 { font-family: "League Spartan"; font-weight: var(--header-weight); font-size: var(--header-xl); + line-height: var(--header-line-height); } h2 { + font-family: "League Spartan"; + font-weight: var(--header-weight); + font-size: var(--header-lg); +} +h3 { font-family: "League Spartan"; font-weight: var(--header-weight); font-size: var(--header-md); margin-bottom: 0.5em; } -h3 { +h4 { font-family: "League Spartan"; font-size: var(--header-sm); font-weight: var(--header-weight); } -h4 { - font-size: var(--header-md); -} .header-font { font-family: "League Spartan"; } @@ -132,23 +133,21 @@ h4 { text-decoration: underline; } -input[type="text"] { +input[type="text"], +input[type="password"], +textarea { border: 1px solid var(--color-bg); } -input[type="text"]:focus { - outline: none; - border: 1px solid var(--color-fg-tertiary); +input[type="checkbox"] { + height: fit-content; } -input[type="password"] { - border: 1px solid var(--color-bg); -} -input[type="password"]:focus { - outline: none; - border: 1px solid var(--color-fg-tertiary); -} -input[type="checkbox"]:focus { - outline: none; - border: 1px solid var(--color-fg-tertiary); +input:focus-visible, +button:focus-visible, +a:focus-visible, +select:focus-visible, +textarea:focus-visible { + border-color: transparent; + outline: 2px solid var(--color-fg-tertiary); } button:hover { @@ -190,4 +189,4 @@ button.default[disabled]:hover { } button.default:hover { color: var(--color-fg-secondary); -} \ No newline at end of file +} diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 818d6e3..0d39e2c 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -1,186 +1,196 @@ -import { useQuery } from "@tanstack/react-query" -import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api" -import Popup from "./Popup" -import { useEffect, useState } from "react" -import { useTheme } from "~/hooks/useTheme" -import ActivityOptsSelector from "./ActivityOptsSelector" +import { useQuery } from "@tanstack/react-query"; +import { + getActivity, + type getActivityArgs, + type ListenActivityItem, +} from "api/api"; +import Popup from "./Popup"; +import { useState } from "react"; +import { useTheme } from "~/hooks/useTheme"; +import ActivityOptsSelector from "./ActivityOptsSelector"; +import type { Theme } from "~/styles/themes.css"; -function getPrimaryColor(): string { - const value = getComputedStyle(document.documentElement) - .getPropertyValue('--color-primary') - .trim(); +function getPrimaryColor(theme: Theme): string { + const value = theme.primary; + const rgbMatch = value.match( + /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/ + ); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join(""); + } - const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); - if (rgbMatch) { - const [, r, g, b] = rgbMatch.map(Number); - return ( - '#' + - [r, g, b] - .map((n) => n.toString(16).padStart(2, '0')) - .join('') - ); - } - - return value; + return value; } - interface Props { - step?: string - range?: number - month?: number - year?: number - artistId?: number - albumId?: number - trackId?: number - configurable?: boolean - autoAdjust?: boolean + step?: string; + range?: number; + month?: number; + year?: number; + artistId?: number; + albumId?: number; + trackId?: number; + configurable?: boolean; + autoAdjust?: boolean; } export default function ActivityGrid({ - step = 'day', - range = 182, - month = 0, - year = 0, - artistId = 0, - albumId = 0, - trackId = 0, - configurable = false, - autoAdjust = false, - }: Props) { + step = "day", + range = 182, + month = 0, + year = 0, + artistId = 0, + albumId = 0, + trackId = 0, + configurable = false, +}: Props) { + const [stepState, setStep] = useState(step); + const [rangeState, setRange] = useState(range); - const [color, setColor] = useState(getPrimaryColor()) - const [stepState, setStep] = useState(step) - const [rangeState, setRange] = useState(range) - - const { isPending, isError, data, error } = useQuery({ - queryKey: [ - 'listen-activity', - { - step: stepState, - range: rangeState, - month: month, - year: year, - artist_id: artistId, - album_id: albumId, - track_id: trackId - }, - ], - queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs), - }); + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "listen-activity", + { + step: stepState, + range: rangeState, + month: month, + year: year, + artist_id: artistId, + album_id: albumId, + track_id: trackId, + }, + ], + queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs), + }); + const { theme } = useTheme(); + const color = getPrimaryColor(theme); - const { theme } = useTheme(); - useEffect(() => { - const raf = requestAnimationFrame(() => { - const color = getPrimaryColor() - setColor(color); - }); - - return () => cancelAnimationFrame(raf); - }, [theme]); - - if (isPending) { - return ( -
-

Activity

-

Loading...

-
- ) - } - if (isError) return

Error:{error.message}

- - // from https://css-tricks.com/snippets/javascript/lighten-darken-color/ - function LightenDarkenColor(hex: string, lum: number) { - // validate hex string - hex = String(hex).replace(/[^0-9a-f]/gi, ''); - if (hex.length < 6) { - hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; - } - lum = lum || 0; - - // convert to decimal and change luminosity - var rgb = "#", c, i; - for (i = 0; i < 3; i++) { - c = parseInt(hex.substring(i*2,(i*2)+2), 16); - c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16); - rgb += ("00"+c).substring(c.length); - } - - return rgb; - } - - const getDarkenAmount = (v: number, t: number): number => { - - if (autoAdjust) { - // automatically adjust the target value based on step - // the smartest way to do this would be to have the api return the - // highest value in the range. too bad im not smart - switch (stepState) { - case 'day': - t = 10 - break; - case 'week': - t = 20 - break; - case 'month': - t = 50 - break; - case 'year': - t = 100 - break; - } - } - - v = Math.min(v, t) - if (theme === "pearl") { - // special case for the only light theme lol - // could be generalized by pragmatically comparing the - // lightness of the bg vs the primary but eh - return ((t-v) / t) - } else { - return ((v-t) / t) * .8 - } - } - - return (
-

Activity

- {configurable ? ( - - ) : ( - '' - )} -
- {data.map((item) => ( -
- -
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)'}`} - >
-
-
- ))} -
-
- + if (isPending) { + return ( +
+

Activity

+

Loading...

+
); + } else if (isError) { + return ( +
+

Activity

+

Error: {error.message}

+
+ ); + } + + // from https://css-tricks.com/snippets/javascript/lighten-darken-color/ + function LightenDarkenColor(hex: string, lum: number) { + // validate hex string + hex = String(hex).replace(/[^0-9a-f]/gi, ""); + if (hex.length < 6) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + lum = lum || 0; + + // convert to decimal and change luminosity + var rgb = "#", + c, + i; + for (i = 0; i < 3; i++) { + c = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16); + rgb += ("00" + c).substring(c.length); + } + + return rgb; + } + + const getDarkenAmount = (v: number, t: number): number => { + // really ugly way to just check if this is for all items and not a specific item. + // is it jsut better to just pass the target in as a var? probably. + const adjustment = + artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1; + + // automatically adjust the target value based on step + // the smartest way to do this would be to have the api return the + // highest value in the range. too bad im not smart + switch (stepState) { + case "day": + t = 10 * adjustment; + break; + case "week": + t = 20 * adjustment; + break; + case "month": + t = 50 * adjustment; + break; + case "year": + t = 100 * adjustment; + break; + } + + v = Math.min(v, t); + return ((v - t) / t) * 0.8; + }; + + const CHUNK_SIZE = 26 * 7; + const chunks = []; + + for (let i = 0; i < data.length; i += CHUNK_SIZE) { + chunks.push(data.slice(i, i + CHUNK_SIZE)); + } + + return ( +
+

Activity

+ {configurable ? ( + + ) : null} + + {chunks.map((chunk, index) => ( +
+ {chunk.map((item) => ( +
+ +
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)" + }`} + >
+
+
+ ))} +
+ ))} +
+ ); } diff --git a/client/app/components/ActivityOptsSelector.tsx b/client/app/components/ActivityOptsSelector.tsx index 213f8a6..26d1357 100644 --- a/client/app/components/ActivityOptsSelector.tsx +++ b/client/app/components/ActivityOptsSelector.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useEffect, useState } from "react"; interface Props { stepSetter: (value: string) => void; @@ -15,18 +16,15 @@ export default function ActivityOptsSelector({ currentRange, disableCache = false, }: Props) { - const stepPeriods = ['day', 'week', 'month', 'year']; - const rangePeriods = [105, 182, 365]; + const stepPeriods = ['day', 'week', 'month']; + const rangePeriods = [105, 182, 364]; + const [collapsed, setCollapsed] = useState(true); - const stepDisplay = (str: string): string => { - return str.split('_').map(w => - w.split('').map((char, index) => - index === 0 ? char.toUpperCase() : char).join('') - ).join(' '); - }; - - const rangeDisplay = (r: number): string => { - return `${r}` + const setMenuOpen = (val: boolean) => { + setCollapsed(val) + if (!disableCache) { + localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val)); + } } const setStep = (val: string) => { @@ -42,56 +40,67 @@ export default function ActivityOptsSelector({ localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val)); } }; - + useEffect(() => { if (!disableCache) { - const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35'); - if (cachedRange) { - rangeSetter(cachedRange); - } + // TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default. + const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182'); + if (cachedRange) rangeSetter(cachedRange); const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]); - if (cachedStep) { - stepSetter(cachedStep); - } + if (cachedStep) stepSetter(cachedStep); + const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]); + if (cachedStep) setMenuOpen(cachedConfiguring !== "true"); } - }, []); + }, []); return ( -
-
-

Step:

- {stepPeriods.map((p, i) => ( -
- - - {i !== stepPeriods.length - 1 ? '|' : ''} - -
- ))} -
+
+ -
-

Range:

- {rangePeriods.map((r, i) => ( -
- - - {i !== rangePeriods.length - 1 ? '|' : ''} - +
+
+
+ Step: + {stepPeriods.map((p) => ( + + ))}
- ))} + +
+ Range: + {rangePeriods.map((r) => ( + + ))} +
+
); diff --git a/client/app/components/AlbumDisplay.tsx b/client/app/components/AlbumDisplay.tsx index 6721199..a7f88e4 100644 --- a/client/app/components/AlbumDisplay.tsx +++ b/client/app/components/AlbumDisplay.tsx @@ -2,24 +2,31 @@ import { imageUrl, type Album } from "api/api"; import { Link } from "react-router"; interface Props { - album: Album - size: number + album: Album; + size: number; } export default function AlbumDisplay({ album, size }: Props) { - return ( -
-
- - {album.title} - -
-
- -

{album.title}

- -

{album.listen_count} plays

-
-
- ) -} \ No newline at end of file + return ( +
+
+ + {album.title} + +
+
+ +

{album.title}

+ +

{album.listen_count} plays

+
+
+ ); +} diff --git a/client/app/components/AllTimeStats.tsx b/client/app/components/AllTimeStats.tsx index 0a54daa..6a3ebac 100644 --- a/client/app/components/AllTimeStats.tsx +++ b/client/app/components/AllTimeStats.tsx @@ -1,45 +1,58 @@ -import { useQuery } from "@tanstack/react-query" -import { getStats } from "api/api" +import { useQuery } from "@tanstack/react-query"; +import { getStats, type Stats, type ApiError } from "api/api"; export default function AllTimeStats() { + const { isPending, isError, data, error } = useQuery({ + queryKey: ["stats", "all_time"], + queryFn: ({ queryKey }) => getStats(queryKey[1]), + }); - const { isPending, isError, data, error } = useQuery({ - queryKey: ['stats', 'all_time'], - queryFn: ({ queryKey }) => getStats(queryKey[1]), - }) - - if (isPending) { - return ( -
-

All Time Stats

-

Loading...

-
- ) - } - if (isError) { - return

Error:{error.message}

- } - - const numberClasses = 'header-font font-bold text-xl' + const header = "All time stats"; + if (isPending) { return ( +
+

{header}

+

Loading...

+
+ ); + } else if (isError) { + return ( + <>
-

All Time Stats

-
- {data.hours_listened} Hours Listened -
-
- {data.listen_count} Plays -
-
- {data.artist_count} Artists -
-
- {data.album_count} Albums -
-
- {data.track_count} Tracks -
+

{header}

+

Error: {error.message}

- ) -} \ No newline at end of file + + ); + } + + const numberClasses = "header-font font-bold text-xl"; + + return ( +
+

{header}

+
+ + {data.minutes_listened} + {" "} + Minutes Listened +
+
+ {data.listen_count} Plays +
+
+ {data.track_count} Tracks +
+
+ {data.album_count} Albums +
+
+ {data.artist_count} Artists +
+
+ ); +} diff --git a/client/app/components/ArtistAlbums.tsx b/client/app/components/ArtistAlbums.tsx index c95155a..dda7de8 100644 --- a/client/app/components/ArtistAlbums.tsx +++ b/client/app/components/ArtistAlbums.tsx @@ -1,51 +1,63 @@ -import { useQuery } from "@tanstack/react-query" -import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api" -import { Link } from "react-router" +import { useQuery } from "@tanstack/react-query"; +import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"; +import { Link } from "react-router"; interface Props { - artistId: number - name: string - period: string + artistId: number; + name: string; + period: string; } -export default function ArtistAlbums({artistId, name, period}: Props) { - - const { isPending, isError, data, error } = useQuery({ - queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}], - queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), - }) - - if (isPending) { - return ( -
-

Albums From This Artist

-

Loading...

-
- ) - } - if (isError) { - return ( -
-

Albums From This Artist

-

Error:{error.message}

-
- ) - } +export default function ArtistAlbums({ artistId, name }: Props) { + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "top-albums", + { limit: 99, period: "all_time", artist_id: artistId }, + ], + queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), + }); + if (isPending) { return ( -
-

Albums featuring {name}

-
- {data.items.map((item) => ( - - {item.title} -
-

{item.title}

-

{item.listen_count} play{item.listen_count > 1 ? 's' : ''}

-
- - ))} -
-
- ) -} \ No newline at end of file +
+

Albums From This Artist

+

Loading...

+
+ ); + } + if (isError) { + return ( +
+

Albums From This Artist

+

Error:{error.message}

+
+ ); + } + + return ( +
+

Albums featuring {name}

+
+ {data.items.map((item) => ( + + {item.item.title} +
+

{item.item.title}

+

+ {item.item.listen_count} play + {item.item.listen_count > 1 ? "s" : ""} +

+
+ + ))} +
+
+ ); +} diff --git a/client/app/components/ImageDropHandler.tsx b/client/app/components/ImageDropHandler.tsx index 8557ff9..9e686ea 100644 --- a/client/app/components/ImageDropHandler.tsx +++ b/client/app/components/ImageDropHandler.tsx @@ -3,11 +3,10 @@ import { useEffect } from 'react'; interface Props { itemType: string, - id: number, onComplete: Function } -export default function ImageDropHandler({ itemType, id, onComplete }: Props) { +export default function ImageDropHandler({ itemType, onComplete }: Props) { useEffect(() => { const handleDragOver = (e: DragEvent) => { console.log('dragover!!') @@ -25,7 +24,11 @@ export default function ImageDropHandler({ itemType, id, onComplete }: Props) { const formData = new FormData(); formData.append('image', imageFile); - formData.append(itemType.toLowerCase()+'_id', String(id)) + const pathname = window.location.pathname; + const segments = pathname.split('/'); + const filteredSegments = segments.filter(segment => segment !== ''); + const lastSegment = filteredSegments[filteredSegments.length - 1]; + formData.append(itemType.toLowerCase()+'_id', lastSegment) replaceImage(formData).then((r) => { if (r.status >= 200 && r.status < 300) { onComplete() diff --git a/client/app/components/InterestGraph.tsx b/client/app/components/InterestGraph.tsx new file mode 100644 index 0000000..9e2baaf --- /dev/null +++ b/client/app/components/InterestGraph.tsx @@ -0,0 +1,112 @@ +import { useQuery } from "@tanstack/react-query"; +import { getInterest, type getInterestArgs } from "api/api"; +import { useTheme } from "~/hooks/useTheme"; +import type { Theme } from "~/styles/themes.css"; +import { Area, AreaChart } from "recharts"; +import { RechartsDevtools } from "@recharts/devtools"; + +function getPrimaryColor(theme: Theme): string { + const value = theme.primary; + const rgbMatch = value.match( + /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/ + ); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join(""); + } + + return value; +} +interface Props { + buckets?: number; + artistId?: number; + albumId?: number; + trackId?: number; +} + +export default function InterestGraph({ + buckets = 16, + artistId = 0, + albumId = 0, + trackId = 0, +}: Props) { + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "interest", + { + buckets: buckets, + artist_id: artistId, + album_id: albumId, + track_id: trackId, + }, + ], + queryFn: ({ queryKey }) => getInterest(queryKey[1] as getInterestArgs), + }); + + const { theme } = useTheme(); + const color = getPrimaryColor(theme); + + if (isPending) { + return ( +
+

Interest over time

+

Loading...

+
+ ); + } else if (isError) { + return ( +
+

Interest over time

+

Error: {error.message}

+
+ ); + } + + // 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 ( +
+

Interest over time

+ + + + + + + + + + +
+ ); +} diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index 2bc1cc3..ace86fd 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -1,106 +1,156 @@ -import { useState } from "react" -import { useQuery } from "@tanstack/react-query" -import { timeSince } from "~/utils/utils" -import ArtistLinks from "./ArtistLinks" -import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api" -import { Link } from "react-router" +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { timeSince } from "~/utils/utils"; +import ArtistLinks from "./ArtistLinks"; +import { + deleteListen, + getLastListens, + getNowPlaying, + type getItemsArgs, + type Listen, + type Track, +} from "api/api"; +import { Link } from "react-router"; +import { useAppContext } from "~/providers/AppProvider"; interface Props { - limit: number - artistId?: Number - albumId?: Number - trackId?: number - hideArtists?: boolean + limit: number; + artistId?: Number; + albumId?: Number; + trackId?: number; + hideArtists?: boolean; + showNowPlaying?: boolean; } export default function LastPlays(props: Props) { - const { isPending, isError, data, error } = useQuery({ - queryKey: ['last-listens', { - limit: props.limit, - period: 'all_time', - artist_id: props.artistId, - album_id: props.albumId, - track_id: props.trackId - }], - queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), - }) + const { user } = useAppContext(); + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "last-listens", + { + limit: props.limit, + period: "all_time", + artist_id: props.artistId, + album_id: props.albumId, + track_id: props.trackId, + }, + ], + queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), + }); + const { data: npData } = useQuery({ + queryKey: ["now-playing"], + queryFn: () => getNowPlaying(), + }); - const [items, setItems] = useState(null) + const header = "Last played"; - const handleDelete = async (listen: Listen) => { - if (!data) return - try { - const res = await deleteListen(listen) - if (res.ok || (res.status >= 200 && res.status < 300)) { - setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) - } else { - console.error("Failed to delete listen:", res.status) - } - } catch (err) { - console.error("Error deleting listen:", err) - } + const [items, setItems] = useState(null); + + const handleDelete = async (listen: Listen) => { + if (!data) return; + try { + const res = await deleteListen(listen); + if (res.ok || (res.status >= 200 && res.status < 300)) { + setItems((prev) => + (prev ?? data.items).filter((i) => i.time !== listen.time) + ); + } else { + console.error("Failed to delete listen:", res.status); + } + } catch (err) { + console.error("Error deleting listen:", err); } + }; - if (isPending) { - return ( -
-

Last Played

-

Loading...

-
- ) - } - if (isError) { - return

Error: {error.message}

- } - - const listens = items ?? data.items - - let params = '' - params += props.artistId ? `&artist_id=${props.artistId}` : '' - params += props.albumId ? `&album_id=${props.albumId}` : '' - params += props.trackId ? `&track_id=${props.trackId}` : '' - + if (isPending) { return ( -
-

- Last Played -

- - - {listens.map((item) => ( - - - - - - ))} - -
- - - {timeSince(new Date(item.time))} - - {props.hideArtists ? null : ( - <> - –{' '} - - )} - - {item.track.title} - -
-
- ) +
+

{header}

+

Loading...

+
+ ); + } else if (isError) { + return ( +
+

{header}

+

Error: {error.message}

+
+ ); + } + + const listens = items ?? data.items; + + let params = ""; + params += props.artistId ? `&artist_id=${props.artistId}` : ""; + params += props.albumId ? `&album_id=${props.albumId}` : ""; + params += props.trackId ? `&track_id=${props.trackId}` : ""; + + return ( +
+

+ {header} +

+ + + {props.showNowPlaying && npData && npData.currently_playing && ( + + + + + + )} + {listens.map((item) => ( + + + + + + ))} + +
+ Now Playing + + {props.hideArtists ? null : ( + <> + –{" "} + + )} + + {npData.track.title} + +
+ + + {timeSince(new Date(item.time))} + + {props.hideArtists ? null : ( + <> + –{" "} + + )} + + {item.track.title} + +
+
+ ); } diff --git a/client/app/components/SearchResults.tsx b/client/app/components/SearchResults.tsx index c0269e8..0e68c3d 100644 --- a/client/app/components/SearchResults.tsx +++ b/client/app/components/SearchResults.tsx @@ -16,19 +16,19 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) { const selectItem = (title: string, id: number) => { if (selected === id) { setSelected(0) - onSelect({id: id, title: title}) + onSelect({id: 0, title: ''}) } else { setSelected(id) onSelect({id: id, title: title}) } } - if (data === undefined) { + if (!data) { return <> } return (
- { data.artists.length > 0 && + { data.artists && data.artists.length > 0 && <>

Artists

@@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
} - { data.albums.length > 0 && + { data.albums && data.albums.length > 0 && <>

Albums

@@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
} - { data.tracks.length > 0 && + { data.tracks && data.tracks.length > 0 && <>

Tracks

diff --git a/client/app/components/TopAlbums.tsx b/client/app/components/TopAlbums.tsx index 4ae87bd..d8a3b00 100644 --- a/client/app/components/TopAlbums.tsx +++ b/client/app/components/TopAlbums.tsx @@ -1,42 +1,68 @@ -import { useQuery } from "@tanstack/react-query" -import ArtistLinks from "./ArtistLinks" -import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api" -import { Link } from "react-router" -import TopListSkeleton from "./skeletons/TopListSkeleton" -import TopItemList from "./TopItemList" +import { useQuery } from "@tanstack/react-query"; +import ArtistLinks from "./ArtistLinks"; +import { + getTopAlbums, + getTopTracks, + imageUrl, + type getItemsArgs, +} from "api/api"; +import { Link } from "react-router"; +import TopListSkeleton from "./skeletons/TopListSkeleton"; +import TopItemList from "./TopItemList"; interface Props { - limit: number, - period: string, - artistId?: Number + limit: number; + period: string; + artistId?: Number; } -export default function TopAlbums (props: Props) { +export default function TopAlbums(props: Props) { + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "top-albums", + { + limit: props.limit, + period: props.period, + artistId: props.artistId, + page: 0, + }, + ], + queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), + }); - const { isPending, isError, data, error } = useQuery({ - queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }], - queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), - }) - - if (isPending) { - return ( -
-

Top Albums

-

Loading...

-
- ) - } - if (isError) { - return

Error:{error.message}

- } + const header = "Top albums"; + if (isPending) { return ( -
-

Top Albums

-
- - {data.items.length < 1 ? 'Nothing to show' : ''} -
-
- ) -} \ No newline at end of file +
+

{header}

+

Loading...

+
+ ); + } else if (isError) { + return ( +
+

{header}

+

Error: {error.message}

+
+ ); + } + + return ( +
+

+ + {header} + +

+
+ + {data.items.length < 1 ? "Nothing to show" : ""} +
+
+ ); +} diff --git a/client/app/components/TopArtists.tsx b/client/app/components/TopArtists.tsx index 1c7b719..a1db871 100644 --- a/client/app/components/TopArtists.tsx +++ b/client/app/components/TopArtists.tsx @@ -1,43 +1,53 @@ -import { useQuery } from "@tanstack/react-query" -import ArtistLinks from "./ArtistLinks" -import { getTopArtists, imageUrl, type getItemsArgs } from "api/api" -import { Link } from "react-router" -import TopListSkeleton from "./skeletons/TopListSkeleton" -import TopItemList from "./TopItemList" +import { useQuery } from "@tanstack/react-query"; +import ArtistLinks from "./ArtistLinks"; +import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"; +import { Link } from "react-router"; +import TopListSkeleton from "./skeletons/TopListSkeleton"; +import TopItemList from "./TopItemList"; interface Props { - limit: number, - period: string, - artistId?: Number - albumId?: Number + limit: number; + period: string; + artistId?: Number; + albumId?: Number; } -export default function TopArtists (props: Props) { +export default function TopArtists(props: Props) { + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "top-artists", + { limit: props.limit, period: props.period, page: 0 }, + ], + queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs), + }); - const { isPending, isError, data, error } = useQuery({ - queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }], - queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs), - }) - - if (isPending) { - return ( -
-

Top Artists

-

Loading...

-
- ) - } - if (isError) { - return

Error:{error.message}

- } + const header = "Top artists"; + if (isPending) { return ( -
-

Top Artists

-
- - {data.items.length < 1 ? 'Nothing to show' : ''} -
-
- ) -} \ No newline at end of file +
+

{header}

+

Loading...

+
+ ); + } else if (isError) { + return ( +
+

{header}

+

Error: {error.message}

+
+ ); + } + + return ( +
+

+ {header} +

+
+ + {data.items.length < 1 ? "Nothing to show" : ""} +
+
+ ); +} diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 22d307c..4d355b7 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -1,142 +1,171 @@ import { Link, useNavigate } from "react-router"; import ArtistLinks from "./ArtistLinks"; -import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api"; +import { + imageUrl, + type Album, + type Artist, + type Track, + type PaginatedResponse, + type Ranked, +} from "api/api"; type Item = Album | Track | Artist; -interface Props { - data: PaginatedResponse - separators?: ConstrainBoolean - type: "album" | "track" | "artist"; - className?: string, +interface Props> { + data: PaginatedResponse; + separators?: ConstrainBoolean; + ranked?: boolean; + type: "album" | "track" | "artist"; + className?: string; } -export default function TopItemList({ data, separators, type, className }: Props) { +export default function TopItemList>({ + data, + separators, + type, + className, + ranked, +}: Props) { + return ( +
+ {data.items.map((item, index) => { + const key = `${type}-${item.item.id}`; + return ( +
+ +
+ ); + })} +
+ ); +} - return ( -
- {data.items.map((item, index) => { - const key = `${type}-${item.id}`; - return ( -
- -
- ); - })} +function ItemCard({ + item, + type, + rank, + ranked, +}: { + item: Item; + type: "album" | "track" | "artist"; + rank: number; + ranked?: boolean; +}) { + const itemClasses = `flex items-center gap-2`; + + switch (type) { + case "album": { + const album = item as Album; + + return ( +
+ {ranked &&
{rank}
} + + {album.title} + +
+ + {album.title} + +
+ {album.is_various_artists ? ( + Various Artists + ) : ( +
+ +
+ )} +
{album.listen_count} plays
+
- ); -} - -function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) { - - const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)` - - const navigate = useNavigate(); - - const handleItemClick = (type: string, id: number) => { - navigate(`/${type.toLowerCase()}/${id}`); - }; - - const handleArtistClick = (event: React.MouseEvent) => { - // Stop the click from navigating to the album page - event.stopPropagation(); - }; - - // Also stop keyboard events on the inner links from bubbling up - const handleArtistKeyDown = (event: React.KeyboardEvent) => { - event.stopPropagation(); + ); } + case "track": { + const track = item as Track; - switch (type) { - case "album": { - const album = item as Album; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleItemClick("album", album.id); - } - }; - - return ( -
-
handleItemClick("album", album.id)} - onKeyDown={handleKeyDown} - role="link" - tabIndex={0} - aria-label={`View album: ${album.title}`} - style={{ cursor: 'pointer' }} - > - {album.title} -
- {album.title} -
- {album.is_various_artists ? - Various Artists - : -
- -
- } -
{album.listen_count} plays
-
-
-
- ); - } - case "track": { - const track = item as Track; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleItemClick("track", track.id); - } - }; - - return ( -
-
handleItemClick("track", track.id)} - onKeyDown={handleKeyDown} - role="link" - tabIndex={0} - aria-label={`View track: ${track.title}`} - style={{ cursor: 'pointer' }} - > - {track.title} -
- {track.title} -
-
- -
-
{track.listen_count} plays
-
-
-
- ); - } - case "artist": { - const artist = item as Artist; - return ( -
- - {artist.name} -
- {artist.name} -
{artist.listen_count} plays
-
- -
- ); - } + return ( +
+ {ranked &&
{rank}
} + + {track.title} + +
+ + {track.title} + +
+
+ +
+
{track.listen_count} plays
+
+
+ ); } + case "artist": { + const artist = item as Artist; + return ( +
+ {ranked &&
{rank}
} + + {artist.name} +
+ {artist.name} +
+ {artist.listen_count} plays +
+
+ +
+ ); + } + } } diff --git a/client/app/components/TopThreeAlbums.tsx b/client/app/components/TopThreeAlbums.tsx index c5136e4..2a9503d 100644 --- a/client/app/components/TopThreeAlbums.tsx +++ b/client/app/components/TopThreeAlbums.tsx @@ -1,38 +1,43 @@ -import { useQuery } from "@tanstack/react-query" -import { getTopAlbums, type getItemsArgs } from "api/api" -import AlbumDisplay from "./AlbumDisplay" +import { useQuery } from "@tanstack/react-query"; +import { getTopAlbums, type getItemsArgs } from "api/api"; +import AlbumDisplay from "./AlbumDisplay"; interface Props { - period: string - artistId?: Number - vert?: boolean - hideTitle?: boolean + period: string; + artistId?: Number; + vert?: boolean; + hideTitle?: boolean; } - + export default function TopThreeAlbums(props: Props) { + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "top-albums", + { limit: 3, period: props.period, artist_id: props.artistId, page: 0 }, + ], + queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), + }); - const { isPending, isError, data, error } = useQuery({ - queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}], - queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), - }) + if (isPending) { + return

Loading...

; + } + if (isError) { + return

Error:{error.message}

; + } - if (isPending) { - return

Loading...

- } - if (isError) { - return

Error:{error.message}

- } + console.log(data); - console.log(data) - - return ( -
- {!props.hideTitle &&

Top Three Albums

} -
- {data.items.map((item, index) => ( - - ))} -
-
- ) -} \ No newline at end of file + return ( +
+ {!props.hideTitle &&

Top Three Albums

} +
+ {data.items.map((item, index) => ( + + ))} +
+
+ ); +} diff --git a/client/app/components/TopTracks.tsx b/client/app/components/TopTracks.tsx index b1d14c7..bfe31ca 100644 --- a/client/app/components/TopTracks.tsx +++ b/client/app/components/TopTracks.tsx @@ -1,50 +1,69 @@ -import { useQuery } from "@tanstack/react-query" -import ArtistLinks from "./ArtistLinks" -import { getTopTracks, imageUrl, type getItemsArgs } from "api/api" -import { Link } from "react-router" -import TopListSkeleton from "./skeletons/TopListSkeleton" -import { useEffect } from "react" -import TopItemList from "./TopItemList" +import { useQuery } from "@tanstack/react-query"; +import ArtistLinks from "./ArtistLinks"; +import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"; +import { Link } from "react-router"; +import TopListSkeleton from "./skeletons/TopListSkeleton"; +import { useEffect } from "react"; +import TopItemList from "./TopItemList"; interface Props { - limit: number, - period: string, - artistId?: Number - albumId?: Number + limit: number; + period: string; + artistId?: Number; + albumId?: Number; } const TopTracks = (props: Props) => { + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "top-tracks", + { + limit: props.limit, + period: props.period, + artist_id: props.artistId, + album_id: props.albumId, + page: 0, + }, + ], + queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs), + }); - const { isPending, isError, data, error } = useQuery({ - queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}], - queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs), - }) - - if (isPending) { - return ( -
-

Top Tracks

-

Loading...

-
- ) - } - if (isError) { - return

Error:{error.message}

- } - - let params = '' - params += props.artistId ? `&artist_id=${props.artistId}` : '' - params += props.albumId ? `&album_id=${props.albumId}` : '' + const header = "Top tracks"; + if (isPending) { return ( -
-

Top Tracks

-
- - {data.items.length < 1 ? 'Nothing to show' : ''} -
-
- ) -} +
+

{header}

+

Loading...

+
+ ); + } else if (isError) { + return ( +
+

{header}

+

Error: {error.message}

+
+ ); + } + if (!data.items) return; -export default TopTracks \ No newline at end of file + let params = ""; + params += props.artistId ? `&artist_id=${props.artistId}` : ""; + params += props.albumId ? `&album_id=${props.albumId}` : ""; + + return ( +
+

+ + {header} + +

+
+ + {data.items.length < 1 ? "Nothing to show" : ""} +
+
+ ); +}; + +export default TopTracks; diff --git a/client/app/components/icons/MbzIcon.tsx b/client/app/components/icons/MbzIcon.tsx new file mode 100644 index 0000000..1ce66ad --- /dev/null +++ b/client/app/components/icons/MbzIcon.tsx @@ -0,0 +1,23 @@ +interface Props { + size: number; + hover?: boolean; +} +export default function MbzIcon({ size, hover }: Props) { + let classNames = ""; + if (hover) { + classNames += "icon-hover-fill"; + } + return ( +
+ + + +
+ ); +} diff --git a/client/app/components/modals/Account.tsx b/client/app/components/modals/Account.tsx index 06d540e..562b53d 100644 --- a/client/app/components/modals/Account.tsx +++ b/client/app/components/modals/Account.tsx @@ -1,106 +1,124 @@ -import { logout, updateUser } from "api/api" -import { useState } from "react" -import { AsyncButton } from "../AsyncButton" -import { useAppContext } from "~/providers/AppProvider" +import { logout, updateUser } from "api/api"; +import { useState } from "react"; +import { AsyncButton } from "../AsyncButton"; +import { useAppContext } from "~/providers/AppProvider"; export default function Account() { - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [confirmPw, setConfirmPw] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [success, setSuccess] = useState('') - const { user, setUsername: setCtxUsername } = useAppContext() + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPw, setConfirmPw] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const { user, setUsername: setCtxUsername } = useAppContext(); - const logoutHandler = () => { - setLoading(true) - logout() - .then(r => { - if (r.ok) { - window.location.reload() - } else { - r.json().then(r => setError(r.error)) - } - }).catch(err => setError(err)) - setLoading(false) - } - const updateHandler = () => { - setError('') - setSuccess('') - if (password != "" && confirmPw === "") { - setError("confirm your new password before submitting") - return + const logoutHandler = () => { + setLoading(true); + logout() + .then((r) => { + if (r.ok) { + window.location.reload(); + } else { + r.json().then((r) => setError(r.error)); } - setError('') - setSuccess('') - setLoading(true) - updateUser(username, password) - .then(r => { - if (r.ok) { - setSuccess("sucessfully updated user") - if (username != "") { - setCtxUsername(username) - } - setUsername('') - setPassword('') - setConfirmPw('') - } else { - r.json().then((r) => setError(r.error)) - } - }).catch(err => setError(err)) - setLoading(false) + }) + .catch((err) => setError(err)); + setLoading(false); + }; + const updateHandler = () => { + setError(""); + setSuccess(""); + if (password != "" && confirmPw === "") { + setError("confirm your new password before submitting"); + return; } + setError(""); + setSuccess(""); + setLoading(true); + updateUser(username, password) + .then((r) => { + if (r.ok) { + setSuccess("sucessfully updated user"); + if (username != "") { + setCtxUsername(username); + } + setUsername(""); + setPassword(""); + setConfirmPw(""); + } else { + r.json().then((r) => setError(r.error)); + } + }) + .catch((err) => setError(err)); + setLoading(false); + }; - return ( - <> -

Account

-
-
-

You're logged in as {user?.username}

- Logout -
-

Update User

-
e.preventDefault()} className="flex flex-col gap-4"> -
- setUsername(e.target.value)} - /> -
-
- Submit -
-
-
e.preventDefault()} className="flex flex-col gap-4"> -
- setPassword(e.target.value)} - /> - setConfirmPw(e.target.value)} - /> -
-
- Submit -
-
- {success != "" &&

{success}

} - {error != "" &&

{error}

} + return ( + <> +

Account

+
+
+

+ You're logged in as {user?.username} +

+ + Logout +
- - ) -} \ No newline at end of file +

Update User

+
e.preventDefault()} + className="flex flex-col gap-4" + > +
+ setUsername(e.target.value)} + /> +
+
+ + Submit + +
+
+
e.preventDefault()} + className="flex flex-col gap-4" + > +
+ setPassword(e.target.value)} + /> + setConfirmPw(e.target.value)} + /> +
+
+ + Submit + +
+
+ {success != "" &&

{success}

} + {error != "" &&

{error}

} +
+ + ); +} diff --git a/client/app/components/modals/AddListenModal.tsx b/client/app/components/modals/AddListenModal.tsx new file mode 100644 index 0000000..4fda1b3 --- /dev/null +++ b/client/app/components/modals/AddListenModal.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { Modal } from "./Modal"; +import { AsyncButton } from "../AsyncButton"; +import { submitListen } from "api/api"; +import { useNavigate } from "react-router"; + +interface Props { + open: boolean; + setOpen: Function; + trackid: number; +} + +export default function AddListenModal({ open, setOpen, trackid }: Props) { + const [ts, setTS] = useState(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 ( + +

Add Listen

+
+ setTS(new Date(e.target.value))} + /> + + Submit + +

{error}

+
+
+ ); +} diff --git a/client/app/components/modals/ApiKeysModal.tsx b/client/app/components/modals/ApiKeysModal.tsx index a4bd822..c205464 100644 --- a/client/app/components/modals/ApiKeysModal.tsx +++ b/client/app/components/modals/ApiKeysModal.tsx @@ -5,172 +5,183 @@ import { useEffect, useRef, useState } from "react"; import { Copy, Trash } from "lucide-react"; type CopiedState = { - x: number; - y: number; - visible: boolean; + x: number; + y: number; + visible: boolean; }; export default function ApiKeysModal() { - const [input, setInput] = useState('') - const [loading, setLoading ] = useState(false) - const [err, setError ] = useState() - const [displayData, setDisplayData] = useState([]) - const [copied, setCopied] = useState(null); - const [expandedKey, setExpandedKey] = useState(null); - const textRefs = useRef>({}); - - const handleRevealAndSelect = (key: string) => { - setExpandedKey(key); - setTimeout(() => { - const el = textRefs.current[key]; - if (el) { - const range = document.createRange(); - range.selectNodeContents(el); - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - } - }, 0); - }; - - const { isPending, isError, data, error } = useQuery({ - queryKey: [ - 'api-keys' - ], - queryFn: () => { - return getApiKeys(); - }, + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [err, setError] = useState(); + const [displayData, setDisplayData] = useState([]); + const [copied, setCopied] = useState(null); + const [expandedKey, setExpandedKey] = useState(null); + const textRefs = useRef>({}); + + const handleRevealAndSelect = (key: string) => { + setExpandedKey(key); + setTimeout(() => { + const el = textRefs.current[key]; + if (el) { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + }, 0); + }; + + const { isPending, isError, data, error } = useQuery({ + queryKey: ["api-keys"], + queryFn: () => { + return getApiKeys(); + }, + }); + + useEffect(() => { + if (data) { + setDisplayData(data); + } + }, [data]); + + if (isError) { + return

Error: {error.message}

; + } + if (isPending) { + return

Loading...

; + } + + const handleCopy = (e: React.MouseEvent, text: string) => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); + } else { + fallbackCopy(text); + } + + const parentRect = ( + e.currentTarget.closest(".relative") as HTMLElement + ).getBoundingClientRect(); + const buttonRect = e.currentTarget.getBoundingClientRect(); + + setCopied({ + x: buttonRect.left - parentRect.left + buttonRect.width / 2, + y: buttonRect.top - parentRect.top - 8, + visible: true, }); - useEffect(() => { - if (data) { - setDisplayData(data) - } - }, [data]) + setTimeout(() => setCopied(null), 1500); + }; - if (isError) { - return ( -

Error: {error.message}

- ) + const fallbackCopy = (text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; // prevent scroll to bottom + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + } catch (err) { + console.error("Fallback: Copy failed", err); } - if (isPending) { - return ( -

Loading...

- ) + document.body.removeChild(textarea); + }; + + const handleCreateApiKey = () => { + setError(undefined); + if (input === "") { + setError("a label must be provided"); + return; } + setLoading(true); + createApiKey(input) + .then((r) => { + setDisplayData([r, ...displayData]); + setInput(""); + }) + .catch((err) => setError(err.message)); + setLoading(false); + }; - const handleCopy = (e: React.MouseEvent, text: string) => { - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); - } else { - fallbackCopy(text); - } - - const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect(); - const buttonRect = e.currentTarget.getBoundingClientRect(); - - setCopied({ - x: buttonRect.left - parentRect.left + buttonRect.width / 2, - y: buttonRect.top - parentRect.top - 8, - visible: true, - }); - - setTimeout(() => setCopied(null), 1500); - }; - - const fallbackCopy = (text: string) => { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; // prevent scroll to bottom - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - try { - document.execCommand("copy"); - } catch (err) { - console.error("Fallback: Copy failed", err); - } - document.body.removeChild(textarea); - }; - - const handleCreateApiKey = () => { - setError(undefined) - if (input === "") { - setError("a label must be provided") - return - } - setLoading(true) - createApiKey(input) - .then(r => { - setDisplayData([r, ...displayData]) - setInput('') - }).catch((err) => setError(err.message)) - setLoading(false) - } + const handleDeleteApiKey = (id: number) => { + setError(undefined); + setLoading(true); + deleteApiKey(id).then((r) => { + if (r.ok) { + setDisplayData(displayData.filter((v) => v.id != id)); + } else { + r.json().then((r) => setError(r.error)); + } + }); + setLoading(false); + }; - const handleDeleteApiKey = (id: number) => { - setError(undefined) - setLoading(true) - deleteApiKey(id) - .then(r => { - if (r.ok) { - setDisplayData(displayData.filter((v) => v.id != id)) - } else { - r.json().then((r) => setError(r.error)) - } - }) - setLoading(false) - - } - - return ( -
-

API Keys

-
- {displayData.map((v) => ( -
{ - 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}`} -
- - handleDeleteApiKey(v.id)} confirm> -
- ))} -
- setInput(e.target.value)} - /> - Create + return ( +
+

API Keys

+
+ {displayData.map((v) => ( +
+
{ + 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}`}
- {err &&

{err}

} - {copied?.visible && ( -
- Copied! -
- )} + + handleDeleteApiKey(v.id)} + confirm + > + + +
+ ))} +
+ setInput(e.target.value)} + /> + + Create +
-
- ) -} \ No newline at end of file + {err &&

{err}

} + {copied?.visible && ( +
+ Copied! +
+ )} +
+
+ ); +} diff --git a/client/app/components/modals/DeleteModal.tsx b/client/app/components/modals/DeleteModal.tsx index 98304ad..227951e 100644 --- a/client/app/components/modals/DeleteModal.tsx +++ b/client/app/components/modals/DeleteModal.tsx @@ -1,40 +1,41 @@ -import { deleteItem } from "api/api" -import { AsyncButton } from "../AsyncButton" -import { Modal } from "./Modal" -import { useNavigate } from "react-router" -import { useState } from "react" +import { deleteItem } from "api/api"; +import { AsyncButton } from "../AsyncButton"; +import { Modal } from "./Modal"; +import { useNavigate } from "react-router"; +import { useState } from "react"; interface Props { - open: boolean - setOpen: Function - title: string, - id: number, - type: string + open: boolean; + setOpen: Function; + title: string; + id: number; + type: string; } export default function DeleteModal({ open, setOpen, title, id, type }: Props) { - const [loading, setLoading] = useState(false) - const navigate = useNavigate() + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); - const doDelete = () => { - setLoading(true) - deleteItem(type.toLowerCase(), id) - .then(r => { - if (r.ok) { - navigate('/') - } else { - console.log(r) - } - }) - } + const doDelete = () => { + setLoading(true); + deleteItem(type.toLowerCase(), id).then((r) => { + if (r.ok) { + navigate(-1); + } else { + console.log(r); + } + }); + }; - return ( - setOpen(false)}> -

Delete "{title}"?

-

This action is irreversible!

-
- Yes, Delete It -
-
- ) -} \ No newline at end of file + return ( + setOpen(false)}> +

Delete "{title}"?

+

This action is irreversible!

+
+ + Yes, Delete It + +
+
+ ); +} diff --git a/client/app/components/modals/EditModal/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx new file mode 100644 index 0000000..a5c981e --- /dev/null +++ b/client/app/components/modals/EditModal/EditModal.tsx @@ -0,0 +1,165 @@ +import { useQuery } from "@tanstack/react-query"; +import { + createAlias, + deleteAlias, + getAliases, + setPrimaryAlias, + updateMbzId, + type Alias, +} from "api/api"; +import { Modal } from "../Modal"; +import { AsyncButton } from "../../AsyncButton"; +import { useEffect, useState } from "react"; +import { Trash } from "lucide-react"; +import SetVariousArtists from "./SetVariousArtist"; +import SetPrimaryArtist from "./SetPrimaryArtist"; +import UpdateMbzID from "./UpdateMbzID"; + +interface Props { + type: string; + id: number; + open: boolean; + setOpen: Function; +} + +export default function EditModal({ open, setOpen, type, id }: Props) { + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [err, setError] = useState(); + const [displayData, setDisplayData] = useState([]); + + 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

Error: {error.message}

; + } + if (isPending) { + return

Loading...

; + } + + 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 ( + +
+
+

Alias Manager

+
+ {displayData.map((v) => ( +
+
+ {v.alias} (source: {v.source}) +
+ handleSetPrimary(v.alias)} + disabled={v.is_primary} + > + Set Primary + + handleDeleteAlias(v.alias)} + confirm + disabled={v.is_primary} + > + + +
+ ))} +
+ setInput(e.target.value)} + /> + + Submit + +
+ {err &&

{err}

} +
+
+ {type.toLowerCase() === "album" && ( + <> + + + + )} + {type.toLowerCase() === "track" && ( + + )} + +
+
+ ); +} diff --git a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx new file mode 100644 index 0000000..e91b083 --- /dev/null +++ b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx @@ -0,0 +1,99 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAlbum, type Artist } from "api/api"; +import { useEffect, useState } from "react"; + +interface Props { + id: number; + type: string; +} + +export default function SetPrimaryArtist({ id, type }: Props) { + const [err, setErr] = useState(""); + const [primary, setPrimary] = useState(); + 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; + }, + }); + + useEffect(() => { + if (data) { + for (let a of data) { + if (a.is_primary) { + setPrimary(a); + break; + } + } + } + }, [data]); + + if (isError) { + return

Error: {error.message}

; + } + if (isPending) { + return

Loading...

; + } + + 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 ( +
+

Set Primary Artist

+
+ + {err &&

{err}

} + {success &&

{success}

} +
+
+ ); +} diff --git a/client/app/components/modals/EditModal/SetVariousArtist.tsx b/client/app/components/modals/EditModal/SetVariousArtist.tsx new file mode 100644 index 0000000..bf9e3d3 --- /dev/null +++ b/client/app/components/modals/EditModal/SetVariousArtist.tsx @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAlbum } from "api/api"; +import { useEffect, useState } from "react"; + +interface Props { + id: number; +} + +export default function SetVariousArtists({ id }: Props) { + const [err, setErr] = useState(""); + const [va, setVA] = useState(false); + const [success, setSuccess] = useState(""); + + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "get-album", + { + id: id, + }, + ], + queryFn: ({ queryKey }) => { + const params = queryKey[1] as { id: number }; + return getAlbum(params.id); + }, + }); + + useEffect(() => { + if (data) { + setVA(data.is_various_artists); + } + }, [data]); + + if (isError) { + return

Error: {error.message}

; + } + if (isPending) { + return

Loading...

; + } + + 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 ( +
+

Mark as Various Artists

+
+ + {err &&

{err}

} + {success &&

{success}

} +
+
+ ); +} diff --git a/client/app/components/modals/EditModal/UpdateMbzID.tsx b/client/app/components/modals/EditModal/UpdateMbzID.tsx new file mode 100644 index 0000000..0654cc1 --- /dev/null +++ b/client/app/components/modals/EditModal/UpdateMbzID.tsx @@ -0,0 +1,53 @@ +import { updateMbzId } from "api/api"; +import { useState } from "react"; +import { AsyncButton } from "~/components/AsyncButton"; + +interface Props { + type: string; + id: number; +} + +export default function UpdateMbzID({ type, id }: Props) { + const [err, setError] = useState(); + 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 ( +
+

Update MusicBrainz ID

+
+ setInput(e.target.value)} + /> + + Submit + +
+ {err &&

{err}

} + {success &&

{success}

} +
+ ); +} diff --git a/client/app/components/modals/ExportModal.tsx b/client/app/components/modals/ExportModal.tsx new file mode 100644 index 0000000..d83d7d4 --- /dev/null +++ b/client/app/components/modals/ExportModal.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { AsyncButton } from "../AsyncButton"; +import { getExport } from "api/api"; + +export default function ExportModal() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleExport = () => { + setLoading(true); + fetch(`/apis/web/v1/export`, { + method: "GET", + }) + .then((res) => { + if (res.ok) { + res.blob().then((blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "koito_export.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + setLoading(false); + }); + } else { + res.json().then((r) => setError(r.error)); + setLoading(false); + } + }) + .catch((err) => { + setError(err); + setLoading(false); + }); + }; + + return ( +
+

Export

+ + Export Data + + {error &&

{error}

} +
+ ); +} diff --git a/client/app/components/modals/ImageReplaceModal.tsx b/client/app/components/modals/ImageReplaceModal.tsx index d76dd61..11319b7 100644 --- a/client/app/components/modals/ImageReplaceModal.tsx +++ b/client/app/components/modals/ImageReplaceModal.tsx @@ -5,86 +5,111 @@ import SearchResults from "../SearchResults"; import { AsyncButton } from "../AsyncButton"; interface Props { - type: string - id: number - musicbrainzId?: string - open: boolean - setOpen: Function + type: string; + id: number; + musicbrainzId?: string; + open: boolean; + setOpen: Function; } -export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) { - const [query, setQuery] = useState(''); - const [loading, setLoading] = useState(false) - const [suggestedImgLoading, setSuggestedImgLoading] = useState(true) +export default function ImageReplaceModal({ + musicbrainzId, + type, + id, + open, + setOpen, +}: Props) { + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [suggestedImgLoading, setSuggestedImgLoading] = useState(true); - const doImageReplace = (url: string) => { - setLoading(true) - const formData = new FormData - formData.set(`${type.toLowerCase()}_id`, id.toString()) - formData.set("image_url", url) - replaceImage(formData) - .then((r) => { - if (r.ok) { - window.location.reload() - } else { - console.log(r) - setLoading(false) - } - }) - .catch((err) => console.log(err)) - } + const doImageReplace = (url: string) => { + setLoading(true); + setError(""); + const formData = new FormData(); + formData.set(`${type.toLowerCase()}_id`, id.toString()); + formData.set("image_url", url); + replaceImage(formData) + .then((r) => { + if (r.status >= 200 && r.status < 300) { + window.location.reload(); + } else { + r.json().then((r) => setError(r.error)); + setLoading(false); + } + }) + .catch((err) => setError(err)); + }; - const closeModal = () => { - setOpen(false) - setQuery('') - } + const closeModal = () => { + setOpen(false); + setQuery(""); + setError(""); + }; - return ( - -

Replace Image

-
- setQuery(e.target.value)} + return ( + +

Replace Image

+
+ setQuery(e.target.value)} + /> + {query != "" ? ( +
+ doImageReplace(query)} + > + Submit + +
+ ) : ( + "" + )} + {type === "Album" && musicbrainzId ? ( + <> +

Suggested Image (Click to Apply)

+ - - : '' - } -
-
- ) -} \ No newline at end of file +
+ + + ) : ( + "" + )} +

{error}

+
+ + ); +} diff --git a/client/app/components/modals/LoginForm.tsx b/client/app/components/modals/LoginForm.tsx index 2c2afc6..1078476 100644 --- a/client/app/components/modals/LoginForm.tsx +++ b/client/app/components/modals/LoginForm.tsx @@ -1,59 +1,74 @@ -import { login } from "api/api" -import { useEffect, useState } from "react" -import { AsyncButton } from "../AsyncButton" +import { login } from "api/api"; +import { useEffect, useState } from "react"; +import { AsyncButton } from "../AsyncButton"; export default function LoginForm() { - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [remember, setRemember] = useState(false) + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(false); - const loginHandler = () => { - if (username && password) { - setLoading(true) - login(username, password, remember) - .then(r => { - if (r.status >= 200 && r.status < 300) { - window.location.reload() - } else { - r.json().then(r => setError(r.error)) - } - }).catch(err => setError(err)) - setLoading(false) - } else if (username || password) { - setError("username and password are required") - } + const loginHandler = () => { + if (username && password) { + setLoading(true); + login(username, password, remember) + .then((r) => { + if (r.status >= 200 && r.status < 300) { + window.location.reload(); + } else { + r.json().then((r) => setError(r.error)); + } + }) + .catch((err) => setError(err)); + setLoading(false); + } else if (username || password) { + setError("username and password are required"); } + }; - return ( - <> -

Log In

-
-

Logging in gives you access to admin tools, such as updating images, merging items, deleting items, and more.

-
e.preventDefault()}> - setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> -
- setRemember(!remember)} /> - -
- Login -
-

{error}

-
- - ) -} \ No newline at end of file + return ( + <> +

Log In

+
+

+ Logging in gives you access to admin tools, such as + updating images, merging items, deleting items, and more. +

+
e.preventDefault()} + > + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+ setRemember(!remember)} + /> + +
+ + Login + +
+

{error}

+
+ + ); +} diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx index ff1079b..c78681d 100644 --- a/client/app/components/modals/MergeModal.tsx +++ b/client/app/components/modals/MergeModal.tsx @@ -2,124 +2,159 @@ import { useEffect, useState } from "react"; import { Modal } from "./Modal"; import { search, type SearchResponse } from "api/api"; import SearchResults from "../SearchResults"; -import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout"; +import type { + MergeFunc, + MergeSearchCleanerFunc, +} from "~/routes/MediaItems/MediaLayout"; import { useNavigate } from "react-router"; interface Props { - open: boolean - setOpen: Function - type: string - currentId: number - currentTitle: string - mergeFunc: MergeFunc - mergeCleanerFunc: MergeSearchCleanerFunc + open: boolean; + setOpen: Function; + type: string; + currentId: number; + currentTitle: string; + mergeFunc: MergeFunc; + mergeCleanerFunc: MergeSearchCleanerFunc; } export default function MergeModal(props: Props) { - const [query, setQuery] = useState(''); - const [data, setData] = useState(); - const [debouncedQuery, setDebouncedQuery] = useState(query); - const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0}) - const [mergeOrderReversed, setMergeOrderReversed] = useState(false) - const navigate = useNavigate() + const [query, setQuery] = useState(props.currentTitle); + const [data, setData] = useState(); + const [debouncedQuery, setDebouncedQuery] = useState(query); + const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>( + { title: "", id: 0 } + ); + const [mergeOrderReversed, setMergeOrderReversed] = useState(false); + const [replaceImage, setReplaceImage] = useState(false); + const navigate = useNavigate(); + const closeMergeModal = () => { + props.setOpen(false); + setQuery(""); + setData(undefined); + setMergeOrderReversed(false); + setMergeTarget({ title: "", id: 0 }); + }; - const closeMergeModal = () => { - props.setOpen(false) - setQuery('') - setData(undefined) - setMergeOrderReversed(false) - setMergeTarget({title: '', id: 0}) + const toggleSelect = ({ title, id }: { title: string; id: number }) => { + setMergeTarget({ title: title, id: id }); + }; + + useEffect(() => { + console.log("mergeTarget", mergeTarget); + }, [mergeTarget]); + + const doMerge = () => { + let from, to; + if (!mergeOrderReversed) { + from = mergeTarget; + to = { id: props.currentId, title: props.currentTitle }; + } else { + from = { id: props.currentId, title: props.currentTitle }; + to = mergeTarget; } - - const toggleSelect = ({title, id}: {title: string, id: number}) => { - if (mergeTarget.id === 0) { - setMergeTarget({title: title, id: id}) + props + .mergeFunc(from.id, to.id, replaceImage) + .then((r) => { + if (r.ok) { + if (mergeOrderReversed) { + navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`); + closeMergeModal(); + } else { + window.location.reload(); + } } else { - setMergeTarget({title:"", id: 0}) + // TODO: handle error + console.log(r); } + }) + .catch((err) => console.log(err)); + }; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedQuery(query); + if (query === "") { + setData(undefined); + } + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [query]); + + useEffect(() => { + if (debouncedQuery) { + search(debouncedQuery).then((r) => { + r = props.mergeCleanerFunc(r, props.currentId); + setData(r); + }); } + }, [debouncedQuery]); - useEffect(() => { - console.log(mergeTarget) - }, [mergeTarget]) - - const doMerge = () => { - let from, to - if (!mergeOrderReversed) { - from = mergeTarget - to = {id: props.currentId, title: props.currentTitle} - } else { - from = {id: props.currentId, title: props.currentTitle} - to = mergeTarget - } - props.mergeFunc(from.id, to.id) - .then(r => { - if (r.ok) { - if (mergeOrderReversed) { - navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`) - closeMergeModal() - } else { - window.location.reload() - } - } else { - // TODO: handle error - console.log(r) - } - }) - .catch((err) => console.log(err)) - } - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedQuery(query); - if (query === '') { - setData(undefined) - } - }, 300); - - return () => { - clearTimeout(handler); - }; - }, [query]); - - useEffect(() => { - if (debouncedQuery) { - search(debouncedQuery).then((r) => { - r = props.mergeCleanerFunc(r, props.currentId) - setData(r); - }); - } - }, [debouncedQuery]); - - return ( + return ( -

Merge {props.type}s

-
- setQuery(e.target.value)} - /> - - { mergeTarget.id !== 0 ? - <> - {mergeOrderReversed ? -

{props.currentTitle} will be merged into {mergeTarget.title}

- : -

{mergeTarget.title} will be merged into {props.currentTitle}

- } - -
- setMergeOrderReversed(!mergeOrderReversed)} /> - +

Merge {props.type}s

+
+ { setQuery(e.target.value); e.target.select()}} + onChange={(e) => setQuery(e.target.value)} + /> + + {mergeTarget.id !== 0 ? ( + <> + {mergeOrderReversed ? ( +

+ {props.currentTitle} will be merged into{" "} + {mergeTarget.title} +

+ ) : ( +

+ {mergeTarget.title} will be merged into{" "} + {props.currentTitle} +

+ )} + +
+ setMergeOrderReversed(!mergeOrderReversed)} + /> +
- : - ''} -
+ {(props.type.toLowerCase() === "album" || + props.type.toLowerCase() === "artist") && ( +
+ setReplaceImage(!replaceImage)} + /> + +
+ )} + + ) : ( + "" + )} +
- ) + ); } diff --git a/client/app/components/modals/Modal.tsx b/client/app/components/modals/Modal.tsx index 47307b0..fc6ce67 100644 --- a/client/app/components/modals/Modal.tsx +++ b/client/app/components/modals/Modal.tsx @@ -32,10 +32,34 @@ export function Modal({ } }, [isOpen, shouldRender]); - // Close on Escape key + // Handle keyboard events useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + // Close on Escape key + if (e.key === 'Escape') { + onClose() + // Trap tab navigation to the modal + } else if (e.key === 'Tab') { + if (modalRef.current) { + const focusableEls = modalRef.current.querySelectorAll( + 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' + ); + const firstEl = focusableEls[0]; + const lastEl = focusableEls[focusableEls.length - 1]; + const activeEl = document.activeElement + + if (e.shiftKey && activeEl === firstEl) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && activeEl === lastEl) { + e.preventDefault(); + firstEl.focus(); + } else if (!Array.from(focusableEls).find(node => node.isEqualNode(activeEl))) { + e.preventDefault(); + firstEl.focus(); + } + } + }; }; if (isOpen) document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); @@ -70,13 +94,13 @@ export function Modal({ }`} style={{ maxWidth: maxW ?? 600, height: h ?? '' }} > + {children} - {children}
, document.body diff --git a/client/app/components/modals/RenameModal.tsx b/client/app/components/modals/RenameModal.tsx deleted file mode 100644 index 4a53ae6..0000000 --- a/client/app/components/modals/RenameModal.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api"; -import { Modal } from "./Modal"; -import { AsyncButton } from "../AsyncButton"; -import { useEffect, useState } from "react"; -import { Trash } from "lucide-react"; - -interface Props { - type: string - id: number - open: boolean - setOpen: Function -} - -export default function RenameModal({ open, setOpen, type, id }: Props) { - const [input, setInput] = useState('') - const [loading, setLoading ] = useState(false) - const [err, setError ] = useState() - const [displayData, setDisplayData] = useState([]) - - 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 ( -

Error: {error.message}

- ) - } - if (isPending) { - return ( -

Loading...

- ) - } - 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 ( - setOpen(false)}> -

Alias Manager

-
- {displayData.map((v) => ( -
-
{v.alias} (source: {v.source})
- handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary - handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}> -
- ))} -
- setInput(e.target.value)} - /> - Submit -
- {err &&

{err}

} -
-
- ) -} \ No newline at end of file diff --git a/client/app/components/modals/SearchModal.tsx b/client/app/components/modals/SearchModal.tsx index ec056cf..80c95dc 100644 --- a/client/app/components/modals/SearchModal.tsx +++ b/client/app/components/modals/SearchModal.tsx @@ -4,57 +4,57 @@ import { search, type SearchResponse } from "api/api"; import SearchResults from "../SearchResults"; interface Props { - open: boolean - setOpen: Function + open: boolean; + setOpen: Function; } export default function SearchModal({ open, setOpen }: Props) { - const [query, setQuery] = useState(''); - const [data, setData] = useState(); - const [debouncedQuery, setDebouncedQuery] = useState(query); + const [query, setQuery] = useState(""); + const [data, setData] = useState(); + const [debouncedQuery, setDebouncedQuery] = useState(query); - const closeSearchModal = () => { - setOpen(false) - setQuery('') - setData(undefined) + const closeSearchModal = () => { + setOpen(false); + setQuery(""); + setData(undefined); + }; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedQuery(query); + if (query === "") { + setData(undefined); + } + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [query]); + + useEffect(() => { + if (debouncedQuery) { + search(debouncedQuery).then((r) => { + setData(r); + }); } + }, [debouncedQuery]); - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedQuery(query); - if (query === '') { - setData(undefined) - } - }, 300); - - return () => { - clearTimeout(handler); - }; - }, [query]); - - useEffect(() => { - if (debouncedQuery) { - search(debouncedQuery).then((r) => { - setData(r); - }); - } - }, [debouncedQuery]); - - return ( - -

Search

-
- setQuery(e.target.value)} - /> -
- -
-
-
- ) + return ( + +

Search

+
+ setQuery(e.target.value)} + /> +
+ +
+
+
+ ); } diff --git a/client/app/components/modals/SettingsModal.tsx b/client/app/components/modals/SettingsModal.tsx index 4ae62d6..31d915b 100644 --- a/client/app/components/modals/SettingsModal.tsx +++ b/client/app/components/modals/SettingsModal.tsx @@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher"; import ThemeHelper from "../../routes/ThemeHelper"; import { useAppContext } from "~/providers/AppProvider"; import ApiKeysModal from "./ApiKeysModal"; +import { AsyncButton } from "../AsyncButton"; +import ExportModal from "./ExportModal"; interface Props { open: boolean @@ -19,7 +21,7 @@ export default function SettingsModal({ open, setOpen } : Props) { const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto" return ( - setOpen(false)} maxW={900}> + setOpen(false)} maxW={900}> Appearance Account {user && ( - - API Keys - + <> + + API Keys + + Export + )} @@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) { + + + ) diff --git a/client/app/components/rewind/Rewind.tsx b/client/app/components/rewind/Rewind.tsx new file mode 100644 index 0000000..a22fe15 --- /dev/null +++ b/client/app/components/rewind/Rewind.tsx @@ -0,0 +1,79 @@ +import { imageUrl, type RewindStats } from "api/api"; +import RewindStatText from "./RewindStatText"; +import { RewindTopItem } from "./RewindTopItem"; + +interface Props { + stats: RewindStats; + includeTime?: boolean; +} + +export default function Rewind(props: Props) { + const artistimg = props.stats.top_artists[0]?.item.image; + const albumimg = props.stats.top_albums[0]?.item.image; + const trackimg = props.stats.top_tracks[0]?.item.image; + if ( + !props.stats.top_artists[0] || + !props.stats.top_albums[0] || + !props.stats.top_tracks[0] + ) { + return

Not enough data exists to create a Rewind for this period :(

; + } + return ( +
+

{props.stats.title}

+ a.name} + includeTime={props.includeTime} + /> + + a.title} + includeTime={props.includeTime} + /> + + t.title} + includeTime={props.includeTime} + /> + +
+ + + + + + + + + +
+
+ ); +} diff --git a/client/app/components/rewind/RewindStatText.tsx b/client/app/components/rewind/RewindStatText.tsx new file mode 100644 index 0000000..5ccec87 --- /dev/null +++ b/client/app/components/rewind/RewindStatText.tsx @@ -0,0 +1,32 @@ +interface Props { + figure: string; + text: string; +} + +export default function RewindStatText(props: Props) { + return ( +
+
+ + + {props.figure} + +
+ {props.text} +
+ ); +} diff --git a/client/app/components/rewind/RewindTopItem.tsx b/client/app/components/rewind/RewindTopItem.tsx new file mode 100644 index 0000000..5093768 --- /dev/null +++ b/client/app/components/rewind/RewindTopItem.tsx @@ -0,0 +1,57 @@ +import type { Ranked } from "api/api"; + +type TopItemProps = { + title: string; + imageSrc: string; + items: Ranked[]; + 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) { + const [top, ...rest] = items; + + if (!top) return null; + + return ( +
+
+ +
+ +
+

{title}

+ +
+
+

{getLabel(top.item)}

+ + {`${top.item.listen_count} plays`} + {includeTime + ? ` (${Math.floor(top.item.time_listened / 60)} minutes)` + : ``} + +
+
+ + {rest.map((e) => ( +
+ {getLabel(e.item)} + + {` - ${e.item.listen_count} plays`} + {includeTime + ? ` (${Math.floor(e.item.time_listened / 60)} minutes)` + : ``} + +
+ ))} +
+
+ ); +} diff --git a/client/app/components/sidebar/Sidebar.tsx b/client/app/components/sidebar/Sidebar.tsx index 1a42e67..2bd88f3 100644 --- a/client/app/components/sidebar/Sidebar.tsx +++ b/client/app/components/sidebar/Sidebar.tsx @@ -1,55 +1,73 @@ -import { ExternalLink, Home, Info } from "lucide-react"; +import { ExternalLink, History, Home, Info } from "lucide-react"; import SidebarSearch from "./SidebarSearch"; import SidebarItem from "./SidebarItem"; import SidebarSettings from "./SidebarSettings"; +import { getRewindParams, getRewindYear } from "~/utils/utils"; export default function Sidebar() { - const iconSize = 20; + const iconSize = 20; - return ( -
-
- {}} modal={<>}> - - - -
-
- } - space={22} - externalLink - to="https://koito.io" - name="About" - onClick={() => {}} - modal={<>} - > - - - -
-
- ); + " + > +
+ {}} + modal={<>} + > + + + + {}} + modal={<>} + > + + +
+
+ } + space={22} + externalLink + to="https://koito.io" + name="About" + onClick={() => {}} + modal={<>} + > + + + +
+
+ ); } diff --git a/client/app/components/themeSwitcher/ThemeOption.tsx b/client/app/components/themeSwitcher/ThemeOption.tsx index 224fcce..7c0166b 100644 --- a/client/app/components/themeSwitcher/ThemeOption.tsx +++ b/client/app/components/themeSwitcher/ThemeOption.tsx @@ -1,22 +1,43 @@ -import type { Theme } from "~/providers/ThemeProvider"; +import type { Theme } from "~/styles/themes.css"; interface Props { - theme: Theme - setTheme: Function + theme: Theme; + themeName: string; + setTheme: Function; } -export default function ThemeOption({ theme, setTheme }: Props) { +export default function ThemeOption({ theme, themeName, setTheme }: Props) { + const capitalizeFirstLetter = (s: string) => { + return s.charAt(0).toUpperCase() + s.slice(1); + }; - const capitalizeFirstLetter = (s: string) => { - return s.charAt(0).toUpperCase() + s.slice(1); - } - - return ( -
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}}> -
{capitalizeFirstLetter(theme.name)}
-
-
-
-
- ) -} \ No newline at end of file + return ( +
setTheme(themeName)} + className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-3 items-center border-2 justify-between" + style={{ + background: theme.bg, + color: theme.fg, + borderColor: theme.bgSecondary, + }} + > +
+ {capitalizeFirstLetter(themeName)} +
+
+
+
+
+
+
+ ); +} diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index e051f50..f27d41c 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -1,36 +1,78 @@ -// ThemeSwitcher.tsx -import { useEffect } from 'react'; -import { useTheme } from '../../hooks/useTheme'; -import { themes } from '~/providers/ThemeProvider'; -import ThemeOption from './ThemeOption'; +import { useState } from "react"; +import { useTheme } from "../../hooks/useTheme"; +import themes from "~/styles/themes.css"; +import ThemeOption from "./ThemeOption"; +import { AsyncButton } from "../AsyncButton"; export function ThemeSwitcher() { - const { theme, setTheme } = useTheme(); + const { setTheme } = useTheme(); + const initialTheme = { + bg: "#1e1816", + bgSecondary: "#2f2623", + bgTertiary: "#453733", + fg: "#f8f3ec", + fgSecondary: "#d6ccc2", + fgTertiary: "#b4a89c", + primary: "#f5a97f", + primaryDim: "#d88b65", + accent: "#f9db6d", + accentDim: "#d9bc55", + error: "#e26c6a", + warning: "#f5b851", + success: "#8fc48f", + info: "#87b8dd", + }; + const { setCustomTheme, getCustomTheme, resetTheme } = useTheme(); + const [custom, setCustom] = useState( + JSON.stringify(getCustomTheme() ?? initialTheme, null, " ") + ); - useEffect(() => { - const saved = localStorage.getItem('theme'); - if (saved && saved !== theme) { - setTheme(saved); - } else if (!saved) { - localStorage.setItem('theme', theme) - } - }, []); + const handleCustomTheme = () => { + console.log(custom); + try { + const themeData = JSON.parse(custom); + setCustomTheme(themeData); + setCustom(JSON.stringify(themeData, null, " ")); + console.log(themeData); + } catch (err) { + console.log(err); + } + }; - useEffect(() => { - if (theme) { - localStorage.setItem('theme', theme) - } - }, [theme]); - - return ( - <> -

Select Theme

-
- {themes.map((t) => ( - - ))} + return ( +
+
+
+

Select Theme

+
+ Reset +
- - ); +
+ {Object.entries(themes).map(([name, themeData]) => ( + + ))} +
+
+
+

Use Custom Theme

+
+