diff --git a/.env.example b/.env.example deleted file mode 100644 index d5ed451..0000000 --- a/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -KOITO_ALLOWED_HOSTS=* -KOITO_LOG_LEVEL=debug -KOITO_CONFIG_DIR=test_config_dir -KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable -TZ=Etc/UTC diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index fbf205d..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 677ad0c..0000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[Bug] " -labels: bug -assignees: gabehf - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots/Logs** -If applicable, add screenshots to help explain your problem and any relevant logs with `KOITO_LOG_LEVEL=debug` if possible. - -**Version (please complete the following information):** - - Koito version: v0.0.X - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 34ecdf9..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "[Enhancement] " -labels: enhancement -assignees: gabehf - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Why would you like this feature to be added?** -A clear description of why this feature might be useful for you will help inform development decisions. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/astro.yml b/.github/workflows/astro.yml index 428b7b8..2da5fc4 100644 --- a/.github/workflows/astro.yml +++ b/.github/workflows/astro.yml @@ -2,13 +2,10 @@ name: Deploy to GitHub Pages on: push: - tags: - - "v*" + branches: [main] paths: - - "docs/**" - - ".github/workflows/**" - - workflow_dispatch: + - 'docs/**' + - '.github/workflows/**' permissions: contents: read @@ -24,9 +21,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 @@ -37,4 +34,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 466a4f6..cd7ecb0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,64 +12,66 @@ name: Publish Docker image on: push: tags: - - "v*" - branches: - - main - paths-ignore: - - "docs/**" - - "README.md" - - workflow_dispatch: + - 'v*' 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 (release) - if: startsWith(github.ref, 'refs/tags/') + name: Push Docker image to Docker Hub needs: test runs-on: ubuntu-latest + permissions: + packages: write + contents: read + attestations: write + id-token: write steps: - - uses: actions/checkout@v4 + - name: Check out the repo + uses: actions/checkout@v4 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: gabehf/koito - name: Extract tag version + id: extract_version run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Build and push release image + - name: Build and push Docker image id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 with: context: . file: ./Dockerfile @@ -79,34 +81,10 @@ jobs: gabehf/koito:${{ env.KOITO_VERSION }} build-args: | KOITO_VERSION=${{ env.KOITO_VERSION }} - platforms: linux/amd64,linux/arm64 - 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 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 with: - 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 + subject-name: index.docker.io/gabehf/koito + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index f37ea6a..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Test - -on: - pull_request: - branches: - - main - -jobs: - test: - name: Go Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install libvips - run: | - sudo apt-get update - sudo apt-get install -y libvips-dev - - - name: Verify libvips install - run: vips --version - - - name: Build - run: go build -v ./... - - - name: Test - uses: robherley/go-test-action@v0 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 083bb78..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -test_config_dir -.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9eb9f33 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# v0.0.4 +## Enhancements +- Re-download images missing from cache on request diff --git a/Dockerfile b/Dockerfile index 72fd522..3c95c9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY ./client . RUN yarn run build -FROM golang:1.24 AS backend +FROM golang:1.23 AS backend ARG KOITO_VERSION ENV CGO_ENABLED=1 diff --git a/Makefile b/Makefile index 99455ac..78c1fb0 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,3 @@ -ifneq (,$(wildcard ./.env)) - include .env - export -endif - .PHONY: all test clean client postgres.schemadump: @@ -15,10 +10,7 @@ postgres.schemadump: -v --dbname="koitodb" -f "/tmp/dump/schema.sql" postgres.run: - 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 + docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres postgres.start: docker start koito-db @@ -26,17 +18,8 @@ postgres.start: postgres.stop: docker stop koito-db -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.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 api.test: go test ./... -timeout 60s @@ -50,7 +33,7 @@ client.dev: docs.dev: cd docs && yarn dev -client.deps: +client.deps: cd client && yarn install client.build: client.deps @@ -58,4 +41,4 @@ client.build: client.deps test: api.test -build: api.build client.build +build: api.build client.build \ No newline at end of file diff --git a/README.md b/README.md index b51b2ff..2bc10ce 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,9 @@ -
- -![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 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 under active development and still considered "unstable", and therefore you can expect some bugs. If you don't want to replace your current scrobbler +> This project is currently pre-release, and therefore you can expect rapid development and some bugs. If you don't want to replace your current scrobbler with Koito quite yet, you can [set up a relay](https://koito.io/guides/scrobbler/#set-up-a-relay) from Koito to another ListenBrainz-compatible 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). @@ -35,9 +23,8 @@ You can view my public instance with my listening data at https://koito.mnrva.de ## Screenshots ![screenshot one](assets/screenshot1.png) -image -image - +![screenshot two](assets/screenshot2.png) +![screenshot three](assets/screenshot3.png) ## Installation @@ -88,16 +75,6 @@ There are currently some known issues that I am actively working on, in addition If you have any feature ideas, open a GitHub issue to let me know. I'm sorting through ideas to decide which data visualizations and customization options to add next. -## Star History - - - - - - Star History Chart - - - ## Albums that fueled development + notes More relevant here than any of my other projects... @@ -107,4 +84,5 @@ Not just during development, you can see my complete listening data on my [live #### Random notes - I find it a little annoying when READMEs use emoji but everyone else is doing it so I felt like I had to... -- 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. +- 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 diff --git a/assets/Jost-Regular.ttf b/assets/Jost-Regular.ttf deleted file mode 100644 index 3269563..0000000 Binary files a/assets/Jost-Regular.ttf and /dev/null differ diff --git a/assets/LeagueSpartan-Medium.ttf b/assets/LeagueSpartan-Medium.ttf deleted file mode 100644 index c701d88..0000000 Binary files a/assets/LeagueSpartan-Medium.ttf and /dev/null differ diff --git a/client/api/api.ts b/client/api/api.ts index bd2430b..ec79aa0 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -1,501 +1,287 @@ 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; -} -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; + step: string + range: number + month: number + year: number + artist_id: number + album_id: number + track_id: number } -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 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 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 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 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 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 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 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 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 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 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 getStats(period: string): Promise { + return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise) } function search(q: string): Promise { - q = encodeURIComponent(q); - return fetch(`/apis/web/v1/search?q=${q}`).then( - (r) => r.json() as Promise - ); + 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, - replaceImage: boolean -): Promise { - return fetch( - `/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, - { - 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 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 mergeArtists(from: number, to: number): Promise { + return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, { + 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 login(username: string, password: string, remember: boolean): Promise { + return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, { + method: "POST", + }) } function logout(): Promise { - 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, - }); + return fetch(`/apis/web/v1/logout`, { + method: "POST", + }) } 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 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 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); } - throw new Error(errorMessage); - } - const data: ApiKey = await r.json(); - return data; + const data: ApiKey = await r.json(); + return data; }; function deleteApiKey(id: number): Promise { - 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 { - const form = new URLSearchParams(); - form.append("id", String(id)); - form.append("label", label); - return fetch(`/apis/web/v1/user/apikeys`, { - method: "PATCH", - body: form, - }); + return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, { + method: "PATCH" + }) } 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) { - const form = new URLSearchParams(); - form.append("username", username); - form.append("password", password); - return fetch(`/apis/web/v1/user`, { - method: "PATCH", - body: form, - }); + return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, { + method: "PATCH" + }) } 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 { - 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 createAlias(type: string, id: number, alias: string): Promise { + return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { + method: 'POST' + }) } -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 deleteAlias(type: string, id: number, alias: string): Promise { + return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, { + method: "DELETE" + }) } -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 setPrimaryAlias(type: string, id: number, alias: string): Promise { + return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, { + method: "POST" + }) } 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", - }); -} -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); + 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" + }) } export { - 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, -}; + 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, +} type Track = { - 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; -}; + id: number + title: string + artists: SimpleArtists[] + listen_count: number + image: string + album_id: number + musicbrainz_id: string +} type Artist = { - 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; -}; + id: number + name: string + image: string, + aliases: string[] + listen_count: number + musicbrainz_id: string +} type Album = { - 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; -}; + id: number, + title: string + image: string + listen_count: number + is_various_artists: boolean + artists: SimpleArtists[] + musicbrainz_id: string +} 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; -}; -type Ranked = { - item: T; - rank: number; -}; + items: T[], + total_record_count: number, + has_next_page: boolean, + current_page: number, + items_per_page: number, +} type ListenActivityItem = { - start_time: Date; - listens: number; -}; -type InterestBucket = { - bucket_start: Date; - bucket_end: Date; - listen_count: number; -}; + start_time: Date, + listens: 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; - minutes_listened: number; -}; + listen_count: number + track_count: number + album_count: number + artist_count: number + hours_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; -}; -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; -}; + error: string +} export type { - getItemsArgs, - getActivityArgs, - getInterestArgs, - Track, - Artist, - Album, - Listen, - SearchResponse, - PaginatedResponse, - Ranked, - ListenActivityItem, - InterestBucket, - User, - Alias, - ApiKey, - ApiError, - Config, - NowPlaying, - Stats, - RewindStats, -}; + getItemsArgs, + getActivityArgs, + Track, + Artist, + Album, + Listen, + SearchResponse, + PaginatedResponse, + ListenActivityItem, + User, + Alias, + ApiKey, + ApiError +} diff --git a/client/app/app.css b/client/app/app.css index 15cfbc0..f0b786e 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -1,56 +1,59 @@ -@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap"); +@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap'); @import "tailwindcss"; @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); + --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); + } } - 100% { - opacity: 1; - transform: scale(1); + + @keyframes fade-out-scale { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.95); + } } - } - - @keyframes fade-out-scale { - 0% { - 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; + } } - 100% { - opacity: 0; - transform: scale(0.95); + + @keyframes fade-out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } } - } - - --animate-fade-in: fade-in 0.1s ease forwards; - --animate-fade-out: fade-out 0.1s ease forwards; - - @keyframes fade-in { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } - } - - @keyframes fade-out { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } - } + } + + :root { --header-xl: 36px; --header-lg: 28px; @@ -58,21 +61,20 @@ --header-sm: 16px; --header-xl-weight: 600; --header-weight: 600; - --header-line-height: 3rem; } @media (min-width: 60rem) { :root { --header-xl: 78px; - --header-lg: 36px; + --header-lg: 28px; --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); @@ -100,24 +102,21 @@ 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; } -h4 { +h3 { 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"; } @@ -133,21 +132,23 @@ h4 { text-decoration: underline; } -input[type="text"], -input[type="password"], -textarea { +input[type="text"] { border: 1px solid var(--color-bg); } -input[type="checkbox"] { - height: fit-content; +input[type="text"]: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); +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); } button:hover { @@ -189,4 +190,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 0d39e2c..818d6e3 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -1,196 +1,186 @@ -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"; +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" -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(""); - } +function getPrimaryColor(): string { + const value = getComputedStyle(document.documentElement) + .getPropertyValue('--color-primary') + .trim(); - return value; + const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + return ( + '#' + + [r, g, b] + .map((n) => n.toString(16).padStart(2, '0')) + .join('') + ); + } + + return value; } + interface Props { - 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, -}: Props) { - const [stepState, setStep] = useState(step); - const [rangeState, setRange] = useState(range); + step = 'day', + range = 182, + month = 0, + year = 0, + artistId = 0, + albumId = 0, + trackId = 0, + configurable = false, + autoAdjust = false, + }: Props) { - 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 [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 { theme } = useTheme(); - const color = getPrimaryColor(theme); - if (isPending) { - return ( -
-

Activity

-

Loading...

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

Activity

-

Error: {error.message}

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

Step:

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

Range:

+ {rangePeriods.map((r, i) => ( +
+ + + {i !== rangePeriods.length - 1 ? '|' : ''} +
-
+ ))}
); diff --git a/client/app/components/AlbumDisplay.tsx b/client/app/components/AlbumDisplay.tsx index a7f88e4..6721199 100644 --- a/client/app/components/AlbumDisplay.tsx +++ b/client/app/components/AlbumDisplay.tsx @@ -2,31 +2,24 @@ 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

-
-
- ); -} + return ( +
+
+ + {album.title} + +
+
+ +

{album.title}

+ +

{album.listen_count} plays

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

All Time Stats

+

Loading...

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

Error:{error.message}

+ } + + const numberClasses = 'header-font font-bold text-xl' - if (isPending) { return ( -
-

{header}

-

Loading...

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

{header}

-

Error: {error.message}

+

All Time Stats

+
+ {data.hours_listened} Hours Listened +
+
+ {data.listen_count} Plays +
+
+ {data.artist_count} Artists +
+
+ {data.album_count} Albums +
+
+ {data.track_count} Tracks +
- - ); - } - - 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 -
-
- ); -} + ) +} \ No newline at end of file diff --git a/client/app/components/ArtistAlbums.tsx b/client/app/components/ArtistAlbums.tsx index dda7de8..c95155a 100644 --- a/client/app/components/ArtistAlbums.tsx +++ b/client/app/components/ArtistAlbums.tsx @@ -1,63 +1,51 @@ -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 }: 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), - }); +export default function ArtistAlbums({artistId, name, period}: Props) { - if (isPending) { - return ( -
-

Albums From This Artist

-

Loading...

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

Albums From This Artist

-

Error:{error.message}

-
- ); - } + 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), + }) - 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" : ""} -

+ if (isPending) { + return ( +
+

Albums From This Artist

+

Loading...

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

Albums From This Artist

+

Error:{error.message}

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

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 ace86fd..2bc1cc3 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -1,156 +1,106 @@ -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"; +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" interface Props { - limit: number; - artistId?: Number; - albumId?: Number; - trackId?: number; - hideArtists?: boolean; - showNowPlaying?: boolean; + limit: number + artistId?: Number + albumId?: Number + trackId?: number + hideArtists?: boolean } export default function LastPlays(props: Props) { - const { user } = useAppContext(); - const { isPending, isError, data, error } = useQuery({ - queryKey: [ - "last-listens", - { - limit: props.limit, - period: "all_time", - artist_id: props.artistId, - album_id: props.albumId, - track_id: props.trackId, - }, - ], - queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), - }); - const { data: npData } = useQuery({ - queryKey: ["now-playing"], - queryFn: () => getNowPlaying(), - }); + const { 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 header = "Last played"; + const [items, setItems] = useState(null) - 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); + 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) { + 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}` : '' + return ( -
-

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

+ Last Played +

+ + + {listens.map((item) => ( + + + + + + ))} + +
+ + + {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 0e68c3d..c0269e8 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: 0, title: ''}) + onSelect({id: id, title: title}) } else { setSelected(id) onSelect({id: id, title: title}) } } - if (!data) { + if (data === undefined) { return <> } return (
- { data.artists && data.artists.length > 0 && + { data.artists.length > 0 && <>

Artists

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

Albums

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

Tracks

diff --git a/client/app/components/TopAlbums.tsx b/client/app/components/TopAlbums.tsx index d8a3b00..4ae87bd 100644 --- a/client/app/components/TopAlbums.tsx +++ b/client/app/components/TopAlbums.tsx @@ -1,68 +1,42 @@ -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) { - 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), - }); +export default function TopAlbums (props: Props) { - const header = "Top albums"; + const { isPending, isError, data, error } = useQuery({ + queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }], + queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs), + }) + + if (isPending) { + return ( +
+

Top Albums

+

Loading...

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

Error:{error.message}

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

{header}

-

Loading...

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

{header}

-

Error: {error.message}

-
- ); - } - - return ( -
-

- - {header} - -

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

Top Albums

+
+ + {data.items.length < 1 ? 'Nothing to show' : ''} +
+
+ ) +} \ No newline at end of file diff --git a/client/app/components/TopArtists.tsx b/client/app/components/TopArtists.tsx index a1db871..1c7b719 100644 --- a/client/app/components/TopArtists.tsx +++ b/client/app/components/TopArtists.tsx @@ -1,53 +1,43 @@ -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) { - const { isPending, isError, data, error } = useQuery({ - queryKey: [ - "top-artists", - { limit: props.limit, period: props.period, page: 0 }, - ], - queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs), - }); +export default function TopArtists (props: Props) { - const header = "Top artists"; + const { isPending, isError, data, error } = useQuery({ + queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }], + queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs), + }) + + if (isPending) { + return ( +
+

Top Artists

+

Loading...

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

Error:{error.message}

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

{header}

-

Loading...

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

{header}

-

Error: {error.message}

-
- ); - } - - return ( -
-

- {header} -

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

Top Artists

+
+ + {data.items.length < 1 ? 'Nothing to show' : ''} +
+
+ ) +} \ No newline at end of file diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 4d355b7..22d307c 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -1,171 +1,142 @@ import { Link, useNavigate } from "react-router"; import ArtistLinks from "./ArtistLinks"; -import { - imageUrl, - type Album, - type Artist, - type Track, - type PaginatedResponse, - type Ranked, -} from "api/api"; +import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api"; type Item = Album | Track | Artist; -interface Props> { - data: PaginatedResponse; - separators?: ConstrainBoolean; - ranked?: boolean; - type: "album" | "track" | "artist"; - className?: string; +interface Props { + data: PaginatedResponse + separators?: ConstrainBoolean + type: "album" | "track" | "artist"; + className?: string, } -export default function TopItemList>({ - data, - separators, - type, - className, - ranked, -}: Props) { - return ( -
- {data.items.map((item, index) => { - const key = `${type}-${item.item.id}`; - return ( -
- -
- ); - })} -
- ); +export default function TopItemList({ data, separators, type, className }: Props) { + + 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`; +function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) { - switch (type) { - case "album": { - const album = item as Album; + const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)` - return ( -
- {ranked &&
{rank}
} - - {album.title} - -
- - {album.title} - -
- {album.is_various_artists ? ( - Various Artists - ) : ( -
- -
- )} -
{album.listen_count} plays
-
-
- ); + 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; - return ( -
- {ranked &&
{rank}
} - - {track.title} - -
- - {track.title} - -
-
- -
-
{track.listen_count} plays
-
-
- ); + 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
+
+ +
+ ); + } } - 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 2a9503d..c5136e4 100644 --- a/client/app/components/TopThreeAlbums.tsx +++ b/client/app/components/TopThreeAlbums.tsx @@ -1,43 +1,38 @@ -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), - }); - if (isPending) { - return

Loading...

; - } - if (isError) { - return

Error:{error.message}

; - } + 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), + }) - console.log(data); + if (isPending) { + return

Loading...

+ } + if (isError) { + return

Error:{error.message}

+ } - return ( -
- {!props.hideTitle &&

Top Three Albums

} -
- {data.items.map((item, index) => ( - - ))} -
-
- ); -} + console.log(data) + + return ( +
+ {!props.hideTitle &&

Top Three Albums

} +
+ {data.items.map((item, index) => ( + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/client/app/components/TopTracks.tsx b/client/app/components/TopTracks.tsx index bfe31ca..b1d14c7 100644 --- a/client/app/components/TopTracks.tsx +++ b/client/app/components/TopTracks.tsx @@ -1,69 +1,50 @@ -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 header = "Top tracks"; + const { isPending, isError, data, error } = useQuery({ + queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}], + queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs), + }) + + if (isPending) { + return ( +
+

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}` : '' - if (isPending) { return ( -
-

{header}

-

Loading...

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

{header}

-

Error: {error.message}

-
- ); - } - if (!data.items) return; +
+

Top Tracks

+
+ + {data.items.length < 1 ? 'Nothing to show' : ''} +
+
+ ) +} - 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; +export default TopTracks \ No newline at end of file diff --git a/client/app/components/icons/MbzIcon.tsx b/client/app/components/icons/MbzIcon.tsx deleted file mode 100644 index 1ce66ad..0000000 --- a/client/app/components/icons/MbzIcon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -interface Props { - size: number; - hover?: boolean; -} -export default function MbzIcon({ size, hover }: Props) { - let classNames = ""; - if (hover) { - classNames += "icon-hover-fill"; - } - return ( -
- - - -
- ); -} diff --git a/client/app/components/modals/Account.tsx b/client/app/components/modals/Account.tsx index 562b53d..06d540e 100644 --- a/client/app/components/modals/Account.tsx +++ b/client/app/components/modals/Account.tsx @@ -1,124 +1,106 @@ -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)) + } + }).catch(err => setError(err)) + setLoading(false) } - setError(""); - setSuccess(""); - setLoading(true); - updateUser(username, password) - .then((r) => { - if (r.ok) { - setSuccess("sucessfully updated user"); - if (username != "") { - setCtxUsername(username); - } - setUsername(""); - setPassword(""); - setConfirmPw(""); - } else { - r.json().then((r) => setError(r.error)); + const updateHandler = () => { + setError('') + setSuccess('') + if (password != "" && confirmPw === "") { + setError("confirm your new password before submitting") + return } - }) - .catch((err) => setError(err)); - setLoading(false); - }; + setError('') + setSuccess('') + setLoading(true) + updateUser(username, password) + .then(r => { + if (r.ok) { + setSuccess("sucessfully updated user") + if (username != "") { + setCtxUsername(username) + } + setUsername('') + setPassword('') + setConfirmPw('') + } else { + r.json().then((r) => setError(r.error)) + } + }).catch(err => setError(err)) + setLoading(false) + } - return ( - <> -

Account

-
-
-

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

- - Logout - + 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}

}
-

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}

} -
- - ); -} + + ) +} \ No newline at end of file diff --git a/client/app/components/modals/AddListenModal.tsx b/client/app/components/modals/AddListenModal.tsx deleted file mode 100644 index 4fda1b3..0000000 --- a/client/app/components/modals/AddListenModal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from "react"; -import { Modal } from "./Modal"; -import { AsyncButton } from "../AsyncButton"; -import { submitListen } from "api/api"; -import { useNavigate } from "react-router"; - -interface Props { - open: boolean; - setOpen: Function; - trackid: number; -} - -export default function AddListenModal({ open, setOpen, trackid }: Props) { - const [ts, setTS] = useState(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 c205464..a4bd822 100644 --- a/client/app/components/modals/ApiKeysModal.tsx +++ b/client/app/components/modals/ApiKeysModal.tsx @@ -5,183 +5,172 @@ 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(); - }, - }); - - 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, + 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(); + }, }); - setTimeout(() => setCopied(null), 1500); - }; + useEffect(() => { + if (data) { + setDisplayData(data) + } + }, [data]) - 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 (isError) { + return ( +

Error: {error.message}

+ ) } - document.body.removeChild(textarea); - }; - - const handleCreateApiKey = () => { - setError(undefined); - if (input === "") { - setError("a label must be provided"); - return; + if (isPending) { + return ( +

Loading...

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

{err}

} + {copied?.visible && ( +
+ Copied! +
+ )}
- {err &&

{err}

} - {copied?.visible && ( -
- Copied! -
- )} -
-
- ); -} +
+ ) +} \ No newline at end of file diff --git a/client/app/components/modals/DeleteModal.tsx b/client/app/components/modals/DeleteModal.tsx index 227951e..98304ad 100644 --- a/client/app/components/modals/DeleteModal.tsx +++ b/client/app/components/modals/DeleteModal.tsx @@ -1,41 +1,40 @@ -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(-1); - } else { - console.log(r); - } - }); - }; + const doDelete = () => { + setLoading(true) + deleteItem(type.toLowerCase(), id) + .then(r => { + if (r.ok) { + navigate('/') + } else { + console.log(r) + } + }) + } - return ( - setOpen(false)}> -

Delete "{title}"?

-

This action is irreversible!

-
- - Yes, Delete It - -
-
- ); -} + return ( + setOpen(false)}> +

Delete "{title}"?

+

This action is irreversible!

+
+ Yes, Delete It +
+
+ ) +} \ No newline at end of file diff --git a/client/app/components/modals/EditModal/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx deleted file mode 100644 index a5c981e..0000000 --- a/client/app/components/modals/EditModal/EditModal.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - createAlias, - deleteAlias, - getAliases, - setPrimaryAlias, - updateMbzId, - type Alias, -} from "api/api"; -import { Modal } from "../Modal"; -import { AsyncButton } from "../../AsyncButton"; -import { useEffect, useState } from "react"; -import { Trash } from "lucide-react"; -import SetVariousArtists from "./SetVariousArtist"; -import SetPrimaryArtist from "./SetPrimaryArtist"; -import UpdateMbzID from "./UpdateMbzID"; - -interface Props { - type: string; - id: number; - open: boolean; - setOpen: Function; -} - -export default function EditModal({ open, setOpen, type, id }: Props) { - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [err, setError] = useState(); - 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 deleted file mode 100644 index e91b083..0000000 --- a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getAlbum, type Artist } from "api/api"; -import { useEffect, useState } from "react"; - -interface Props { - id: number; - type: string; -} - -export default function SetPrimaryArtist({ id, type }: Props) { - const [err, setErr] = useState(""); - const [primary, setPrimary] = useState(); - 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 deleted file mode 100644 index bf9e3d3..0000000 --- a/client/app/components/modals/EditModal/SetVariousArtist.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getAlbum } from "api/api"; -import { useEffect, useState } from "react"; - -interface Props { - id: number; -} - -export default function SetVariousArtists({ id }: Props) { - const [err, setErr] = useState(""); - const [va, setVA] = useState(false); - const [success, setSuccess] = useState(""); - - const { isPending, isError, data, error } = useQuery({ - queryKey: [ - "get-album", - { - id: id, - }, - ], - queryFn: ({ queryKey }) => { - const params = queryKey[1] as { id: number }; - return getAlbum(params.id); - }, - }); - - useEffect(() => { - if (data) { - setVA(data.is_various_artists); - } - }, [data]); - - if (isError) { - return

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 deleted file mode 100644 index 0654cc1..0000000 --- a/client/app/components/modals/EditModal/UpdateMbzID.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { updateMbzId } from "api/api"; -import { useState } from "react"; -import { AsyncButton } from "~/components/AsyncButton"; - -interface Props { - type: string; - id: number; -} - -export default function UpdateMbzID({ type, id }: Props) { - const [err, setError] = useState(); - 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 deleted file mode 100644 index d83d7d4..0000000 --- a/client/app/components/modals/ExportModal.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from "react"; -import { AsyncButton } from "../AsyncButton"; -import { getExport } from "api/api"; - -export default function ExportModal() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - - const handleExport = () => { - setLoading(true); - fetch(`/apis/web/v1/export`, { - method: "GET", - }) - .then((res) => { - if (res.ok) { - res.blob().then((blob) => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "koito_export.json"; - document.body.appendChild(a); - a.click(); - a.remove(); - window.URL.revokeObjectURL(url); - setLoading(false); - }); - } else { - res.json().then((r) => setError(r.error)); - setLoading(false); - } - }) - .catch((err) => { - setError(err); - setLoading(false); - }); - }; - - return ( -
-

Export

- - Export Data - - {error &&

{error}

} -
- ); -} diff --git a/client/app/components/modals/ImageReplaceModal.tsx b/client/app/components/modals/ImageReplaceModal.tsx index 11319b7..d76dd61 100644 --- a/client/app/components/modals/ImageReplaceModal.tsx +++ b/client/app/components/modals/ImageReplaceModal.tsx @@ -5,111 +5,86 @@ 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 [error, setError] = useState(""); - 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 [suggestedImgLoading, setSuggestedImgLoading] = useState(true) - 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 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 closeModal = () => { - setOpen(false); - setQuery(""); - setError(""); - }; + const closeModal = () => { + setOpen(false) + setQuery('') + } - return ( - -

Replace Image

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

Suggested Image (Click to Apply)

- - - ) : ( - "" - )} -

{error}

-
-
- ); -} + { query != "" ? +
+ doImageReplace(query)}>Submit +
: + ''} + { type === "Album" && musicbrainzId ? + <> +

Suggested Image (Click to Apply)

+ + + : '' + } +
+ + ) +} \ No newline at end of file diff --git a/client/app/components/modals/LoginForm.tsx b/client/app/components/modals/LoginForm.tsx index 1078476..2c2afc6 100644 --- a/client/app/components/modals/LoginForm.tsx +++ b/client/app/components/modals/LoginForm.tsx @@ -1,74 +1,59 @@ -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}

-
- - ); -} + 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 diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx index c78681d..ff1079b 100644 --- a/client/app/components/modals/MergeModal.tsx +++ b/client/app/components/modals/MergeModal.tsx @@ -2,159 +2,124 @@ 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(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 [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 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 closeMergeModal = () => { + props.setOpen(false) + setQuery('') + setData(undefined) + setMergeOrderReversed(false) + setMergeTarget({title: '', id: 0}) } - 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(); - } + + const toggleSelect = ({title, id}: {title: string, id: number}) => { + if (mergeTarget.id === 0) { + setMergeTarget({title: title, id: id}) } else { - // TODO: handle error - console.log(r); + setMergeTarget({title:"", id: 0}) } - }) - .catch((err) => console.log(err)); - }; - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedQuery(query); - if (query === "") { - setData(undefined); - } - }, 300); - - return () => { - clearTimeout(handler); - }; - }, [query]); - - useEffect(() => { - if (debouncedQuery) { - search(debouncedQuery).then((r) => { - r = props.mergeCleanerFunc(r, props.currentId); - setData(r); - }); } - }, [debouncedQuery]); - return ( + useEffect(() => { + console.log(mergeTarget) + }, [mergeTarget]) + + const doMerge = () => { + let from, to + if (!mergeOrderReversed) { + from = mergeTarget + to = {id: props.currentId, title: props.currentTitle} + } else { + from = {id: props.currentId, title: props.currentTitle} + to = mergeTarget + } + props.mergeFunc(from.id, to.id) + .then(r => { + if (r.ok) { + if (mergeOrderReversed) { + navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`) + closeMergeModal() + } else { + window.location.reload() + } + } else { + // TODO: handle error + console.log(r) + } + }) + .catch((err) => console.log(err)) + } + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedQuery(query); + if (query === '') { + setData(undefined) + } + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [query]); + + useEffect(() => { + if (debouncedQuery) { + search(debouncedQuery).then((r) => { + r = props.mergeCleanerFunc(r, props.currentId) + setData(r); + }); + } + }, [debouncedQuery]); + + return ( -

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

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)} /> +
- {(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 fc6ce67..47307b0 100644 --- a/client/app/components/modals/Modal.tsx +++ b/client/app/components/modals/Modal.tsx @@ -32,34 +32,10 @@ export function Modal({ } }, [isOpen, shouldRender]); - // Handle keyboard events + // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // 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 (e.key === 'Escape') onClose(); }; if (isOpen) document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); @@ -94,13 +70,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 new file mode 100644 index 0000000..4a53ae6 --- /dev/null +++ b/client/app/components/modals/RenameModal.tsx @@ -0,0 +1,124 @@ +import { useQuery } from "@tanstack/react-query"; +import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api"; +import { Modal } from "./Modal"; +import { AsyncButton } from "../AsyncButton"; +import { useEffect, useState } from "react"; +import { Trash } from "lucide-react"; + +interface Props { + type: string + id: number + open: boolean + setOpen: Function +} + +export default function RenameModal({ open, setOpen, type, id }: Props) { + const [input, setInput] = useState('') + const [loading, setLoading ] = useState(false) + const [err, setError ] = useState() + 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 80c95dc..ec056cf 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); - }; - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedQuery(query); - if (query === "") { - setData(undefined); - } - }, 300); - - return () => { - clearTimeout(handler); - }; - }, [query]); - - useEffect(() => { - if (debouncedQuery) { - search(debouncedQuery).then((r) => { - setData(r); - }); + const closeSearchModal = () => { + setOpen(false) + setQuery('') + setData(undefined) } - }, [debouncedQuery]); - return ( - -

Search

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

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 deleted file mode 100644 index 5ccec87..0000000 --- a/client/app/components/rewind/RewindStatText.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 5093768..0000000 --- a/client/app/components/rewind/RewindTopItem.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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 2bd88f3..1a42e67 100644 --- a/client/app/components/sidebar/Sidebar.tsx +++ b/client/app/components/sidebar/Sidebar.tsx @@ -1,73 +1,55 @@ -import { ExternalLink, History, Home, Info } from "lucide-react"; +import { ExternalLink, 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={<>} - > - - - - {}} - modal={<>} - > - - -
-
- } - space={22} - externalLink - to="https://koito.io" - name="About" - onClick={() => {}} - 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 7c0166b..224fcce 100644 --- a/client/app/components/themeSwitcher/ThemeOption.tsx +++ b/client/app/components/themeSwitcher/ThemeOption.tsx @@ -1,43 +1,22 @@ -import type { Theme } from "~/styles/themes.css"; +import type { Theme } from "~/providers/ThemeProvider"; interface Props { - theme: Theme; - themeName: string; - setTheme: Function; + theme: Theme + setTheme: Function } -export default function ThemeOption({ theme, themeName, setTheme }: Props) { - const capitalizeFirstLetter = (s: string) => { - return s.charAt(0).toUpperCase() + s.slice(1); - }; +export default function ThemeOption({ theme, setTheme }: Props) { - 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)} -
-
-
-
-
-
-
- ); -} + 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 diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index f27d41c..e051f50 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -1,78 +1,36 @@ -import { useState } from "react"; -import { useTheme } from "../../hooks/useTheme"; -import themes from "~/styles/themes.css"; -import ThemeOption from "./ThemeOption"; -import { AsyncButton } from "../AsyncButton"; +// ThemeSwitcher.tsx +import { useEffect } from 'react'; +import { useTheme } from '../../hooks/useTheme'; +import { themes } from '~/providers/ThemeProvider'; +import ThemeOption from './ThemeOption'; export function ThemeSwitcher() { - 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 { theme, setTheme } = useTheme(); - const { setCustomTheme, getCustomTheme, resetTheme } = useTheme(); - const [custom, setCustom] = useState( - JSON.stringify(getCustomTheme() ?? initialTheme, null, " ") - ); - 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(() => { + const saved = localStorage.getItem('theme'); + if (saved && saved !== theme) { + setTheme(saved); + } else if (!saved) { + localStorage.setItem('theme', theme) + } + }, []); - return ( -
-
-
-

Select Theme

-
- Reset -
+ useEffect(() => { + if (theme) { + localStorage.setItem('theme', theme) + } + }, [theme]); + + return ( + <> +

Select Theme

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

Use Custom Theme

-
-