Compare commits

..

No commits in common. "main" and "v1.26.1" have entirely different histories.

125 changed files with 23257 additions and 13016 deletions

View file

@ -19,6 +19,5 @@
!frontend/tsconfig.json !frontend/tsconfig.json
!frontend/vite.config.js !frontend/vite.config.js
!frontend/index.html !frontend/index.html
!frontend/*.d.ts
!frontend/src/**/* !frontend/src/**/*
!frontend/public/**/* !frontend/public/**/*

View file

@ -3,18 +3,27 @@ name: CI
on: on:
push: push:
workflow_call: workflow_call:
pull_request:
jobs: jobs:
api: api:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: npm Cache
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 18
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@ -30,12 +39,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: npm Cache
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 16
- run: npm ci - run: npm ci

View file

@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Install chart-releaser - name: Install chart-releaser
env: env:

View file

@ -15,27 +15,27 @@ jobs:
needs: tests needs: tests
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v2
with: with:
version: "v0.11.2" version: "v0.10.4"
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 18
- name: Install Helm - name: Install Helm
uses: azure/setup-helm@v3 uses: azure/setup-helm@v3
with: with:
version: "v3.11.1" version: "v3.9.0"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}

View file

@ -1,66 +1,3 @@
# [1.31.0](https://github.com/apricote/Listory/compare/v1.30.1...v1.31.0) (2023-10-15)
### Features
* **frontend:** hide revoked api tokens ([#307](https://github.com/apricote/Listory/issues/307)) ([4cef4f7](https://github.com/apricote/Listory/commit/4cef4f75ace6a38ba19c1d2f93d81389ec2b7cb8))
## [1.30.1](https://github.com/apricote/Listory/compare/v1.30.0...v1.30.1) (2023-10-08)
### Bug Fixes
* no listens are being crawled ([#306](https://github.com/apricote/Listory/issues/306)) ([9af3115](https://github.com/apricote/Listory/commit/9af3115cab19cc4ac4a6cd0fb680371154069aa2))
# [1.30.0](https://github.com/apricote/Listory/compare/v1.29.0...v1.30.0) (2023-10-01)
### Features
* import listens from spotify extended streaming history ([#305](https://github.com/apricote/Listory/issues/305)) ([7140cb0](https://github.com/apricote/Listory/commit/7140cb0679ec3aac8a2102197d9edb070cf0e6c0))
# [1.29.0](https://github.com/apricote/Listory/compare/v1.28.2...v1.29.0) (2023-09-30)
### Features
* **frontend:** general revamp of navigation & pages ([#303](https://github.com/apricote/Listory/issues/303)) ([4b1dd10](https://github.com/apricote/Listory/commit/4b1dd10846d741cd39fe9b0f41150e68510f2220))
## [1.28.2](https://github.com/apricote/Listory/compare/v1.28.1...v1.28.2) (2023-09-18)
### Bug Fixes
* slow query taking up 66% of db time ([#298](https://github.com/apricote/Listory/issues/298)) ([625db7d](https://github.com/apricote/Listory/commit/625db7dbe71a7315921562bfab82420c04aa6c17))
## [1.28.1](https://github.com/apricote/Listory/compare/v1.28.0...v1.28.1) (2023-09-16)
### Bug Fixes
* invalid database migration ([9ba47a5](https://github.com/apricote/Listory/commit/9ba47a560c9c98866cd2dc34c3997f96f027f65e))
# [1.28.0](https://github.com/apricote/Listory/compare/v1.27.0...v1.28.0) (2023-09-16)
### Features
* optimize db queries ([#297](https://github.com/apricote/Listory/issues/297)) ([dd57a52](https://github.com/apricote/Listory/commit/dd57a52ab66684e713c5d0766a8fed281b472e40))
# [1.27.0](https://github.com/apricote/Listory/compare/v1.26.2...v1.27.0) (2023-09-16)
### Features
* improve listens report response time ([89440da](https://github.com/apricote/Listory/commit/89440daf7ba38ff97fabd19cf3a9d11ab21efb45))
## [1.26.2](https://github.com/apricote/Listory/compare/v1.26.1...v1.26.2) (2023-09-10)
### Bug Fixes
* failing healthcheck for spotify api ([6898687](https://github.com/apricote/Listory/commit/689868798dbbcf2e3c0077ece154b5511edd73c4))
## [1.26.1](https://github.com/apricote/Listory/compare/v1.26.0...v1.26.1) (2023-05-07) ## [1.26.1](https://github.com/apricote/Listory/compare/v1.26.0...v1.26.1) (2023-05-07)

View file

@ -1,4 +1,9 @@
# syntax=docker/dockerfile:1.12 # syntax=docker/dockerfile:1.5
FROM scratch as ignore
WORKDIR /listory
COPY . /listory/
################## ##################
## common ## common

View file

@ -14,8 +14,8 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
version: 1.31.0 version: 1.26.1
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. # incremented each time you make changes to the application.
appVersion: 1.31.0 appVersion: 1.26.1

View file

@ -6,7 +6,7 @@ services:
##### #####
db: db:
image: postgres:16.6 image: postgres:15.2
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_PASSWORD: listory POSTGRES_PASSWORD: listory
@ -18,7 +18,7 @@ services:
- db - db
api: api:
image: apricote/listory:1.31.0 image: apricote/listory:1.26.1
restart: unless-stopped restart: unless-stopped
environment: environment:
DB_HOST: db DB_HOST: db
@ -32,7 +32,7 @@ services:
# make sure to restart the container if you made any changes. # make sure to restart the container if you made any changes.
env_file: .env env_file: .env
ports: ports:
- "3000:3000" # API - 3000:3000 # API
networks: networks:
- web - web
- db - db

View file

@ -12,7 +12,7 @@ services:
##### #####
db: db:
image: postgres:16.6 image: postgres:15.2
environment: environment:
POSTGRES_PASSWORD: listory POSTGRES_PASSWORD: listory
POSTGRES_USER: listory POSTGRES_USER: listory
@ -37,8 +37,7 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318 OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
env_file: .env env_file: .env
volumes: volumes:
- ./src:/app/src:ro - ./src:/app/src
- ./dist:/app/dist # build cache
ports: ports:
- 3000 # API - 3000 # API
- "9464:9464" # Metrics - "9464:9464" # Metrics
@ -73,7 +72,7 @@ services:
- web - web
proxy: proxy:
image: traefik:v2.11.15 image: traefik:v2.10.1
command: command:
#- --log.level=debug #- --log.level=debug
#- --accesslog=true #- --accesslog=true
@ -116,8 +115,7 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318 OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
env_file: .env env_file: .env
volumes: volumes:
- ./src:/app/src:ro - ./src:/app/src
- ./dist:/app/dist # build cache
ports: ports:
- "9464:9464" # Metrics - "9464:9464" # Metrics
networks: networks:
@ -131,7 +129,7 @@ services:
prometheus: prometheus:
profiles: ["observability"] profiles: ["observability"]
image: prom/prometheus:v2.55.1 image: prom/prometheus:v2.43.1
volumes: volumes:
- ./observability/prometheus:/etc/prometheus - ./observability/prometheus:/etc/prometheus
- prometheus_data:/prometheus - prometheus_data:/prometheus
@ -141,14 +139,14 @@ services:
- "--storage.tsdb.retention.time=200h" - "--storage.tsdb.retention.time=200h"
- "--web.enable-lifecycle" - "--web.enable-lifecycle"
ports: ports:
- "9090:9090" - 9090:9090
networks: networks:
- observability - observability
- web - web
loki: loki:
profiles: ["observability"] profiles: ["observability"]
image: grafana/loki:2.9.11 image: grafana/loki:2.8.2
command: ["-config.file=/etc/loki/loki.yaml"] command: ["-config.file=/etc/loki/loki.yaml"]
ports: ports:
- "3100" # loki needs to be exposed so it receives logs - "3100" # loki needs to be exposed so it receives logs
@ -159,7 +157,7 @@ services:
promtail: promtail:
profiles: ["observability"] profiles: ["observability"]
image: grafana/promtail:2.9.11 image: grafana/promtail:2.8.2
command: ["-config.file=/etc/promtail.yaml"] command: ["-config.file=/etc/promtail.yaml"]
volumes: volumes:
- ./observability/promtail/promtail.yaml:/etc/promtail.yaml - ./observability/promtail/promtail.yaml:/etc/promtail.yaml
@ -177,7 +175,7 @@ services:
tempo: tempo:
profiles: ["observability"] profiles: ["observability"]
image: grafana/tempo:2.6.1 image: grafana/tempo:2.1.1
command: ["-config.file=/etc/tempo.yaml"] command: ["-config.file=/etc/tempo.yaml"]
volumes: volumes:
- ./observability/tempo/tempo.yaml:/etc/tempo.yaml - ./observability/tempo/tempo.yaml:/etc/tempo.yaml
@ -191,7 +189,7 @@ services:
grafana: grafana:
profiles: ["observability"] profiles: ["observability"]
image: grafana/grafana-oss:10.4.14 image: grafana/grafana-oss:9.5.1
volumes: volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning - ./observability/grafana/provisioning:/etc/grafana/provisioning
environment: environment:
@ -202,7 +200,7 @@ services:
- GF_USERS_ALLOW_SIGN_UP=false - GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_HTTP_PORT=2345 - GF_SERVER_HTTP_PORT=2345
ports: ports:
- "2345:2345" - 2345:2345
networks: networks:
- observability - observability

44
frontend/README.md Normal file
View file

@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

View file

@ -1,16 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "./src/index.css",
"baseColor": "gray",
"cssVariables": false
},
"aliases": {
"components": "src/components",
"utils": "src/lib/utils"
}
}

13622
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,44 +8,32 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "1.1.2", "@testing-library/jest-dom": "5.16.5",
"@radix-ui/react-dropdown-menu": "2.1.3", "@testing-library/react": "14.0.0",
"@radix-ui/react-label": "2.1.1", "@testing-library/user-event": "14.4.3",
"@radix-ui/react-navigation-menu": "1.2.2", "@types/jest": "29.5.1",
"@radix-ui/react-select": "2.1.3", "@types/node": "18.16.5",
"@radix-ui/react-slot": "1.1.1", "@types/react": "18.2.6",
"@testing-library/jest-dom": "6.6.3", "@types/react-dom": "18.2.4",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
"@types/node": "20.17.16",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react-router-dom": "5.3.3", "@types/react-router-dom": "5.3.3",
"@types/recharts": "1.8.29", "@types/recharts": "1.8.24",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.0.0",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.14",
"axios": "1.7.9", "axios": "0.27.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"eslint-config-react-app": "7.0.1", "eslint-config-react-app": "7.0.1",
"jsdom": "22.1.0", "jsdom": "22.0.0",
"lucide-react": "0.468.0",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"postcss": "8.4.49", "postcss": "8.4.23",
"prettier": "3.4.2", "prettier": "2.8.8",
"react": "18.3.1", "react": "18.2.0",
"react-dom": "18.3.1", "react-dom": "18.2.0",
"react-files": "3.0.3", "react-router-dom": "6.11.1",
"react-router-dom": "6.28.0", "recharts": "2.5.0",
"recharts": "2.15.0", "tailwindcss": "3.3.2",
"tailwind-merge": "1.14.0", "typescript": "5.0.4",
"tailwindcss": "3.4.16", "vite": "4.3.5",
"tailwindcss-animate": "1.0.7", "vitest": "0.31.0"
"typescript": "5.7.2",
"vite": "5.4.12",
"vitest": "1.6.0"
}, },
"scripts": { "scripts": {
"format": "prettier --write \"./*.js\" \"src/**/*.(tsx|ts|css)\"", "format": "prettier --write \"./*.js\" \"src/**/*.(tsx|ts|css)\"",

View file

@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; }

View file

@ -2,21 +2,20 @@ import React from "react";
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from "react-router-dom";
import { AuthApiTokens } from "./components/AuthApiTokens"; import { AuthApiTokens } from "./components/AuthApiTokens";
import { Footer } from "./components/Footer"; import { Footer } from "./components/Footer";
import { ImportListens } from "./components/ImportListens";
import { LoginFailure } from "./components/LoginFailure"; import { LoginFailure } from "./components/LoginFailure";
import { LoginLoading } from "./components/LoginLoading"; import { LoginLoading } from "./components/LoginLoading";
import { LoginSuccess } from "./components/LoginSuccess";
import { NavBar } from "./components/NavBar"; import { NavBar } from "./components/NavBar";
import { Navigate } from "react-router-dom"; import { RecentListens } from "./components/RecentListens";
import { RecentListens } from "./components/reports/RecentListens"; import { ReportListens } from "./components/ReportListens";
import { ReportListens } from "./components/reports/ReportListens"; import { ReportTopAlbums } from "./components/ReportTopAlbums";
import { ReportTopAlbums } from "./components/reports/ReportTopAlbums"; import { ReportTopArtists } from "./components/ReportTopArtists";
import { ReportTopArtists } from "./components/reports/ReportTopArtists"; import { ReportTopGenres } from "./components/ReportTopGenres";
import { ReportTopGenres } from "./components/reports/ReportTopGenres"; import { ReportTopTracks } from "./components/ReportTopTracks";
import { ReportTopTracks } from "./components/reports/ReportTopTracks";
import { useAuth } from "./hooks/use-auth"; import { useAuth } from "./hooks/use-auth";
export function App() { export function App() {
const { isLoaded, user } = useAuth(); const { isLoaded } = useAuth();
if (!isLoaded) { if (!isLoaded) {
return <LoginLoading />; return <LoginLoading />;
@ -28,43 +27,18 @@ export function App() {
<NavBar /> <NavBar />
</header> </header>
<main className="mb-auto" /* mb-auto is for sticky footer */> <main className="mb-auto" /* mb-auto is for sticky footer */>
<div className="md:flex md:justify-center p-4 text-gray-700 dark:text-gray-400"> <Routes>
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 lg:max-w-screen-lg"> <Route path="/" />
{user && ( <Route path="/login/success" element={<LoginSuccess />} />
<Routes> <Route path="/login/failure" element={<LoginFailure />} />
<Route index element={<Navigate to="/listens" />} /> <Route path="/listens" element={<RecentListens />} />
<Route path="/login/success" element={<Navigate to="/" />} /> <Route path="/reports/listens" element={<ReportListens />} />
<Route path="/login/failure" element={<LoginFailure />} /> <Route path="/reports/top-artists" element={<ReportTopArtists />} />
<Route path="/listens" element={<RecentListens />} /> <Route path="/reports/top-albums" element={<ReportTopAlbums />} />
<Route path="/reports/listens" element={<ReportListens />} /> <Route path="/reports/top-tracks" element={<ReportTopTracks />} />
<Route <Route path="/reports/top-genres" element={<ReportTopGenres />} />
path="/reports/top-artists" <Route path="/auth/api-tokens" element={<AuthApiTokens />} />
element={<ReportTopArtists />} </Routes>
/>
<Route
path="/reports/top-albums"
element={<ReportTopAlbums />}
/>
<Route
path="/reports/top-tracks"
element={<ReportTopTracks />}
/>
<Route
path="/reports/top-genres"
element={<ReportTopGenres />}
/>
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
<Route path="/import" element={<ImportListens />} />
</Routes>
)}
{!user && (
<Routes>
<Route index />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
)}
</div>
</div>
</main> </main>
<footer> <footer>
<Footer /> <Footer />

View file

@ -14,14 +14,12 @@ import { TopGenresItem } from "./entities/top-genres-item";
import { TopGenresOptions } from "./entities/top-genres-options"; import { TopGenresOptions } from "./entities/top-genres-options";
import { TopTracksItem } from "./entities/top-tracks-item"; import { TopTracksItem } from "./entities/top-tracks-item";
import { TopTracksOptions } from "./entities/top-tracks-options"; import { TopTracksOptions } from "./entities/top-tracks-options";
import { SpotifyExtendedStreamingHistoryItem } from "./entities/spotify-extended-streaming-history-item";
import { ExtendedStreamingHistoryStatus } from "./entities/extended-streaming-history-status";
export class UnauthenticatedError extends Error {} export class UnauthenticatedError extends Error {}
export const getRecentListens = async ( export const getRecentListens = async (
options: PaginationOptions = { page: 1, limit: 10 }, options: PaginationOptions = { page: 1, limit: 10 },
client: AxiosInstance, client: AxiosInstance
): Promise<Pagination<Listen>> => { ): Promise<Pagination<Listen>> => {
const { page, limit } = options; const { page, limit } = options;
@ -46,7 +44,7 @@ export const getRecentListens = async (
export const getListensReport = async ( export const getListensReport = async (
options: ListenReportOptions, options: ListenReportOptions,
client: AxiosInstance, client: AxiosInstance
): Promise<ListenReportItem[]> => { ): Promise<ListenReportItem[]> => {
const { const {
timeFrame, timeFrame,
@ -62,7 +60,7 @@ export const getListensReport = async (
customTimeStart: formatISO(customTimeStart), customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd), customTimeEnd: formatISO(customTimeEnd),
}, },
}, }
); );
switch (res.status) { switch (res.status) {
@ -85,7 +83,7 @@ export const getListensReport = async (
export const getTopArtists = async ( export const getTopArtists = async (
options: TopArtistsOptions, options: TopArtistsOptions,
client: AxiosInstance, client: AxiosInstance
): Promise<TopArtistsItem[]> => { ): Promise<TopArtistsItem[]> => {
const { const {
time: { timePreset, customTimeStart, customTimeEnd }, time: { timePreset, customTimeStart, customTimeEnd },
@ -99,7 +97,7 @@ export const getTopArtists = async (
customTimeStart: formatISO(customTimeStart), customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd), customTimeEnd: formatISO(customTimeEnd),
}, },
}, }
); );
switch (res.status) { switch (res.status) {
@ -122,7 +120,7 @@ export const getTopArtists = async (
export const getTopAlbums = async ( export const getTopAlbums = async (
options: TopAlbumsOptions, options: TopAlbumsOptions,
client: AxiosInstance, client: AxiosInstance
): Promise<TopAlbumsItem[]> => { ): Promise<TopAlbumsItem[]> => {
const { const {
time: { timePreset, customTimeStart, customTimeEnd }, time: { timePreset, customTimeStart, customTimeEnd },
@ -136,7 +134,7 @@ export const getTopAlbums = async (
customTimeStart: formatISO(customTimeStart), customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd), customTimeEnd: formatISO(customTimeEnd),
}, },
}, }
); );
switch (res.status) { switch (res.status) {
@ -159,7 +157,7 @@ export const getTopAlbums = async (
export const getTopTracks = async ( export const getTopTracks = async (
options: TopTracksOptions, options: TopTracksOptions,
client: AxiosInstance, client: AxiosInstance
): Promise<TopTracksItem[]> => { ): Promise<TopTracksItem[]> => {
const { const {
time: { timePreset, customTimeStart, customTimeEnd }, time: { timePreset, customTimeStart, customTimeEnd },
@ -173,7 +171,7 @@ export const getTopTracks = async (
customTimeStart: formatISO(customTimeStart), customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd), customTimeEnd: formatISO(customTimeEnd),
}, },
}, }
); );
switch (res.status) { switch (res.status) {
@ -196,7 +194,7 @@ export const getTopTracks = async (
export const getTopGenres = async ( export const getTopGenres = async (
options: TopGenresOptions, options: TopGenresOptions,
client: AxiosInstance, client: AxiosInstance
): Promise<TopGenresItem[]> => { ): Promise<TopGenresItem[]> => {
const { const {
time: { timePreset, customTimeStart, customTimeEnd }, time: { timePreset, customTimeStart, customTimeEnd },
@ -210,7 +208,7 @@ export const getTopGenres = async (
customTimeStart: formatISO(customTimeStart), customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd), customTimeEnd: formatISO(customTimeEnd),
}, },
}, }
); );
switch (res.status) { switch (res.status) {
@ -232,7 +230,7 @@ export const getTopGenres = async (
}; };
export const getApiTokens = async ( export const getApiTokens = async (
client: AxiosInstance, client: AxiosInstance
): Promise<ApiToken[]> => { ): Promise<ApiToken[]> => {
const res = await client.get<ApiToken[]>(`/api/v1/auth/api-tokens`); const res = await client.get<ApiToken[]>(`/api/v1/auth/api-tokens`);
@ -253,7 +251,7 @@ export const getApiTokens = async (
export const createApiToken = async ( export const createApiToken = async (
description: string, description: string,
client: AxiosInstance, client: AxiosInstance
): Promise<NewApiToken> => { ): Promise<NewApiToken> => {
const res = await client.post<NewApiToken>(`/api/v1/auth/api-tokens`, { const res = await client.post<NewApiToken>(`/api/v1/auth/api-tokens`, {
description, description,
@ -276,9 +274,9 @@ export const createApiToken = async (
export const revokeApiToken = async ( export const revokeApiToken = async (
id: string, id: string,
client: AxiosInstance, client: AxiosInstance
): Promise<void> => { ): Promise<void> => {
const res = await client.delete(`/api/v1/auth/api-tokens/${id}`); const res = await client.delete<NewApiToken>(`/api/v1/auth/api-tokens/${id}`);
switch (res.status) { switch (res.status) {
case 200: { case 200: {
@ -292,50 +290,3 @@ export const revokeApiToken = async (
} }
} }
}; };
export const importExtendedStreamingHistory = async (
listens: SpotifyExtendedStreamingHistoryItem[],
client: AxiosInstance
): Promise<void> => {
const res = await client.post(`/api/v1/import/extended-streaming-history`, {
listens,
});
switch (res.status) {
case 201: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(
`Unable to importExtendedStreamingHistory: ${res.status}`
);
}
}
};
export const getExtendedStreamingHistoryStatus = async (
client: AxiosInstance
): Promise<ExtendedStreamingHistoryStatus> => {
const res = await client.get<ExtendedStreamingHistoryStatus>(
`/api/v1/import/extended-streaming-history/status`
);
switch (res.status) {
case 200: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(
`Unable to getExtendedStreamingHistoryStatus: ${res.status}`
);
}
}
return res.data;
};

View file

@ -1,4 +0,0 @@
export interface ExtendedStreamingHistoryStatus {
total: number;
imported: number;
}

View file

@ -1,4 +0,0 @@
export interface SpotifyExtendedStreamingHistoryItem {
ts: string;
spotify_track_uri: string;
}

View file

@ -2,61 +2,65 @@ import { format, formatDistanceToNow } from "date-fns";
import React, { FormEvent, useCallback, useMemo, useState } from "react"; import React, { FormEvent, useCallback, useMemo, useState } from "react";
import { ApiToken, NewApiToken } from "../api/entities/api-token"; import { ApiToken, NewApiToken } from "../api/entities/api-token";
import { useApiTokens } from "../hooks/use-api"; import { useApiTokens } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { SpinnerIcon } from "../icons/Spinner"; import { SpinnerIcon } from "../icons/Spinner";
import TrashcanIcon from "../icons/Trashcan"; import TrashcanIcon from "../icons/Trashcan";
import { Spinner } from "./ui/Spinner"; import { Spinner } from "./Spinner";
export const AuthApiTokens: React.FC = () => { export const AuthApiTokens: React.FC = () => {
const { requireUser } = useAuthProtection();
const { apiTokens, isLoading, createToken, revokeToken } = useApiTokens(); const { apiTokens, isLoading, createToken, revokeToken } = useApiTokens();
const sortedTokens = useMemo( const sortedTokens = useMemo(
() => () => apiTokens.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
apiTokens [apiTokens]
.filter((token) => !token.revokedAt)
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
[apiTokens],
); );
requireUser();
return ( return (
<> <div className="md:flex md:justify-center p-4 text-gray-700 dark:text-gray-400">
<div className="flex justify-between"> <div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<p className="text-2xl font-normal">API Tokens</p> <div className="flex justify-between">
</div> <p className="text-2xl font-normal">API Tokens</p>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
<p className="mb-4">
You can use API Tokens to access the Listory API directly. You can
find the API docs{" "}
<a href="/api/docs" target="_blank" className={"underline"}>
here
</a>
.
</p>
<div className="mb-4">
<NewTokenForm createToken={createToken} />
</div> </div>
<div> <div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
<h3 className="text-xl">Manage Existing Tokens</h3> <p className="mb-4">
{isLoading && <Spinner className="m-8" />} You can use API Tokens to access the Listory API directly. You can
{sortedTokens.length === 0 && ( find the API docs{" "}
<div className="text-center m-4"> <a href="/api/docs" target="_blank">
<p className="">Could not find any api tokens!</p> here
</div> </a>
)} .
</p>
<div className="mb-4">
<NewTokenForm createToken={createToken} />
</div>
<div> <div>
{sortedTokens.length > 0 && ( <h3 className="text-xl">Manage Existing Tokens</h3>
<div className="table-auto w-full"> {isLoading && <Spinner className="m-8" />}
{sortedTokens.map((apiToken) => ( {sortedTokens.length === 0 && (
<ApiTokenItem <div className="text-center m-4">
apiToken={apiToken} <p className="">Could not find any api tokens!</p>
revokeToken={revokeToken}
key={apiToken.id}
/>
))}
</div> </div>
)} )}
<div>
{sortedTokens.length > 0 && (
<div className="table-auto w-full">
{sortedTokens.map((apiToken) => (
<ApiTokenItem
apiToken={apiToken}
revokeToken={revokeToken}
key={apiToken.id}
/>
))}
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
</> </div>
); );
}; };
@ -93,7 +97,7 @@ const NewTokenForm: React.FC<{
createToken, createToken,
setNewToken, setNewToken,
setNewTokenDescription, setNewTokenDescription,
], ]
); );
return ( return (

View file

@ -1,280 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import Files from "react-files";
import type { ReactFile } from "react-files";
import {
useSpotifyImportExtendedStreamingHistory,
useSpotifyImportExtendedStreamingHistoryStatus,
} from "../hooks/use-api";
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
import { ErrorIcon } from "../icons/Error";
import { numberToPercent } from "../util/numberToPercent";
import { Button } from "./ui/button";
import { Table, TableBody, TableCell, TableRow } from "./ui/table";
import { Badge } from "./ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Code } from "./ui/code";
export const ImportListens: React.FC = () => {
return (
<>
<div className="flex justify-between">
<p className="text-2xl font-normal">
Import Listens from Spotify Extended Streaming History
</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
<p className="my-4">
Here you can import your full Spotify Listen history that was exported
from the{" "}
<a
target="blank"
href="https://www.spotify.com/us/account/privacy/"
className="underline"
>
Extended streaming history
</a>
.
</p>
<p className="my-4">
The extended streaming history contains additional personally
identifiable data such as the IP address of the listen (which can be
linked to locations). To avoid saving this on the server, the data is
preprocessed in your web browser and only the necessary data
(timestamp & track ID) are sent to the server.
</p>
<p className="my-4">
If an error occurs, you can always retry uploading the file, Listory
deduplicates any listens to make sure that everything is saved only
once.
</p>
<FileUpload />
<ImportProgress />
</div>
</>
);
};
interface FileData {
file: ReactFile;
status: Status;
error?: Error;
}
enum Status {
Select,
Import,
Finished,
Error,
}
const FileUpload: React.FC = () => {
// Using a map is ... meh, need to wrap all state updates in `new Map()` so react re-renders
const [fileMap, setFileMap] = useState<Map<ReactFile["id"], FileData>>(
new Map(),
);
const [status, setStatus] = useState<Status>(Status.Select);
const addFiles = useCallback(
(files: ReactFile[]) => {
setFileMap((_fileMap) => {
files.forEach((file) =>
_fileMap.set(file.id, { file, status: Status.Select }),
);
return new Map(_fileMap);
});
},
[setFileMap],
);
const updateFile = useCallback((data: FileData) => {
setFileMap((_fileMap) => new Map(_fileMap.set(data.file.id, data)));
}, []);
const clearFiles = useCallback(() => {
setFileMap(new Map());
}, [setFileMap]);
const { importHistory } = useSpotifyImportExtendedStreamingHistory();
const handleImport = useCallback(async () => {
setStatus(Status.Import);
let errorOccurred = false;
for (const data of fileMap.values()) {
data.status = Status.Import;
updateFile(data);
let items: SpotifyExtendedStreamingHistoryItem[];
// Scope so these tmp variables can be GC-ed ASAP
{
const fileContent = await data.file.text();
const rawItems = JSON.parse(
fileContent,
) as SpotifyExtendedStreamingHistoryItem[];
items = rawItems
.filter(({ spotify_track_uri }) => spotify_track_uri !== null)
.map(({ ts, spotify_track_uri }) => ({
ts,
spotify_track_uri,
}));
}
try {
await importHistory(items);
data.status = Status.Finished;
} catch (err) {
data.error = err as Error;
data.status = Status.Error;
errorOccurred = true;
}
updateFile(data);
}
if (!errorOccurred) {
setStatus(Status.Finished);
}
}, [fileMap, importHistory, updateFile]);
return (
<Card className="mb-5">
<CardHeader>
<CardTitle>File Upload</CardTitle>
<CardDescription>
Select <Code>endsong_XY.json</Code> files here and start the import.
</CardDescription>
</CardHeader>
<CardContent>
<Files
className="shadow-inner bg-gray-200 dark:bg-gray-700 rounded p-4 text-center cursor-pointer"
dragActiveClassName=""
onChange={addFiles}
accepts={["application/json"]}
multiple
clickable
>
Drop files here or click to upload
</Files>
<Table>
<TableBody>
{Array.from(fileMap.values()).map((data) => (
<File key={data.file.id} data={data} />
))}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex gap-x-2">
<Button
onClick={() =>
handleImport().catch((e) => console.error("Import Failed:", e))
}
variant="secondary"
disabled={status !== Status.Select}
>
Start Import
</Button>
<Button
onClick={clearFiles}
variant="secondary"
disabled={status !== Status.Select}
>
Remove All Files
</Button>
</CardFooter>
</Card>
);
};
const File: React.FC<{ data: FileData }> = ({ data }) => {
const hasErrors = data.status === Status.Error && data.error;
return (
<TableRow>
<TableCell>{data.file.name}</TableCell>
<TableCell className="text-sm font-thin">
{data.file.sizeReadable}
</TableCell>
<TableCell className="text-right">
{data.status === Status.Select && <Badge>Prepared for import!</Badge>}
{data.status === Status.Import && <Badge>Loading!</Badge>}
{data.status === Status.Finished && <Badge>Check!</Badge>}
{hasErrors && (
<Badge variant="destructive">
<ErrorIcon />
{data.error?.message}
</Badge>
)}
</TableCell>
</TableRow>
);
};
const ImportProgress: React.FC = () => {
const {
importStatus: { total, imported },
isLoading,
reload,
} = useSpotifyImportExtendedStreamingHistoryStatus();
useEffect(() => {
const interval = setInterval(() => {
if (!isLoading) {
reload();
}
}, 1000);
return () => clearInterval(interval);
}, [isLoading, reload]);
return (
<Card>
<CardHeader>
<CardTitle>Import Progress</CardTitle>
<CardDescription>
Shows how many of the submitted listens are already imported and
visible to you. This will take a while, and the process might halt for
a few minutes if we hit the Spotify API rate limit. If this is not
finished after a few hours, please contact your Listory administrator.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex pb-2">
<div className="md:flex w-10/12">
<div className={`md:w-full font-bold`}>
Imported
<br />
{imported}
</div>
</div>
<div className="w-2/12 text-right">
Total
<br />
{total}
</div>
</div>
{total > 0 && (
<div className="h-2 w-full bg-gradient-to-r from-teal-200/25 via-green-400 to-violet-400 dark:from-teal-700/25 dark:via-green-600/85 dark:to-amber-500 flex flex-row-reverse">
<div
style={{ width: numberToPercent(1 - imported / total) }}
className="h-full bg-gray-100 dark:bg-gray-900"
></div>
</div>
)}
</CardContent>
</Card>
);
};

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Spinner } from "./ui/Spinner"; import { Spinner } from "./Spinner";
export const LoginLoading: React.FC = () => ( export const LoginLoading: React.FC = () => (
<main className="sm:flex sm:justify-center p-4 dark:bg-gray-900 h-screen"> <main className="sm:flex sm:justify-center p-4 dark:bg-gray-900 h-screen">

View file

@ -0,0 +1,7 @@
import React from "react";
import { useNavigate } from "react-router-dom";
export const LoginSuccess: React.FC = () => {
useNavigate()("/", { replace: false });
return null;
};

View file

@ -1,124 +1,55 @@
import React from "react"; import React, { useCallback, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { User } from "../api/entities/user"; import { User } from "../api/entities/user";
import { useAuth } from "../hooks/use-auth"; import { useAuth } from "../hooks/use-auth";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { CogwheelIcon } from "../icons/Cogwheel"; import { CogwheelIcon } from "../icons/Cogwheel";
import { ImportIcon } from "../icons/Import";
import { SpotifyLogo } from "../icons/Spotify"; import { SpotifyLogo } from "../icons/Spotify";
import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "./ui/navigation-menu";
import { cn } from "../lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Button } from "./ui/button";
export const NavBar: React.FC = () => { export const NavBar: React.FC = () => {
const { user, loginWithSpotifyProps } = useAuth(); const { user, loginWithSpotifyProps } = useAuth();
return ( return (
<div className="flex items-center justify-between flex-wrap py-3 px-6 bg-green-500 dark:bg-gray-800 dark:text-gray-100"> <div className="flex items-center justify-between flex-wrap bg-green-500 dark:bg-gray-800 p-6">
<div className="flex items-center shrink-0 mr-6"> <div className="flex items-center shrink-0 text-white mr-6">
<span className="font-semibold text-xl tracking-tight text-white"> <span className="font-semibold text-xl tracking-tight">Listory</span>
Listory
</span>
</div> </div>
<nav className="w-full grow sm:flex sm:items-center sm:w-auto"> <nav className="w-full block grow lg:flex lg:items-center lg:w-auto ">
<div className="sm:grow"> <div className="text-sm lg:grow">
{user && ( {user && (
<NavigationMenu> <>
<NavigationMenuList> <Link to="/">
<NavigationMenuItem> <NavItem>Home</NavItem>
<NavigationMenuLink </Link>
asChild <Link to="/listens">
className={navigationMenuTriggerStyle()} <NavItem>Your Listens</NavItem>
> </Link>
<Link to="/">Home</Link> <Link to="/reports/listens">
</NavigationMenuLink> <NavItem>Listens Report</NavItem>
</NavigationMenuItem> </Link>
<Link to="/reports/top-artists">
<NavigationMenuItem> <NavItem>Top Artists</NavItem>
<NavigationMenuLink </Link>
asChild <Link to="/reports/top-albums">
className={navigationMenuTriggerStyle()} <NavItem>Top Albums</NavItem>
> </Link>
<Link to="/listens">Your Listens</Link> <Link to="/reports/top-tracks">
</NavigationMenuLink> <NavItem>Top Tracks</NavItem>
</NavigationMenuItem> </Link>
<Link to="/reports/top-genres">
<NavigationMenuItem> <NavItem>Top Genres</NavItem>
<NavigationMenuTrigger>Reports</NavigationMenuTrigger> </Link>
<NavigationMenuContent> </>
<ul className="grid gap-3 p-4 grid-flow-row grid-cols-1 sm:grid-cols-2 w-6 min-w-max sm:min-w-fit sm:w-[500px]">
<NavListItem title="Listens" to={"/reports/listens"}>
When did you listen how much music?
</NavListItem>
<NavListItem
title="Top Artists"
to={"/reports/top-artists"}
>
What are your top artists in the last week/month/year?
</NavListItem>
<NavListItem
title="Top Albums"
to={"/reports/top-albums"}
>
What are your top albums in the last week/month/year?
</NavListItem>
<NavListItem
title="Top Tracks"
to={"/reports/top-tracks"}
>
What are your top tracks in the last week/month/year?
</NavListItem>
<NavListItem
title="Top Genres"
to={"/reports/top-genres"}
>
What are your top genres in the last week/month/year?
</NavListItem>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)} )}
</div> </div>
<div> <div>
{!user && ( {!user && (
<NavigationMenu> <a {...loginWithSpotifyProps()}>
<NavigationMenuList> <NavItem>
<NavigationMenuItem> Login with Spotify{" "}
<NavigationMenuLink <SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
asChild </NavItem>
className={navigationMenuTriggerStyle()} </a>
>
<a {...loginWithSpotifyProps()}>
<span>Login with Spotify </span>
<SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)} )}
{user && <NavUserInfo user={user} />} {user && <NavUserInfo user={user} />}
</div> </div>
@ -127,76 +58,58 @@ export const NavBar: React.FC = () => {
); );
}; };
const NavListItem = React.forwardRef< const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
React.ElementRef<typeof Link>,
React.ComponentPropsWithoutRef<typeof Link>
>(({ className, title, children, ...props }, ref) => {
return ( return (
<li> <span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
<NavigationMenuLink asChild> {children}
<Link </span>
ref={ref} );
className={cn( };
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className, const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
)} const [menuOpen, setMenuOpen] = useState<boolean>(false);
{...props} const closeMenu = useCallback(() => setMenuOpen(false), [setMenuOpen]);
>
<div className="text-sm font-medium leading-none">{title}</div> const wrapperRef = useRef(null);
<p className="line-clamp-3 text-sm leading-snug text-muted-foreground"> useOutsideClick(wrapperRef, closeMenu);
{children}
</p> return (
</Link> <div ref={wrapperRef}>
</NavigationMenuLink> <div
</li> className="flex items-center mr-4 mt-4 lg:mt-0 cursor-pointer"
); onClick={() => setMenuOpen(!menuOpen)}
}); >
NavListItem.displayName = "NavListItem"; <span className="text-green-200 text-sm">{user.displayName}</span>
{user.photo && (
const NavUserInfo: React.FC<{ user: User }> = ({ user }) => { <img
return ( className="w-6 h-6 rounded-full ml-4"
<DropdownMenu> src={user.photo}
<DropdownMenuTrigger asChild> alt="Profile of logged in user"
<Button ></img>
variant={"ghost"} )}
className="flex flex-row-reverse sm:flex-row px-0 mt-2 sm:px-8" </div>
> {menuOpen ? <NavUserInfoMenu closeMenu={closeMenu} /> : null}
<span className="text-green-200 pl-2 sm:pr-2"> </div>
{user.displayName} );
</span> };
<Avatar>
<AvatarImage const NavUserInfoMenu: React.FC<{ closeMenu: () => void }> = ({
src={user.photo} closeMenu,
alt="Profile picture of logged in user" }) => {
/> return (
<AvatarFallback> <div className="relative">
{user.displayName <div className="drop-down w-48 overflow-hidden bg-green-100 dark:bg-gray-700 text-gray-700 dark:text-green-200 rounded-md shadow absolute top-3 right-3">
.split(" ") <ul>
.filter((name) => name.length > 0) <li className="px-3 py-3 text-sm font-medium flex items-center space-x-2 hover:bg-green-200 hover:text-gray-800 dark:hover:text-white">
.map((name) => name[0].toUpperCase()) <span>
.join("")} <CogwheelIcon className="w-5 h-5 fill-current" />
</AvatarFallback> </span>
</Avatar> <Link to="/auth/api-tokens" onClick={closeMenu}>
</Button> API Tokens
</DropdownMenuTrigger> </Link>
<DropdownMenuContent> </li>
<DropdownMenuLabel>My Account</DropdownMenuLabel> </ul>
<DropdownMenuSeparator /> </div>
<DropdownMenuGroup> </div>
<DropdownMenuItem asChild>
<Link to="/auth/api-tokens">
<CogwheelIcon className="w-5 h-5 fill-current pr-2" />
API Tokens
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/import">
<ImportIcon className="w-5 h-5 fill-current pr-2" />
Import Listens
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
); );
}; };

View file

@ -1,16 +1,17 @@
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Listen } from "../../api/entities/listen"; import { Listen } from "../api/entities/listen";
import { useRecentListens } from "../../hooks/use-api"; import { useRecentListens } from "../hooks/use-api";
import { ReloadIcon } from "../../icons/Reload"; import { useAuthProtection } from "../hooks/use-auth-protection";
import { getPaginationItems } from "../../util/getPaginationItems"; import { ReloadIcon } from "../icons/Reload";
import { Spinner } from "../ui/Spinner"; import { getPaginationItems } from "../util/getPaginationItems";
import { Table, TableBody, TableCell, TableRow } from "../ui/table"; import { Spinner } from "./Spinner";
import { Button } from "../ui/button";
const LISTENS_PER_PAGE = 15; const LISTENS_PER_PAGE = 15;
export const RecentListens: React.FC = () => { export const RecentListens: React.FC = () => {
const { requireUser } = useAuthProtection();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -25,43 +26,44 @@ export const RecentListens: React.FC = () => {
} }
}, [totalPages, paginationMeta]); }, [totalPages, paginationMeta]);
requireUser();
return ( return (
<> <div className="md:flex md:justify-center p-4">
<div className="flex justify-between"> <div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400"> <div className="flex justify-between">
Recent listens <p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
</h2> Recent listens
<Button </p>
className="shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded" <button
onClick={reload} className="shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded"
variant="outline" onClick={reload}
> >
<ReloadIcon className="w-5 h-5 fill-current" /> <ReloadIcon className="w-5 h-5 fill-current" />
</Button> </button>
</div> </div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-2 m-2"> <div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-2 m-2">
{isLoading && <Spinner className="m-8" />} {isLoading && <Spinner className="m-8" />}
{recentListens.length === 0 && ( {recentListens.length === 0 && (
<div className="text-center m-4"> <div className="text-center m-4">
<p className="text-gray-700 dark:text-gray-400"> <p className="text-gray-700 dark:text-gray-400">
Could not find any listens! Could not find any listens!
</p> </p>
</div> </div>
)} )}
<div> <div>
{recentListens.length > 0 && ( {recentListens.length > 0 && (
<Table className="table-auto w-full"> <div className="table-auto w-full">
<TableBody>
{recentListens.map((listen) => ( {recentListens.map((listen) => (
<ListenItem listen={listen} key={listen.id} /> <ListenItem listen={listen} key={listen.id} />
))} ))}
</TableBody> </div>
</Table> )}
)} </div>
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
</div> </div>
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
</div> </div>
</> </div>
); );
}; };
@ -109,7 +111,7 @@ const Pagination: React.FC<{
> >
... ...
</div> </div>
), )
)} )}
<button <button
className={`${ className={`${
@ -132,19 +134,15 @@ const ListenItem: React.FC<{ listen: Listen }> = ({ listen }) => {
}); });
const dateTime = format(new Date(listen.playedAt), "PP p"); const dateTime = format(new Date(listen.playedAt), "PP p");
return ( return (
<TableRow className="sm:flex sm:justify-around sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700/25 text-gray-700 dark:text-gray-300 px-2 py-2"> <div className="hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700/25 md:flex md:justify-around text-gray-700 dark:text-gray-300 px-2 py-2">
<TableCell className="block py-1 sm:p-1 sm:table-cell sm:w-1/2 font-bold text-l"> <div className="md:w-1/2 font-bold">{trackName}</div>
{trackName} <div className=" md:w-1/3">{artists}</div>
</TableCell> <div
<TableCell className="block py-1 sm:p-1 sm:table-cell sm:w-1/3 text-l"> className="md:w-1/6 text-gray-500 font-extra-light text-sm"
{artists}
</TableCell>
<TableCell
className="block py-1 sm:p-1 sm:table-cell sm:w-1/6 font-extra-light text-sm"
title={dateTime} title={dateTime}
> >
{timeAgo} {timeAgo}
</TableCell> </div>
</TableRow> </div>
); );
}; };

View file

@ -10,25 +10,20 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { ListenReportItem } from "../../api/entities/listen-report-item"; import { ListenReportItem } from "../api/entities/listen-report-item";
import { ListenReportOptions } from "../../api/entities/listen-report-options"; import { ListenReportOptions } from "../api/entities/listen-report-options";
import { TimeOptions } from "../../api/entities/time-options"; import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum"; import { TimePreset } from "../api/entities/time-preset.enum";
import { useListensReport } from "../../hooks/use-api"; import { useListensReport } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { ReportTimeOptions } from "./ReportTimeOptions"; import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "./Spinner";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
export const ReportListens: React.FC = () => { export const ReportListens: React.FC = () => {
const { requireUser } = useAuthProtection();
const [timeFrame, setTimeFrame] = useState<"day" | "week" | "month" | "year">( const [timeFrame, setTimeFrame] = useState<"day" | "week" | "month" | "year">(
"day", "day"
); );
const [timeOptions, setTimeOptions] = useState<TimeOptions>({ const [timeOptions, setTimeOptions] = useState<TimeOptions>({
@ -39,60 +34,60 @@ export const ReportListens: React.FC = () => {
const reportOptions = useMemo( const reportOptions = useMemo(
() => ({ timeFrame, time: timeOptions }), () => ({ timeFrame, time: timeOptions }),
[timeFrame, timeOptions], [timeFrame, timeOptions]
); );
const { report, isLoading } = useListensReport(reportOptions); const { report, isLoading } = useListensReport(reportOptions);
const reportHasItems = report.length !== 0; const reportHasItems = report.length !== 0;
requireUser();
return ( return (
<> <div className="md:flex md:justify-center p-4">
<div className="flex justify-between"> <div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400"> <div className="flex justify-between">
Listen Report <p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
</h2> Listen Report
</div> </p>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<div className="sm:flex">
<div className="text-gray-700 dark:text-gray-300 mr-2">
<Label className="text-sm" htmlFor={"timeframe"}>
Timeframe
</Label>
<Select
onValueChange={(e: "day" | "week" | "month" | "year") =>
setTimeFrame(e)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Choose aggregation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">Daily</SelectItem>
<SelectItem value="week">Weekly</SelectItem>
<SelectItem value="month">Monthly</SelectItem>
<SelectItem value="year">Yearly</SelectItem>
</SelectContent>
</Select>
</div>
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
</div> </div>
{isLoading && <Spinner className="m-8" />} <div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
{!reportHasItems && !isLoading && ( <div className="md:flex">
<div> <div className="text-gray-700 dark:text-gray-300 mr-2">
<p>Report is empty! :(</p> <label className="text-sm">Timeframe</label>
<select
className="block appearance-none min-w-full md:win-w-0 md:w-1/4 bg-white dark:bg-gray-700 border border-gray-400 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:text-gray-200 p-2 rounded shadow leading-tight focus:outline-none focus:ring"
onChange={(e) =>
setTimeFrame(
e.target.value as "day" | "week" | "month" | "year"
)
}
>
<option value="day">Daily</option>
<option value="week">Weekly</option>
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
</div>
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
</div> </div>
)} {isLoading && <Spinner className="m-8" />}
{reportHasItems && ( {!reportHasItems && !isLoading && (
<div className="w-full text-gray-700 dark:text-gray-300 mt-5"> <div>
<ReportGraph timeFrame={timeFrame} data={report} /> <p>Report is emtpy! :(</p>
</div> </div>
)} )}
{reportHasItems && (
<div className="w-full text-gray-700 dark:text-gray-300 mt-5">
<ReportGraph timeFrame={timeFrame} data={report} />
</div>
)}
</div>
</div> </div>
</> </div>
); );
}; };
@ -133,7 +128,7 @@ const ReportGraph: React.FC<{
<AreaChart <AreaChart
data={dataLocal} data={dataLocal}
margin={{ margin={{
left: -5, left: -20,
}} }}
> >
<defs> <defs>
@ -168,7 +163,7 @@ const ReportGraph: React.FC<{
}; };
const shortDateFormatFromTimeFrame = ( const shortDateFormatFromTimeFrame = (
timeFrame: "day" | "week" | "month" | "year", timeFrame: "day" | "week" | "month" | "year"
): string => { ): string => {
const FORMAT_DAY = "P"; const FORMAT_DAY = "P";
const FORMAT_WEEK = "'Week' w yyyy"; const FORMAT_WEEK = "'Week' w yyyy";
@ -191,7 +186,7 @@ const shortDateFormatFromTimeFrame = (
}; };
const dateFormatFromTimeFrame = ( const dateFormatFromTimeFrame = (
timeFrame: "day" | "week" | "month" | "year", timeFrame: "day" | "week" | "month" | "year"
): string => { ): string => {
const FORMAT_DAY = "PPPP"; const FORMAT_DAY = "PPPP";
const FORMAT_WEEK = "'Week starting on' PPPP"; const FORMAT_WEEK = "'Week starting on' PPPP";

View file

@ -1,15 +1,7 @@
import React from "react"; import React from "react";
import { TimeOptions } from "../../api/entities/time-options"; import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum"; import { TimePreset } from "../api/entities/time-preset.enum";
import { DateSelect } from "../inputs/DateSelect"; import { DateSelect } from "./inputs/DateSelect";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
interface ReportTimeOptionsProps { interface ReportTimeOptionsProps {
timeOptions: TimeOptions; timeOptions: TimeOptions;
@ -31,34 +23,28 @@ export const ReportTimeOptions: React.FC<ReportTimeOptionsProps> = ({
setTimeOptions, setTimeOptions,
}) => { }) => {
return ( return (
<div className="sm:flex mb-4"> <div className="md:flex mb-4">
<div className="text-gray-700 dark:text-gray-300"> <div className="text-gray-700 dark:text-gray-300">
<Label className="text-sm" htmlFor={"period"}> <label className="text-sm">Timeframe</label>
Period <select
</Label> className="block appearance-none min-w-full md:w-1/4 bg-white dark:bg-gray-700 border border-gray-400 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:text-gray-200 p-2 rounded shadow leading-tight focus:outline-none focus:ring"
<Select onChange={(e) =>
onValueChange={(e: TimePreset) =>
setTimeOptions({ setTimeOptions({
...timeOptions, ...timeOptions,
timePreset: e, timePreset: e.target.value as TimePreset,
}) })
} }
value={timeOptions.timePreset} value={timeOptions.timePreset}
> >
<SelectTrigger className="w-[180px]"> {timePresetOptions.map(({ value, description }) => (
<SelectValue placeholder="Time period" /> <option value={value} key={value}>
</SelectTrigger> {description}
<SelectContent> </option>
{timePresetOptions.map(({ value, description }) => ( ))}
<SelectItem value={value} key={value}> </select>
{description}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{timeOptions.timePreset === TimePreset.CUSTOM && ( {timeOptions.timePreset === TimePreset.CUSTOM && (
<div className="sm:flex text-gray-700 dark:text-gray-200"> <div className="md:flex text-gray-700 dark:text-gray-200">
<div className="pl-2"> <div className="pl-2">
<DateSelect <DateSelect
label="Start" label="Start"

View file

@ -0,0 +1,85 @@
import React, { useMemo, useState } from "react";
import { Album } from "../api/entities/album";
import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum";
import { useTopAlbums } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { getMaxCount } from "../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "./Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopAlbums: React.FC = () => {
const { requireUser } = useAuthProtection();
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions]
);
const { topAlbums, isLoading } = useTopAlbums(options);
const reportHasItems = topAlbums.length !== 0;
const maxCount = getMaxCount(topAlbums);
requireUser();
return (
<div className="md:flex md:justify-center p-4">
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<div className="flex justify-between">
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Albums
</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topAlbums.map(({ album, count }) => (
<ReportItem
key={album.id}
album={album}
count={count}
maxCount={maxCount}
/>
))}
</div>
</div>
</div>
);
};
const ReportItem: React.FC<{
album: Album;
count: number;
maxCount: number;
}> = ({ album, count, maxCount }) => {
const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
return (
<TopListItem
key={album.id}
title={album.name}
subTitle={artists}
count={count}
maxCount={maxCount}
/>
);
};

View file

@ -0,0 +1,67 @@
import React, { useMemo, useState } from "react";
import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum";
import { useTopArtists } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { getMaxCount } from "../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "./Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopArtists: React.FC = () => {
const { requireUser } = useAuthProtection();
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions]
);
const { topArtists, isLoading } = useTopArtists(options);
const reportHasItems = topArtists.length !== 0;
const maxCount = getMaxCount(topArtists);
requireUser();
return (
<div className="md:flex md:justify-center p-4">
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<div className="flex justify-between">
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Artists
</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topArtists.map(({ artist, count }) => (
<TopListItem
key={artist.id}
title={artist.name}
count={count}
maxCount={maxCount}
/>
))}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,102 @@
import React, { useMemo, useState } from "react";
import { Artist } from "../api/entities/artist";
import { Genre } from "../api/entities/genre";
import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum";
import { TopArtistsItem } from "../api/entities/top-artists-item";
import { useTopGenres } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { capitalizeString } from "../util/capitalizeString";
import { getMaxCount } from "../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "./Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopGenres: React.FC = () => {
const { requireUser } = useAuthProtection();
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions]
);
const { topGenres, isLoading } = useTopGenres(options);
const reportHasItems = topGenres.length !== 0;
requireUser();
const maxCount = getMaxCount(topGenres);
return (
<div className="md:flex md:justify-center p-4">
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<div className="flex justify-between">
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Genres
</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topGenres.map(({ genre, artists, count }) => (
<ReportItem
key={genre.id}
genre={genre}
count={count}
artists={artists}
maxCount={maxCount}
/>
))}
</div>
</div>
</div>
);
};
const ReportItem: React.FC<{
genre: Genre;
artists: TopArtistsItem[];
count: number;
maxCount: number;
}> = ({ genre, artists, count, maxCount }) => {
const artistList = artists
.map(({ artist, count: artistCount }) => (
<ArtistItem key={artist.id} artist={artist} count={artistCount} />
))
// @ts-expect-error
.reduce((acc, curr) => (acc === null ? [curr] : [acc, ", ", curr]), null);
return (
<TopListItem
title={capitalizeString(genre.name)}
subTitle={artistList}
count={count}
maxCount={maxCount}
/>
);
};
const ArtistItem: React.FC<{
artist: Artist;
count: number;
}> = ({ artist, count }) => (
<span title={`Listens: ${count}`}>{artist.name}</span>
);

View file

@ -0,0 +1,85 @@
import React, { useMemo, useState } from "react";
import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum";
import { Track } from "../api/entities/track";
import { useTopTracks } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { getMaxCount } from "../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "./Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopTracks: React.FC = () => {
const { requireUser } = useAuthProtection();
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions]
);
const { topTracks, isLoading } = useTopTracks(options);
const reportHasItems = topTracks.length !== 0;
requireUser();
const maxCount = getMaxCount(topTracks);
return (
<div className="md:flex md:justify-center p-4">
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<div className="flex justify-between">
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Tracks
</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topTracks.map(({ track, count }) => (
<ReportItem
key={track.id}
track={track}
count={count}
maxCount={maxCount}
/>
))}
</div>
</div>
</div>
);
};
const ReportItem: React.FC<{
track: Track;
count: number;
maxCount: number;
}> = ({ track, count, maxCount }) => {
const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
return (
<TopListItem
title={track.name}
subTitle={artists}
count={count}
maxCount={maxCount}
/>
);
};

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { SpinnerIcon } from "../../icons/Spinner"; import { SpinnerIcon } from "../icons/Spinner";
interface SpinnerProps { interface SpinnerProps {
className?: string; className?: string;

View file

@ -1,73 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View file

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { numberToPercent } from "../../util/numberToPercent";
export interface TopListItemProps { export interface TopListItemProps {
title: string; title: string;
@ -43,3 +42,9 @@ export const TopListItem: React.FC<TopListItemProps> = ({
const isMaxCountValid = (maxCount: number) => const isMaxCountValid = (maxCount: number) =>
!(Number.isNaN(maxCount) || maxCount === 0); !(Number.isNaN(maxCount) || maxCount === 0);
const numberToPercent = (ratio: number) =>
ratio.toLocaleString(undefined, {
style: "percent",
minimumFractionDigits: 2,
});

View file

@ -1,78 +0,0 @@
import React, { useMemo, useState } from "react";
import { Album } from "../../api/entities/album";
import { TimeOptions } from "../../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum";
import { useTopAlbums } from "../../hooks/use-api";
import { getMaxCount } from "../../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "../ui/Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopAlbums: React.FC = () => {
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions],
);
const { topAlbums, isLoading } = useTopAlbums(options);
const reportHasItems = topAlbums.length !== 0;
const maxCount = getMaxCount(topAlbums);
return (
<>
<div className="flex justify-between">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Albums
</h2>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topAlbums.map(({ album, count }) => (
<ReportItem
key={album.id}
album={album}
count={count}
maxCount={maxCount}
/>
))}
</div>
</>
);
};
const ReportItem: React.FC<{
album: Album;
count: number;
maxCount: number;
}> = ({ album, count, maxCount }) => {
const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
return (
<TopListItem
key={album.id}
title={album.name}
subTitle={artists}
count={count}
maxCount={maxCount}
/>
);
};

View file

@ -1,60 +0,0 @@
import React, { useMemo, useState } from "react";
import { TimeOptions } from "../../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum";
import { useTopArtists } from "../../hooks/use-api";
import { getMaxCount } from "../../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "../ui/Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopArtists: React.FC = () => {
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions],
);
const { topArtists, isLoading } = useTopArtists(options);
const reportHasItems = topArtists.length !== 0;
const maxCount = getMaxCount(topArtists);
return (
<>
<div className="flex justify-between">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Artists
</h2>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topArtists.map(({ artist, count }) => (
<TopListItem
key={artist.id}
title={artist.name}
count={count}
maxCount={maxCount}
/>
))}
</div>
</>
);
};

View file

@ -1,95 +0,0 @@
import React, { useMemo, useState } from "react";
import { Artist } from "../../api/entities/artist";
import { Genre } from "../../api/entities/genre";
import { TimeOptions } from "../../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum";
import { TopArtistsItem } from "../../api/entities/top-artists-item";
import { useTopGenres } from "../../hooks/use-api";
import { capitalizeString } from "../../util/capitalizeString";
import { getMaxCount } from "../../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "../ui/Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopGenres: React.FC = () => {
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions],
);
const { topGenres, isLoading } = useTopGenres(options);
const reportHasItems = topGenres.length !== 0;
const maxCount = getMaxCount(topGenres);
return (
<>
<div className="flex justify-between">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Genres
</h2>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topGenres.map(({ genre, artists, count }) => (
<ReportItem
key={genre.id}
genre={genre}
count={count}
artists={artists}
maxCount={maxCount}
/>
))}
</div>
</>
);
};
const ReportItem: React.FC<{
genre: Genre;
artists: TopArtistsItem[];
count: number;
maxCount: number;
}> = ({ genre, artists, count, maxCount }) => {
const artistList = artists
.map(({ artist, count: artistCount }) => (
<ArtistItem key={artist.id} artist={artist} count={artistCount} />
))
// @ts-expect-error
.reduce((acc, curr) => (acc === null ? [curr] : [acc, ", ", curr]), null);
return (
<TopListItem
title={capitalizeString(genre.name)}
subTitle={artistList}
count={count}
maxCount={maxCount}
/>
);
};
const ArtistItem: React.FC<{
artist: Artist;
count: number;
}> = ({ artist, count }) => (
<span title={`Listens: ${count}`}>{artist.name}</span>
);

View file

@ -1,78 +0,0 @@
import React, { useMemo, useState } from "react";
import { TimeOptions } from "../../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum";
import { Track } from "../../api/entities/track";
import { useTopTracks } from "../../hooks/use-api";
import { getMaxCount } from "../../util/getMaxCount";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "../ui/Spinner";
import { TopListItem } from "./TopListItem";
export const ReportTopTracks: React.FC = () => {
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
timePreset: TimePreset.LAST_90_DAYS,
customTimeStart: new Date("2020"),
customTimeEnd: new Date(),
});
const options = useMemo(
() => ({
time: timeOptions,
}),
[timeOptions],
);
const { topTracks, isLoading } = useTopTracks(options);
const reportHasItems = topTracks.length !== 0;
const maxCount = getMaxCount(topTracks);
return (
<>
<div className="flex justify-between">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Top Tracks
</h2>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<ReportTimeOptions
timeOptions={timeOptions}
setTimeOptions={setTimeOptions}
/>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems &&
topTracks.map(({ track, count }) => (
<ReportItem
key={track.id}
track={track}
count={count}
maxCount={maxCount}
/>
))}
</div>
</>
);
};
const ReportItem: React.FC<{
track: Track;
count: number;
maxCount: number;
}> = ({ track, count, maxCount }) => {
const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
return (
<TopListItem
title={track.name}
subTitle={artists}
count={count}
maxCount={maxCount}
/>
);
};

View file

@ -1,48 +0,0 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "src/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -1,36 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "src/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300",
{
variants: {
variant: {
default:
"border-transparent bg-gray-900 text-gray-50 hover:bg-gray-900/80 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/80",
secondary:
"border-transparent bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
destructive:
"border-transparent bg-red-500 text-gray-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80",
outline: "text-gray-900 dark:text-gray-50",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View file

@ -1,58 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "src/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-gray-900 dark:focus-visible:ring-gray-300",
{
variants: {
variant: {
default:
"bg-gray-900 text-gray-50 hover:bg-gray-900/90 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/90",
destructive:
"bg-red-500 text-gray-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90",
outline:
"border border-gray-200 bg-white hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
secondary:
"bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
ghost:
"hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
link: "text-gray-900 underline-offset-4 hover:underline dark:text-gray-50",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -1,86 +0,0 @@
import * as React from "react";
import { cn } from "src/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -1,9 +0,0 @@
import React, { PropsWithChildren } from "react";
export const Code: React.FC<PropsWithChildren> = ({ children }) => {
return (
<code className="tracking-wide font-mono bg-gray-200 dark:bg-gray-600 rounded-md px-1">
{children}
</code>
);
};

View file

@ -1,198 +0,0 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "src/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-800 dark:data-[state=open]:bg-gray-800",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-900 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View file

@ -1,24 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "src/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View file

@ -1,129 +0,0 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "src/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 flex-col sm:flex-row list-none sm:items-center justify-center space-y-2 sm:space-x-1 sm:space-y-0",
className,
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-gray-100/50 data-[state=open]:bg-gray-100/50 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[active]:bg-gray-800/50 dark:data-[state=open]:bg-gray-800/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}
{""}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-gray-200 bg-white text-gray-900 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-gray-200 shadow-md dark:bg-gray-800" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View file

@ -1,119 +0,0 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "src/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-gray-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:bg-gray-900 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus:ring-gray-300",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};

View file

@ -1,117 +0,0 @@
import * as React from "react";
import { cn } from "src/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"bg-gray-900 font-medium text-gray-50 dark:bg-gray-50 dark:text-gray-900",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-gray-100/50 data-[state=selected]:bg-gray-100 dark:hover:bg-gray-800/50 dark:data-[state=selected]:bg-gray-800",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0 dark:text-gray-400",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-gray-500 dark:text-gray-400", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View file

@ -1,8 +1,4 @@
import axios, { import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
AxiosInstance,
InternalAxiosRequestConfig,
AxiosResponse,
} from "axios";
import React, { import React, {
createContext, createContext,
useContext, useContext,
@ -17,7 +13,7 @@ interface ApiClientContext {
} }
const apiClientContext = createContext<ApiClientContext>( const apiClientContext = createContext<ApiClientContext>(
undefined as any as ApiClientContext, undefined as any as ApiClientContext
); );
export const ProvideApiClient: React.FC<{ children: React.ReactNode }> = ({ export const ProvideApiClient: React.FC<{ children: React.ReactNode }> = ({
@ -39,13 +35,13 @@ export function useApiClient() {
function useProvideApiClient(): ApiClientContext { function useProvideApiClient(): ApiClientContext {
const { accessToken, refreshAccessToken } = useAuth(); const { accessToken, refreshAccessToken } = useAuth();
// Wrap value to immediately update when refreshing access token // Wrap value to immediatly update when refreshing access token
// and always having access to newest access token in interceptor // and always having access to newest access token in interceptor
const localAccessToken = useRef(accessToken); const localAccessToken = useRef(accessToken);
// initialState must be passed as function as return value of axios.create() // initialState must be passed as function as return value of axios.create()
// is also callable and react will call it and then use that result (promise) // is also callable and react will call it and then use that result (promise)
// as the initial state instead of the axios instance. // as the initial state instead of the axios instace.
const [client] = useState<AxiosInstance>(() => axios.create()); const [client] = useState<AxiosInstance>(() => axios.create());
useEffect(() => { useEffect(() => {
@ -58,11 +54,15 @@ function useProvideApiClient(): ApiClientContext {
// Setup Axios Interceptors // Setup Axios Interceptors
const requestInterceptor = client.interceptors.request.use( const requestInterceptor = client.interceptors.request.use(
(config) => { (config) => {
if (!config.headers) {
config.headers = {};
}
config.headers.Authorization = `Bearer ${localAccessToken.current}`; config.headers.Authorization = `Bearer ${localAccessToken.current}`;
return config; return config;
}, },
(err) => Promise.reject(err), (err) => Promise.reject(err)
); );
const responseInterceptor = client.interceptors.response.use( const responseInterceptor = client.interceptors.response.use(
(data) => data, (data) => data,
@ -73,7 +73,7 @@ function useProvideApiClient(): ApiClientContext {
const { response, config } = err as { const { response, config } = err as {
response: AxiosResponse; response: AxiosResponse;
config: InternalAxiosRequestConfig; config: AxiosRequestConfig;
}; };
if (response && response.status !== 401) { if (response && response.status !== 401) {
@ -84,7 +84,7 @@ function useProvideApiClient(): ApiClientContext {
localAccessToken.current = await refreshAccessToken(); localAccessToken.current = await refreshAccessToken();
return client.request(config); return client.request(config);
}, }
); );
return () => { return () => {

View file

@ -2,14 +2,12 @@ import { useCallback, useMemo } from "react";
import { import {
createApiToken, createApiToken,
getApiTokens, getApiTokens,
getExtendedStreamingHistoryStatus,
getListensReport, getListensReport,
getRecentListens, getRecentListens,
getTopAlbums, getTopAlbums,
getTopArtists, getTopArtists,
getTopGenres, getTopGenres,
getTopTracks, getTopTracks,
importExtendedStreamingHistory,
revokeApiToken, revokeApiToken,
} from "../api/api"; } from "../api/api";
import { ListenReportOptions } from "../api/entities/listen-report-options"; import { ListenReportOptions } from "../api/entities/listen-report-options";
@ -20,7 +18,6 @@ import { TopGenresOptions } from "../api/entities/top-genres-options";
import { TopTracksOptions } from "../api/entities/top-tracks-options"; import { TopTracksOptions } from "../api/entities/top-tracks-options";
import { useApiClient } from "./use-api-client"; import { useApiClient } from "./use-api-client";
import { useAsync } from "./use-async"; import { useAsync } from "./use-async";
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
const INITIAL_EMPTY_ARRAY: [] = []; const INITIAL_EMPTY_ARRAY: [] = [];
Object.freeze(INITIAL_EMPTY_ARRAY); Object.freeze(INITIAL_EMPTY_ARRAY);
@ -30,7 +27,7 @@ export const useRecentListens = (options: PaginationOptions) => {
const fetchData = useMemo( const fetchData = useMemo(
() => () => getRecentListens(options, client), () => () => getRecentListens(options, client),
[options, client], [options, client]
); );
const { const {
@ -51,7 +48,7 @@ export const useListensReport = (options: ListenReportOptions) => {
const fetchData = useMemo( const fetchData = useMemo(
() => () => getListensReport(options, client), () => () => getListensReport(options, client),
[options, client], [options, client]
); );
const { const {
@ -68,7 +65,7 @@ export const useTopArtists = (options: TopArtistsOptions) => {
const fetchData = useMemo( const fetchData = useMemo(
() => () => getTopArtists(options, client), () => () => getTopArtists(options, client),
[options, client], [options, client]
); );
const { const {
@ -85,7 +82,7 @@ export const useTopAlbums = (options: TopAlbumsOptions) => {
const fetchData = useMemo( const fetchData = useMemo(
() => () => getTopAlbums(options, client), () => () => getTopAlbums(options, client),
[options, client], [options, client]
); );
const { const {
@ -102,7 +99,7 @@ export const useTopTracks = (options: TopTracksOptions) => {
const fetchData = useMemo( const fetchData = useMemo(
() => () => getTopTracks(options, client), () => () => getTopTracks(options, client),
[options, client], [options, client]
); );
const { const {
@ -119,7 +116,7 @@ export const useTopGenres = (options: TopGenresOptions) => {
const fetchData = useMemo( const fetchData = useMemo(
() => () => getTopGenres(options, client), () => () => getTopGenres(options, client),
[options, client], [options, client]
); );
const { const {
@ -146,11 +143,13 @@ export const useApiTokens = () => {
const createToken = useCallback( const createToken = useCallback(
async (description: string) => { async (description: string) => {
const apiToken = await createApiToken(description, client); const apiToken = await createApiToken(description, client);
console.log("apiToken created", apiToken);
await reload(); await reload();
console.log("reloaded data");
return apiToken; return apiToken;
}, },
[client, reload], [client, reload]
); );
const revokeToken = useCallback( const revokeToken = useCallback(
@ -158,43 +157,8 @@ export const useApiTokens = () => {
await revokeApiToken(id, client); await revokeApiToken(id, client);
await reload(); await reload();
}, },
[client, reload], [client, reload]
); );
return { apiTokens, isLoading, error, createToken, revokeToken }; return { apiTokens, isLoading, error, createToken, revokeToken };
}; };
export const useSpotifyImportExtendedStreamingHistory = () => {
const { client } = useApiClient();
const importHistory = useCallback(
async (listens: SpotifyExtendedStreamingHistoryItem[]) => {
return importExtendedStreamingHistory(listens, client);
},
[client]
);
const getStatus = useCallback(async () => {
return getExtendedStreamingHistoryStatus(client);
}, [client]);
return { importHistory, getStatus };
};
export const useSpotifyImportExtendedStreamingHistoryStatus = () => {
const { client } = useApiClient();
const fetchData = useMemo(
() => () => getExtendedStreamingHistoryStatus(client),
[client]
);
const {
value: importStatus,
pending: isLoading,
error,
reload,
} = useAsync(fetchData, { total: 0, imported: 0 });
return { importStatus, isLoading, error, reload };
};

View file

@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useTransition } from "react";
type UseAsync = <T>( type UseAsync = <T>(
asyncFunction: () => Promise<T>, asyncFunction: () => Promise<T>,
initialValue: T, initialValue: T
) => { ) => {
pending: boolean; pending: boolean;
value: T; value: T;
@ -12,7 +12,7 @@ type UseAsync = <T>(
export const useAsync: UseAsync = <T extends any>( export const useAsync: UseAsync = <T extends any>(
asyncFunction: () => Promise<T>, asyncFunction: () => Promise<T>,
initialValue: T, initialValue: T
) => { ) => {
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [value, setValue] = useState<T>(initialValue); const [value, setValue] = useState<T>(initialValue);

View file

@ -0,0 +1,16 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "./use-auth";
export function useAuthProtection() {
const { user } = useAuth();
const navigate = useNavigate();
const requireUser = useCallback(async () => {
if (!user) {
navigate("/");
}
}, [user, navigate]);
return { requireUser };
}

View file

@ -0,0 +1,26 @@
import React, { useEffect } from "react";
/**
* Hook that alerts clicks outside of the passed ref
*/
export const useOutsideClick = (
ref: React.MutableRefObject<any>,
callback: () => void
) => {
useEffect(() => {
/**
* Alert if clicked on outside of element
*/
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target)) {
callback();
}
};
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, callback]);
};

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
export const CogwheelIcon: React.FC<React.SVGProps<SVGSVGElement>> = ( export const CogwheelIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
props, props
) => { ) => {
return ( return (
<svg <svg

View file

@ -1,14 +0,0 @@
import React from "react";
export const ErrorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
fill="#D75A4A"
>
<circle fill="fill" cx="25" cy="25" r="25" />
</svg>
);
};

View file

@ -1,12 +0,0 @@
import * as React from "react";
export const ImportIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
viewBox="0 0 60.903 60.903"
{...props}
>
<path d="M49.561 16.464H39.45v6h10.111c3.008 0 5.341 1.535 5.341 2.857v26.607c0 1.321-2.333 2.858-5.341 2.858H11.34c-3.007 0-5.34-1.537-5.34-2.858V25.324c0-1.322 2.333-2.858 5.34-2.858h10.11v-6H11.34C4.981 16.466 0 20.357 0 25.324v26.605c0 4.968 4.981 8.857 11.34 8.857h38.223c6.357 0 11.34-3.891 11.34-8.857V25.324c-.001-4.969-4.982-8.86-11.342-8.86z" />
<path d="M39.529 29.004a2.99 2.99 0 0 0-2.121.88l-3.756 3.755V3.117a3 3 0 0 0-6 0v30.724l-3.959-3.958a2.992 2.992 0 0 0-4.242 0 2.997 2.997 0 0 0 0 4.241l8.957 8.957a2.988 2.988 0 0 0 2.12.877h.045c.768 0 1.534-.291 2.12-.877l8.957-8.957a2.997 2.997 0 0 0-2.121-5.12z" />
</svg>
);

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
export const TrashcanIcon: React.FC<React.SVGProps<SVGSVGElement>> = ( export const TrashcanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
props, props
) => { ) => {
return ( return (
<svg <svg

View file

@ -5,7 +5,6 @@ import { App } from "./App";
import { ProvideApiClient } from "./hooks/use-api-client"; import { ProvideApiClient } from "./hooks/use-api-client";
import { ProvideAuth } from "./hooks/use-auth"; import { ProvideAuth } from "./hooks/use-auth";
import "./index.css"; import "./index.css";
import { ThemeProvider } from "./components/ThemeProvider";
const root = createRoot(document.getElementById("root")!); const root = createRoot(document.getElementById("root")!);
@ -14,11 +13,9 @@ root.render(
<ProvideAuth> <ProvideAuth>
<ProvideApiClient> <ProvideApiClient>
<BrowserRouter> <BrowserRouter>
<ThemeProvider> <App />
<App />
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</ProvideApiClient> </ProvideApiClient>
</ProvideAuth> </ProvideAuth>
</React.StrictMode>, </React.StrictMode>
); );

View file

@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -1,36 +0,0 @@
//// <reference types="react" />
declare module "react-files" {
declare const Files: React.FC<
Partial<{
accepts: string[];
children: React.ReactNode;
className: string;
clickable: boolean;
dragActiveClassName: string;
inputProps: unknown;
multiple: boolean;
maxFiles: number;
maxFileSize: number;
minFileSize: number;
name: string;
onChange: (files: ReactFile[]) => void;
onDragEnter: () => void;
onDragLeave: () => void;
onError: (
error: { code: number; message: string },
file: ReactFile
) => void;
style: object;
}>
>;
export type ReactFile = File & {
id: string;
extension: string;
sizeReadable: string;
preview: { type: "image"; url: string } | { type: "file" };
};
export default Files;
}

View file

@ -1,7 +1,7 @@
export const getPaginationItems = ( export const getPaginationItems = (
currentPage: number, currentPage: number,
totalPages: number, totalPages: number,
delta: number = 1, delta: number = 1
): (number | null)[] => { ): (number | null)[] => {
const left = currentPage - delta; const left = currentPage - delta;
const right = currentPage + delta; const right = currentPage + delta;

View file

@ -1,5 +0,0 @@
export const numberToPercent = (ratio: number) =>
ratio.toLocaleString(undefined, {
style: "percent",
minimumFractionDigits: 2,
});

View file

@ -5,7 +5,7 @@ export const qs = (parameters: QueryParameters): string => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
Object.entries(parameters).forEach(([key, value]) => Object.entries(parameters).forEach(([key, value]) =>
queryParams.append(key, value), queryParams.append(key, value)
); );
return queryParams.toString(); return queryParams.toString();

View file

@ -1,8 +1,6 @@
const colors = require("tailwindcss/colors"); const colors = require("tailwindcss/colors");
/** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
theme: { theme: {
colors: { colors: {
@ -14,7 +12,6 @@ module.exports = {
// Tailwind v1 Colors // Tailwind v1 Colors
gray: { gray: {
50: "#ffffff",
100: "#f7fafc", 100: "#f7fafc",
200: "#edf2f7", 200: "#edf2f7",
300: "#e2e8f0", 300: "#e2e8f0",
@ -24,11 +21,9 @@ module.exports = {
700: "#4a5568", 700: "#4a5568",
800: "#2d3748", 800: "#2d3748",
900: "#1a202c", 900: "#1a202c",
950: "#0C0F12",
}, },
green: { green: {
50: "#FFFFFF",
100: "#f0fff4", 100: "#f0fff4",
200: "#c6f6d5", 200: "#c6f6d5",
300: "#9ae6b4", 300: "#9ae6b4",
@ -38,7 +33,6 @@ module.exports = {
700: "#2f855a", 700: "#2f855a",
800: "#276749", 800: "#276749",
900: "#22543d", 900: "#22543d",
950: "#1C4A2F",
}, },
yellow: colors.yellow, yellow: colors.yellow,
@ -46,29 +40,5 @@ module.exports = {
violet: colors.violet, violet: colors.violet,
amber: colors.amber, amber: colors.amber,
}, },
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
}, },
plugins: [require("tailwindcss-animate")],
}; };

View file

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -17,8 +21,5 @@
}, },
"include": [ "include": [
"src" "src"
], ]
"paths": {
"src/*": ["./src/*"]
}
} }

View file

@ -1,4 +1,3 @@
import path from "path";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
@ -8,11 +7,6 @@ export default defineConfig(() => {
outDir: "build", outDir: "build",
}, },
plugins: [react()], plugins: [react()],
resolve: {
alias: {
src: path.resolve(__dirname, "./src"),
},
},
test: { test: {
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",

18286
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "@listory/api", "name": "@listory/api",
"version": "1.31.0", "version": "1.26.1",
"description": "Track your Spotify Listen History", "description": "Track your Spotify Listen History",
"author": { "author": {
"name": "Julian Tölle", "name": "Julian Tölle",
@ -26,93 +26,94 @@
"test:e2e": "jest --config ./apps/listory/test/jest-e2e.json" "test:e2e": "jest --config ./apps/listory/test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@apricote/nest-pg-boss": "2.1.0", "@apricote/nest-pg-boss": "2.0.0",
"@narando/nest-axios-interceptor": "3.0.0", "@narando/nest-axios-interceptor": "2.2.0",
"@nestjs/axios": "3.1.3", "@nestjs/axios": "0.1.0",
"@nestjs/common": "10.4.15", "@nestjs/common": "9.4.0",
"@nestjs/config": "3.3.0", "@nestjs/config": "2.3.1",
"@nestjs/core": "10.4.15", "@nestjs/core": "9.4.0",
"@nestjs/jwt": "10.2.0", "@nestjs/jwt": "10.0.3",
"@nestjs/passport": "10.0.3", "@nestjs/passport": "9.0.3",
"@nestjs/platform-express": "10.4.15", "@nestjs/platform-express": "9.4.0",
"@nestjs/serve-static": "4.0.2", "@nestjs/serve-static": "3.0.1",
"@nestjs/swagger": "7.4.2", "@nestjs/swagger": "6.3.0",
"@nestjs/terminus": "10.2.3", "@nestjs/terminus": "9.2.2",
"@nestjs/typeorm": "10.0.2", "@nestjs/typeorm": "9.0.1",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.4.0",
"@opentelemetry/api-metrics": "0.33.0", "@opentelemetry/api-metrics": "0.33.0",
"@opentelemetry/context-async-hooks": "1.29.0", "@opentelemetry/context-async-hooks": "1.9.0",
"@opentelemetry/exporter-prometheus": "0.56.0", "@opentelemetry/exporter-prometheus": "0.35.0",
"@opentelemetry/exporter-trace-otlp-http": "0.56.0", "@opentelemetry/exporter-trace-otlp-http": "0.35.0",
"@opentelemetry/instrumentation": "0.56.0", "@opentelemetry/instrumentation": "0.35.0",
"@opentelemetry/instrumentation-dns": "0.42.0", "@opentelemetry/instrumentation-dns": "0.31.3",
"@opentelemetry/instrumentation-express": "0.46.0", "@opentelemetry/instrumentation-express": "0.32.2",
"@opentelemetry/instrumentation-http": "0.56.0", "@opentelemetry/instrumentation-http": "0.35.0",
"@opentelemetry/instrumentation-nestjs-core": "0.43.0", "@opentelemetry/instrumentation-nestjs-core": "0.32.3",
"@opentelemetry/instrumentation-pg": "0.49.0", "@opentelemetry/instrumentation-pg": "0.35.1",
"@opentelemetry/instrumentation-pino": "0.45.0", "@opentelemetry/instrumentation-pino": "0.33.2",
"@opentelemetry/resources": "1.29.0", "@opentelemetry/resources": "1.9.0",
"@opentelemetry/sdk-metrics": "1.29.0", "@opentelemetry/sdk-metrics-base": "0.31.0",
"@opentelemetry/sdk-node": "0.56.0", "@opentelemetry/sdk-node": "0.35.0",
"@opentelemetry/sdk-trace-base": "1.29.0", "@opentelemetry/sdk-trace-base": "1.9.0",
"@opentelemetry/semantic-conventions": "1.28.0", "@opentelemetry/semantic-conventions": "1.9.0",
"@sentry/node": "7.120.3", "@sentry/node": "7.51.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.14.1", "class-validator": "0.14.0",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.6",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"joi": "17.13.3", "joi": "17.9.2",
"lodash": "4.17.21", "lodash": "^4.17.21",
"nest-raven": "10.1.0", "nest-raven": "9.2.0",
"nestjs-otel": "5.1.5", "nestjs-otel": "5.1.2",
"nestjs-pino": "4.1.0", "nestjs-pino": "3.2.0",
"nestjs-typeorm-paginate": "4.0.4", "nestjs-typeorm-paginate": "4.0.3",
"passport": "0.7.0", "passport": "0.6.0",
"passport-http-bearer": "1.0.1", "passport-http-bearer": "^1.0.1",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"passport-spotify": "2.0.0", "passport-spotify": "2.0.0",
"pg": "8.13.1", "pg": "8.10.0",
"pg-boss": "9.0.3", "pg-boss": "^9.0.0",
"pino": "8.21.0", "pino": "8.12.1",
"pino-http": "9.0.0", "pino-http": "8.3.3",
"reflect-metadata": "0.1.14", "reflect-metadata": "0.1.13",
"rimraf": "5.0.10", "rimraf": "5.0.0",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"typeorm": "0.3.20" "typeorm": "0.3.15"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "10.4.9", "@nestjs/cli": "9.4.2",
"@nestjs/schematics": "10.2.3", "@nestjs/schematics": "9.1.0",
"@nestjs/testing": "10.4.15", "@nestjs/testing": "9.4.0",
"@types/cookie-parser": "1.4.8", "@types/cookie-parser": "1.4.3",
"@types/express": "5.0.0", "@types/express": "4.17.17",
"@types/jest": "29.5.14", "@types/jest": "29.5.1",
"@types/lodash": "4.17.14", "@types/lodash": "^4.14.194",
"@types/node": "20.17.16", "@types/long": "4.0.2",
"@types/passport-http-bearer": "1.0.41", "@types/node": "18.16.5",
"@types/passport-jwt": "4.0.1", "@types/passport-http-bearer": "^1.0.37",
"@types/supertest": "6.0.2", "@types/passport-jwt": "3.0.8",
"@typescript-eslint/eslint-plugin": "6.21.0", "@types/supertest": "2.0.12",
"@typescript-eslint/parser": "6.21.0", "@typescript-eslint/eslint-plugin": "5.59.2",
"eslint": "8.57.1", "@typescript-eslint/parser": "5.59.2",
"eslint": "8.40.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.1.0", "eslint-config-airbnb-typescript": "17.0.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "8.8.0",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.27.5",
"eslint-plugin-jsdoc": "48.11.0", "eslint-plugin-jsdoc": "43.2.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-prefer-arrow": "1.2.3",
"eslint-plugin-react": "7.37.4", "eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-react-hooks": "4.6.0",
"jest": "29.7.0", "jest": "29.5.0",
"pino-pretty": "10.3.1", "pino-pretty": "10.0.0",
"prettier": "3.4.2", "prettier": "2.8.8",
"supertest": "6.3.4", "supertest": "6.3.3",
"ts-jest": "29.2.5", "ts-jest": "29.1.0",
"ts-loader": "9.5.1", "ts-loader": "9.4.2",
"ts-node": "10.9.2", "ts-node": "10.9.1",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.7.2" "typescript": "5.0.4"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View file

@ -7,8 +7,7 @@
":automergeBranch", ":automergeBranch",
":automergeLinters", ":automergeLinters",
":automergeTesters", ":automergeTesters",
":automergeTypes", ":automergeTypes"
":maintainLockFilesWeekly"
], ],
"packageRules": [ "packageRules": [
{ {

View file

@ -1,5 +1,5 @@
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import type { Response as ExpressResponse } from "express"; import type { Response } from "express";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { AuthSession } from "./auth-session.entity"; import { AuthSession } from "./auth-session.entity";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
@ -27,7 +27,7 @@ describe("AuthController", () => {
describe("spotifyCallback", () => { describe("spotifyCallback", () => {
let user: User; let user: User;
let res: ExpressResponse; let res: Response;
let refreshToken: string; let refreshToken: string;
beforeEach(() => { beforeEach(() => {
@ -36,7 +36,7 @@ describe("AuthController", () => {
statusCode: 200, statusCode: 200,
cookie: jest.fn(), cookie: jest.fn(),
redirect: jest.fn(), redirect: jest.fn(),
} as unknown as ExpressResponse; } as unknown as Response;
refreshToken = "REFRESH_TOKEN"; refreshToken = "REFRESH_TOKEN";
authService.createSession = jest.fn().mockResolvedValue({ refreshToken }); authService.createSession = jest.fn().mockResolvedValue({ refreshToken });
@ -56,7 +56,7 @@ describe("AuthController", () => {
expect(res.cookie).toHaveBeenCalledWith( expect(res.cookie).toHaveBeenCalledWith(
COOKIE_REFRESH_TOKEN, COOKIE_REFRESH_TOKEN,
refreshToken, refreshToken,
{ httpOnly: true }, { httpOnly: true }
); );
}); });
@ -65,7 +65,7 @@ describe("AuthController", () => {
expect(res.redirect).toHaveBeenCalledTimes(1); expect(res.redirect).toHaveBeenCalledTimes(1);
expect(res.redirect).toHaveBeenCalledWith( expect(res.redirect).toHaveBeenCalledWith(
"/login/success?source=spotify", "/login/success?source=spotify"
); );
}); });
}); });

View file

@ -1,5 +1,5 @@
import { import {
Body as NestBody, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
@ -10,7 +10,7 @@ import {
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBody, ApiTags } from "@nestjs/swagger"; import { ApiBody, ApiTags } from "@nestjs/swagger";
import type { Response as ExpressResponse } from "express"; import type { Response } from "express";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { AuthSession } from "./auth-session.entity"; import { AuthSession } from "./auth-session.entity";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
@ -42,7 +42,7 @@ export class AuthController {
@Get("spotify/callback") @Get("spotify/callback")
@UseFilters(SpotifyAuthFilter) @UseFilters(SpotifyAuthFilter)
@UseGuards(SpotifyAuthGuard) @UseGuards(SpotifyAuthGuard)
async spotifyCallback(@ReqUser() user: User, @Res() res: ExpressResponse) { async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
const { refreshToken } = await this.authService.createSession(user); const { refreshToken } = await this.authService.createSession(user);
// Refresh token should not be accessible to frontend to reduce risk // Refresh token should not be accessible to frontend to reduce risk
@ -57,7 +57,7 @@ export class AuthController {
@UseGuards(RefreshTokenAuthGuard) @UseGuards(RefreshTokenAuthGuard)
async refreshAccessToken( async refreshAccessToken(
// With RefreshTokenAuthGuard the session is available instead of user // With RefreshTokenAuthGuard the session is available instead of user
@ReqUser() session: AuthSession, @ReqUser() session: AuthSession
): Promise<RefreshAccessTokenResponseDto> { ): Promise<RefreshAccessTokenResponseDto> {
const { accessToken } = await this.authService.createAccessToken(session); const { accessToken } = await this.authService.createAccessToken(session);
@ -69,7 +69,7 @@ export class AuthController {
@AuthAccessToken() @AuthAccessToken()
async createApiToken( async createApiToken(
@ReqUser() user: User, @ReqUser() user: User,
@NestBody("description") description: string, @Body("description") description: string
): Promise<NewApiTokenDto> { ): Promise<NewApiTokenDto> {
const apiToken = await this.authService.createApiToken(user, description); const apiToken = await this.authService.createApiToken(user, description);
@ -100,7 +100,7 @@ export class AuthController {
@AuthAccessToken() @AuthAccessToken()
async revokeApiToken( async revokeApiToken(
@ReqUser() user: User, @ReqUser() user: User,
@Param("id") id: string, @Param("id") id: string
): Promise<void> { ): Promise<void> {
return this.authService.revokeApiToken(user, id); return this.authService.revokeApiToken(user, id);
} }

View file

@ -38,7 +38,7 @@ describe("AuthService", () => {
usersService = module.get<UsersService>(UsersService); usersService = module.get<UsersService>(UsersService);
jwtService = module.get<JwtService>(JwtService); jwtService = module.get<JwtService>(JwtService);
authSessionRepository = module.get<AuthSessionRepository>( authSessionRepository = module.get<AuthSessionRepository>(
AuthSessionRepository, AuthSessionRepository
); );
apiTokenRepository = module.get<ApiTokenRepository>(ApiTokenRepository); apiTokenRepository = module.get<ApiTokenRepository>(ApiTokenRepository);
}); });
@ -84,7 +84,7 @@ describe("AuthService", () => {
expect(service.allowedByUserFilter).toHaveBeenCalledTimes(1); expect(service.allowedByUserFilter).toHaveBeenCalledTimes(1);
expect(service.allowedByUserFilter).toHaveBeenCalledWith( expect(service.allowedByUserFilter).toHaveBeenCalledWith(
loginDto.profile.id, loginDto.profile.id
); );
}); });
@ -92,7 +92,7 @@ describe("AuthService", () => {
service.allowedByUserFilter = jest.fn().mockReturnValue(false); service.allowedByUserFilter = jest.fn().mockReturnValue(false);
await expect(service.spotifyLogin(loginDto)).rejects.toThrow( await expect(service.spotifyLogin(loginDto)).rejects.toThrow(
ForbiddenException, ForbiddenException
); );
}); });
@ -197,7 +197,7 @@ describe("AuthService", () => {
{ {
jwtid: session.id, jwtid: session.id,
expiresIn: "EXPIRATION_TIME", expiresIn: "EXPIRATION_TIME",
}, }
); );
}); });
}); });
@ -231,7 +231,7 @@ describe("AuthService", () => {
session.revokedAt = new Date("2020-01-01T00:00:00Z"); session.revokedAt = new Date("2020-01-01T00:00:00Z");
await expect(service.createAccessToken(session)).rejects.toThrow( await expect(service.createAccessToken(session)).rejects.toThrow(
ForbiddenException, ForbiddenException
); );
}); });
@ -258,7 +258,7 @@ describe("AuthService", () => {
it("returns the session", async () => { it("returns the session", async () => {
await expect(service.findSession("AUTH_SESSION")).resolves.toEqual( await expect(service.findSession("AUTH_SESSION")).resolves.toEqual(
session, session
); );
expect(authSessionRepository.findOneBy).toHaveBeenCalledTimes(1); expect(authSessionRepository.findOneBy).toHaveBeenCalledTimes(1);

View file

@ -20,11 +20,11 @@ export class AuthService {
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly authSessionRepository: AuthSessionRepository, private readonly authSessionRepository: AuthSessionRepository,
private readonly apiTokenRepository: ApiTokenRepository, private readonly apiTokenRepository: ApiTokenRepository
) { ) {
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER"); this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
this.sessionExpirationTime = this.config.get<string>( this.sessionExpirationTime = this.config.get<string>(
"SESSION_EXPIRATION_TIME", "SESSION_EXPIRATION_TIME"
); );
} }
@ -69,7 +69,7 @@ export class AuthService {
* @param session * @param session
*/ */
private async createRefreshToken( private async createRefreshToken(
session: AuthSession, session: AuthSession
): Promise<{ refreshToken: string }> { ): Promise<{ refreshToken: string }> {
const payload = { const payload = {
sub: session.user.id, sub: session.user.id,
@ -86,7 +86,7 @@ export class AuthService {
} }
async createAccessToken( async createAccessToken(
session: AuthSession, session: AuthSession
): Promise<{ accessToken: string }> { ): Promise<{ accessToken: string }> {
if (session.revokedAt) { if (session.revokedAt) {
throw new ForbiddenException("SessionIsRevoked"); throw new ForbiddenException("SessionIsRevoked");
@ -115,7 +115,7 @@ export class AuthService {
// TODO demagic 20 // TODO demagic 20
const tokenBuffer = await new Promise<Buffer>((resolve, reject) => const tokenBuffer = await new Promise<Buffer>((resolve, reject) =>
randomBytes(20, (err, buf) => (err ? reject(err) : resolve(buf))), randomBytes(20, (err, buf) => (err ? reject(err) : resolve(buf)))
); );
apiToken.token = `lis${tokenBuffer.toString("hex")}`; apiToken.token = `lis${tokenBuffer.toString("hex")}`;

View file

@ -6,6 +6,6 @@ export function AuthAccessToken() {
return applyDecorators( return applyDecorators(
UseGuards(ApiAuthGuard), UseGuards(ApiAuthGuard),
ApiBearerAuth(), ApiBearerAuth(),
ApiUnauthorizedResponse({ description: "Unauthorized" }), ApiUnauthorizedResponse({ description: "Unauthorized" })
); );
} }

View file

@ -4,5 +4,5 @@ export const ReqUser = createParamDecorator<void>(
(_: void, ctx: ExecutionContext) => { (_: void, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
return request.user; return request.user;
}, }
); );

View file

@ -5,14 +5,14 @@ import {
ForbiddenException, ForbiddenException,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Response as ExpressResponse } from "express"; import type { Response } from "express";
@Catch() @Catch()
export class SpotifyAuthFilter implements ExceptionFilter { export class SpotifyAuthFilter implements ExceptionFilter {
private readonly logger = new Logger(this.constructor.name); private readonly logger = new Logger(this.constructor.name);
catch(exception: Error, host: ArgumentsHost) { catch(exception: Error, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<ExpressResponse>(); const response = host.switchToHttp().getResponse<Response>();
let reason = "unknown"; let reason = "unknown";
@ -29,7 +29,7 @@ export class SpotifyAuthFilter implements ExceptionFilter {
this.logger.error( this.logger.error(
`Login with Spotify failed: ${exception}`, `Login with Spotify failed: ${exception}`,
exception.stack, exception.stack
); );
response.redirect(`/login/failure?reason=${reason}&source=spotify`); response.redirect(`/login/failure?reason=${reason}&source=spotify`);

View file

@ -8,11 +8,11 @@ import { AuthStrategy } from "./strategies.enum";
@Injectable() @Injectable()
export class AccessTokenStrategy extends PassportStrategy( export class AccessTokenStrategy extends PassportStrategy(
Strategy, Strategy,
AuthStrategy.AccessToken, AuthStrategy.AccessToken
) { ) {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
config: ConfigService, config: ConfigService
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),

View file

@ -12,7 +12,7 @@ import { AuthStrategy } from "./strategies.enum";
@Injectable() @Injectable()
export class ApiTokenStrategy extends PassportStrategy( export class ApiTokenStrategy extends PassportStrategy(
Strategy, Strategy,
AuthStrategy.ApiToken, AuthStrategy.ApiToken
) { ) {
constructor(private readonly authService: AuthService) { constructor(private readonly authService: AuthService) {
super(); super();

View file

@ -54,7 +54,7 @@ describe("RefreshTokenStrategy", () => {
authService.findSession = jest.fn().mockResolvedValue(undefined); authService.findSession = jest.fn().mockResolvedValue(undefined);
await expect(strategy.validate(payload)).rejects.toThrow( await expect(strategy.validate(payload)).rejects.toThrow(
UnauthorizedException, UnauthorizedException
); );
}); });
@ -62,7 +62,7 @@ describe("RefreshTokenStrategy", () => {
session.revokedAt = "2021-01-01"; session.revokedAt = "2021-01-01";
await expect(strategy.validate(payload)).rejects.toThrow( await expect(strategy.validate(payload)).rejects.toThrow(
ForbiddenException, ForbiddenException
); );
}); });
}); });

View file

@ -19,11 +19,11 @@ const extractJwtFromCookie: JwtFromRequestFunction = (req) => {
@Injectable() @Injectable()
export class RefreshTokenStrategy extends PassportStrategy( export class RefreshTokenStrategy extends PassportStrategy(
Strategy, Strategy,
AuthStrategy.RefreshToken, AuthStrategy.RefreshToken
) { ) {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
config: ConfigService, config: ConfigService
) { ) {
super({ super({
jwtFromRequest: extractJwtFromCookie, jwtFromRequest: extractJwtFromCookie,

View file

@ -8,17 +8,17 @@ import { AuthStrategy } from "./strategies.enum";
@Injectable() @Injectable()
export class SpotifyStrategy extends PassportStrategy( export class SpotifyStrategy extends PassportStrategy(
Strategy, Strategy,
AuthStrategy.Spotify, AuthStrategy.Spotify
) { ) {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
config: ConfigService, config: ConfigService
) { ) {
super({ super({
clientID: config.get<string>("SPOTIFY_CLIENT_ID"), clientID: config.get<string>("SPOTIFY_CLIENT_ID"),
clientSecret: config.get<string>("SPOTIFY_CLIENT_SECRET"), clientSecret: config.get<string>("SPOTIFY_CLIENT_SECRET"),
callbackURL: `${config.get<string>( callbackURL: `${config.get<string>(
"APP_URL", "APP_URL"
)}/api/v1/auth/spotify/callback`, )}/api/v1/auth/spotify/callback`,
scope: [ scope: [
"user-read-private", "user-read-private",

View file

@ -27,7 +27,7 @@ import * as Joi from "joi";
SPOTIFY_UPDATE_INTERVAL_SEC: Joi.number().default(60), SPOTIFY_UPDATE_INTERVAL_SEC: Joi.number().default(60),
SPOTIFY_WEB_API_URL: Joi.string().default("https://api.spotify.com/"), SPOTIFY_WEB_API_URL: Joi.string().default("https://api.spotify.com/"),
SPOTIFY_AUTH_API_URL: Joi.string().default( SPOTIFY_AUTH_API_URL: Joi.string().default(
"https://accounts.spotify.com/", "https://accounts.spotify.com/"
), ),
SPOTIFY_USER_FILTER: Joi.string(), SPOTIFY_USER_FILTER: Joi.string(),
@ -53,14 +53,14 @@ import * as Joi from "joi";
{ {
is: Joi.valid(true), is: Joi.valid(true),
then: Joi.required(), then: Joi.required(),
}, }
), ),
PROMETHEUS_BASIC_AUTH_PASSWORD: Joi.string().when( PROMETHEUS_BASIC_AUTH_PASSWORD: Joi.string().when(
"PROMETHEUS_BASIC_AUTH", "PROMETHEUS_BASIC_AUTH",
{ {
is: Joi.valid(true), is: Joi.valid(true),
then: Joi.required(), then: Joi.required(),
}, }
), ),
}), }),
}), }),

View file

@ -27,7 +27,7 @@ export const DatabaseModule = TypeOrmModule.forRootAsync({
// Debug/Development Options // Debug/Development Options
// //
//logging: true, // logging: true,
// //
// synchronize: true, // synchronize: true,
// migrationsRun: false, // migrationsRun: false,

View file

@ -5,7 +5,7 @@ import { TYPEORM_ENTITY_REPOSITORY } from "./entity-repository.decorator";
export class TypeOrmRepositoryModule { export class TypeOrmRepositoryModule {
public static for<T extends new (...args: any[]) => any>( public static for<T extends new (...args: any[]) => any>(
repositories: T[], repositories: T[]
): DynamicModule { ): DynamicModule {
const providers: Provider[] = []; const providers: Provider[] = [];
@ -24,7 +24,7 @@ export class TypeOrmRepositoryModule {
return new repository( return new repository(
baseRepository.target, baseRepository.target,
baseRepository.manager, baseRepository.manager,
baseRepository.queryRunner, baseRepository.queryRunner
); );
}, },
}); });

View file

@ -39,7 +39,7 @@ export class CreateUsersTable0000000000001 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
} }

View file

@ -43,7 +43,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
await queryRunner.createTable( await queryRunner.createTable(
@ -64,7 +64,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
isUnique: true, isUnique: true,
}), }),
], ],
}), })
); );
await queryRunner.createTable( await queryRunner.createTable(
@ -94,7 +94,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
referencedTableName: "album", referencedTableName: "album",
}), }),
], ],
}), })
); );
await queryRunner.createTable( await queryRunner.createTable(
@ -137,7 +137,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
await queryRunner.createTable( await queryRunner.createTable(
@ -180,7 +180,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
} }

View file

@ -65,7 +65,7 @@ export class CreateListensTable0000000000003 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
} }

View file

@ -60,7 +60,7 @@ export class CreateAuthSessionsTable0000000000004
}), }),
], ],
}), }),
true, true
); );
} }

View file

@ -34,7 +34,7 @@ export class CreateGenreTables0000000000005 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
await queryRunner.createTable( await queryRunner.createTable(
@ -77,7 +77,7 @@ export class CreateGenreTables0000000000005 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
} }

View file

@ -8,7 +8,7 @@ export class AddUpdatedAtColumnes0000000000006 implements MigrationInterface {
name: "updatedAt", name: "updatedAt",
type: "timestamp", type: "timestamp",
default: "NOW()", default: "NOW()",
}), })
); );
} }

View file

@ -67,7 +67,7 @@ export class CreateApiTokensTable0000000000007 implements MigrationInterface {
}), }),
], ],
}), }),
true, true
); );
} }

View file

@ -1,72 +0,0 @@
import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
export class OptimizeDBIndices0000000000008 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createIndices("artist", [
new TableIndex({
// This index helps with the "update artist" job
name: "IDX_ARTIST_UPDATED_AT",
columnNames: ["updatedAt"],
}),
]);
await queryRunner.createIndices("listen", [
new TableIndex({
// This index helps with the "getCrawlableUserInfo" query
name: "IDX_LISTEN_USER_ID_PLAYED_AT",
columnNames: ["userId", "playedAt"],
}),
]);
// handled by Primary Key on (albumId, artistId)
await queryRunner.dropIndex("album_artists", "IDX_ALBUM_ARTISTS_ALBUM_ID");
// handled by Primary Key on (artistId, genreId)
await queryRunner.dropIndex("artist_genres", "IDX_ARTIST_GENRES_ARTIST_ID");
// handled by IDX_LISTEN_UNIQUE on (trackId, userId, playedAt)
await queryRunner.dropIndex("listen", "IDX_LISTEN_TRACK_ID");
// handled by IDX_LISTEN_USER_ID_PLAYED_AT on (userId, playedAt)
await queryRunner.dropIndex("listen", "IDX_LISTEN_USER_ID");
// handled by Primary Key on (trackId, artistId)
await queryRunner.dropIndex("track_artists", "IDX_TRACK_ARTISTS_TRACK_ID");
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createIndices("album_artists", [
new TableIndex({
name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
columnNames: ["albumId"],
}),
]);
await queryRunner.createIndices("artist_genres", [
new TableIndex({
name: "IDX_ARTIST_GENRES_ARTIST_ID",
columnNames: ["artistId"],
}),
]);
await queryRunner.createIndices("listen", [
new TableIndex({
name: "IDX_LISTEN_TRACK_ID",
columnNames: ["trackId"],
}),
new TableIndex({
name: "IDX_LISTEN_USER_ID",
columnNames: ["userId"],
}),
]);
await queryRunner.createIndices("track_artists", [
new TableIndex({
name: "IDX_TRACK_ARTISTS_TRACK_ID",
columnNames: ["trackId"],
}),
]);
await queryRunner.dropIndex("artist", "IDX_ARTIST_UPDATED_AT");
await queryRunner.dropIndex("listen", "IDX_LISTEN_USER_ID_PLAYED_AT");
}
}

View file

@ -1,68 +0,0 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from "typeorm";
import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
const primaryUUIDColumn: TableColumnOptions = {
name: "id",
type: "uuid",
isPrimary: true,
isGenerated: true,
generationStrategy: "uuid",
};
export class CreateSpotifyImportTables0000000000009
implements MigrationInterface
{
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "spotify_extended_streaming_history_listen",
columns: [
primaryUUIDColumn,
{ name: "userId", type: "uuid" },
{ name: "playedAt", type: "timestamp" },
{ name: "spotifyTrackUri", type: "varchar" },
{ name: "trackId", type: "uuid", isNullable: true },
{ name: "listenId", type: "uuid", isNullable: true },
],
indices: [
new TableIndex({
name: "IDX_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_PLAYED_AT",
columnNames: ["userId", "playedAt", "spotifyTrackUri"],
isUnique: true,
}),
],
foreignKeys: [
new TableForeignKey({
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_ID",
columnNames: ["userId"],
referencedColumnNames: ["id"],
referencedTableName: "user",
}),
new TableForeignKey({
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_TRACK_ID",
columnNames: ["trackId"],
referencedColumnNames: ["id"],
referencedTableName: "track",
}),
new TableForeignKey({
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_LISTEN_ID",
columnNames: ["listenId"],
referencedColumnNames: ["id"],
referencedTableName: "listen",
}),
],
}),
true,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("spotify_extended_streaming_history_listen");
}
}

View file

@ -17,7 +17,7 @@ export class HealthCheckController {
private readonly health: HealthCheckService, private readonly health: HealthCheckService,
private readonly http: HttpHealthIndicator, private readonly http: HttpHealthIndicator,
private readonly typeorm: TypeOrmHealthIndicator, private readonly typeorm: TypeOrmHealthIndicator,
private readonly config: ConfigService, private readonly config: ConfigService
) {} ) {}
@Get() @Get()
@ -27,10 +27,7 @@ export class HealthCheckController {
() => () =>
this.http.pingCheck( this.http.pingCheck(
"spotify-web", "spotify-web",
this.config.get<string>("SPOTIFY_WEB_API_URL"), this.config.get<string>("SPOTIFY_WEB_API_URL")
{
validateStatus: () => true,
}, // Successful as long as we get a valid HTTP response back }
), ),
() => this.typeorm.pingCheck("db"), () => this.typeorm.pingCheck("db"),
]); ]);

View file

@ -3,6 +3,10 @@ import { Repository, SelectQueryBuilder } from "typeorm";
import { EntityRepository } from "../database/entity-repository"; import { EntityRepository } from "../database/entity-repository";
import { Interval } from "../reports/interval"; import { Interval } from "../reports/interval";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import {
CreateListenRequestDto,
CreateListenResponseDto,
} from "./dto/create-listen.dto";
import { Listen } from "./listen.entity"; import { Listen } from "./listen.entity";
export class ListenScopes extends SelectQueryBuilder<Listen> { export class ListenScopes extends SelectQueryBuilder<Listen> {
@ -33,4 +37,52 @@ export class ListenRepository extends Repository<Listen> {
get scoped(): ListenScopes { get scoped(): ListenScopes {
return new ListenScopes(this.createQueryBuilder("listen")); return new ListenScopes(this.createQueryBuilder("listen"));
} }
async insertNoConflict({
user,
track,
playedAt,
}: CreateListenRequestDto): Promise<CreateListenResponseDto> {
const result = await this.createQueryBuilder()
.insert()
.values({
user,
track,
playedAt,
})
.onConflict('("playedAt", "trackId", "userId") DO NOTHING')
.execute();
const [insertedRowIdentifier] = result.identifiers;
if (!insertedRowIdentifier) {
// We did not insert a new listen, it already existed
return {
listen: await this.findOneBy({ user, track, playedAt }),
isDuplicate: true,
};
}
return {
listen: await this.findOneBy({ id: insertedRowIdentifier.id }),
isDuplicate: false,
};
}
/**
*
* @param rows
* @returns A list of all new (non-duplicate) listens
*/
async insertsNoConflict(rows: CreateListenRequestDto[]): Promise<Listen[]> {
const result = await this.createQueryBuilder()
.insert()
.values(rows)
.orIgnore()
.execute();
return this.findBy(
result.identifiers.filter(Boolean).map(({ id }) => ({ id }))
);
}
} }

View file

@ -41,7 +41,7 @@ describe("Listens Controller", () => {
it("returns the listens", async () => { it("returns the listens", async () => {
await expect( await expect(
controller.getRecentlyPlayed(filter, user, 1, 10), controller.getRecentlyPlayed(filter, user, 1, 10)
).resolves.toEqual(listens); ).resolves.toEqual(listens);
expect(listensService.getListens).toHaveBeenCalledTimes(1); expect(listensService.getListens).toHaveBeenCalledTimes(1);
@ -57,7 +57,7 @@ describe("Listens Controller", () => {
await controller.getRecentlyPlayed(filter, user, 1, 1000); await controller.getRecentlyPlayed(filter, user, 1, 1000);
expect(listensService.getListens).toHaveBeenCalledWith( expect(listensService.getListens).toHaveBeenCalledWith(
expect.objectContaining({ limit: 100 }), expect.objectContaining({ limit: 100 })
); );
}); });
}); });

View file

@ -19,7 +19,7 @@ export class ListensController {
@Query("filter") filter: GetListensFilterDto, @Query("filter") filter: GetListensFilterDto,
@ReqUser() user: User, @ReqUser() user: User,
@Query("page") page: number = 1, @Query("page") page: number = 1,
@Query("limit") limit: number = 10, @Query("limit") limit: number = 10
): Promise<Pagination<Listen>> { ): Promise<Pagination<Listen>> {
const clampedLimit = limit > 100 ? 100 : limit; const clampedLimit = limit > 100 ? 100 : limit;

View file

@ -4,7 +4,9 @@ import {
paginate, paginate,
PaginationTypeEnum, PaginationTypeEnum,
} from "nestjs-typeorm-paginate"; } from "nestjs-typeorm-paginate";
import { Track } from "../music-library/track.entity";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { CreateListenResponseDto } from "./dto/create-listen.dto";
import { GetListensDto } from "./dto/get-listens.dto"; import { GetListensDto } from "./dto/get-listens.dto";
import { Listen } from "./listen.entity"; import { Listen } from "./listen.entity";
import { ListenRepository, ListenScopes } from "./listen.repository"; import { ListenRepository, ListenScopes } from "./listen.repository";
@ -33,6 +35,39 @@ describe("ListensService", () => {
expect(listenRepository).toBeDefined(); expect(listenRepository).toBeDefined();
}); });
describe("createListen", () => {
let user: User;
let track: Track;
let playedAt: Date;
let response: CreateListenResponseDto;
beforeEach(() => {
user = { id: "USER" } as User;
track = { id: "TRACK" } as Track;
playedAt = new Date("2021-01-01T00:00:00Z");
response = {
listen: {
id: "LISTEN",
} as Listen,
isDuplicate: true,
};
listenRepository.insertNoConflict = jest.fn().mockResolvedValue(response);
});
it("creates the listen", async () => {
await expect(
service.createListen({ user, track, playedAt })
).resolves.toEqual(response);
expect(listenRepository.insertNoConflict).toHaveBeenCalledTimes(1);
expect(listenRepository.insertNoConflict).toHaveBeenLastCalledWith({
user,
track,
playedAt,
});
});
});
describe("getListens", () => { describe("getListens", () => {
let options: GetListensDto & IPaginationOptions; let options: GetListensDto & IPaginationOptions;
let user: User; let user: User;

View file

@ -1,12 +1,14 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Span } from "nestjs-otel";
import { import {
IPaginationOptions, IPaginationOptions,
paginate, paginate,
Pagination, Pagination,
PaginationTypeEnum, PaginationTypeEnum,
} from "nestjs-typeorm-paginate"; } from "nestjs-typeorm-paginate";
import { CreateListenRequestDto } from "./dto/create-listen.dto"; import {
CreateListenRequestDto,
CreateListenResponseDto,
} from "./dto/create-listen.dto";
import { GetListensDto } from "./dto/get-listens.dto"; import { GetListensDto } from "./dto/get-listens.dto";
import { Listen } from "./listen.entity"; import { Listen } from "./listen.entity";
import { ListenRepository, ListenScopes } from "./listen.repository"; import { ListenRepository, ListenScopes } from "./listen.repository";
@ -15,9 +17,22 @@ import { ListenRepository, ListenScopes } from "./listen.repository";
export class ListensService { export class ListensService {
constructor(private readonly listenRepository: ListenRepository) {} constructor(private readonly listenRepository: ListenRepository) {}
@Span() async createListen({
user,
track,
playedAt,
}: CreateListenRequestDto): Promise<CreateListenResponseDto> {
const response = await this.listenRepository.insertNoConflict({
user,
track,
playedAt,
});
return response;
}
async createListens( async createListens(
listensData: CreateListenRequestDto[], listensData: CreateListenRequestDto[]
): Promise<Listen[]> { ): Promise<Listen[]> {
const existingListens = await this.listenRepository.findBy(listensData); const existingListens = await this.listenRepository.findBy(listensData);
@ -27,19 +42,17 @@ export class ListensService {
(existingListen) => (existingListen) =>
newListen.user.id === existingListen.user.id && newListen.user.id === existingListen.user.id &&
newListen.track.id === existingListen.track.id && newListen.track.id === existingListen.track.id &&
newListen.playedAt.getTime() === existingListen.playedAt.getTime(), newListen.playedAt.getTime() === existingListen.playedAt.getTime()
), )
); );
const newListens = await this.listenRepository.save( return this.listenRepository.save(
missingListens.map((entry) => this.listenRepository.create(entry)), missingListens.map((entry) => this.listenRepository.create(entry))
); );
return [...existingListens, ...newListens];
} }
async getListens( async getListens(
options: GetListensDto & IPaginationOptions, options: GetListensDto & IPaginationOptions
): Promise<Pagination<Listen>> { ): Promise<Pagination<Listen>> {
const { page, limit, user, filter } = options; const { page, limit, user, filter } = options;
@ -64,6 +77,16 @@ export class ListensService {
}); });
} }
async getMostRecentListenPerUser(): Promise<Listen[]> {
return this.listenRepository
.createQueryBuilder("listen")
.leftJoinAndSelect("listen.user", "user")
.distinctOn(["user.id"])
.orderBy({ "user.id": "ASC", "listen.playedAt": "DESC" })
.limit(1)
.getMany();
}
getScopedQueryBuilder(): ListenScopes { getScopedQueryBuilder(): ListenScopes {
return this.listenRepository.scoped; return this.listenRepository.scoped;
} }

View file

@ -13,7 +13,7 @@ import { Scope } from "@sentry/node";
function setupSentry( function setupSentry(
app: NestExpressApplication, app: NestExpressApplication,
configService: ConfigService, configService: ConfigService
) { ) {
Sentry.init({ Sentry.init({
dsn: configService.get<string>("SENTRY_DSN"), dsn: configService.get<string>("SENTRY_DSN"),
@ -34,7 +34,7 @@ function setupSentry(
} }
}, },
], ],
}), })
); );
} }
@ -43,19 +43,14 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: true, bufferLogs: true,
rawBody: true,
}); });
app.useLogger(app.get(Logger)); app.useLogger(app.get(Logger));
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
transform: true, transform: true,
transformOptions: { enableImplicitConversion: true }, transformOptions: { enableImplicitConversion: true },
}), })
); );
app.useBodyParser("json", {
limit:
"10mb" /* Need large bodies for Spotify Extended Streaming History */,
});
app.enableShutdownHooks(); app.enableShutdownHooks();
const configService = app.get<ConfigService>(ConfigService); const configService = app.get<ConfigService>(ConfigService);

View file

@ -1,6 +1,5 @@
export class FindTrackDto { export class FindTrackDto {
spotify: { spotify: {
id?: string; id: string;
uri?: string;
}; };
} }

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