mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
Compare commits
No commits in common. "main" and "v1.28.0" have entirely different histories.
85 changed files with 22185 additions and 11142 deletions
|
|
@ -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/**/*
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- 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: 20
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- 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: 20
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
version: "v0.11.2"
|
version: "v0.11.2"
|
||||||
|
|
||||||
- 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: 20
|
||||||
|
|
||||||
|
|
|
||||||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -1,45 +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)
|
# [1.28.0](https://github.com/apricote/Listory/compare/v1.27.0...v1.28.0) (2023-09-16)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
# syntax=docker/dockerfile:1.12
|
# syntax=docker/dockerfile:1.5
|
||||||
|
|
||||||
|
FROM scratch as ignore
|
||||||
|
|
||||||
|
WORKDIR /listory
|
||||||
|
COPY . /listory/
|
||||||
|
|
||||||
##################
|
##################
|
||||||
## common
|
## common
|
||||||
|
|
|
||||||
|
|
@ -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.28.0
|
||||||
|
|
||||||
# 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.28.0
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ services:
|
||||||
#####
|
#####
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16.6
|
image: postgres:16.0
|
||||||
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.28.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
#####
|
#####
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16.6
|
image: postgres:16.0
|
||||||
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.4
|
||||||
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.47.0
|
||||||
volumes:
|
volumes:
|
||||||
- ./observability/prometheus:/etc/prometheus
|
- ./observability/prometheus:/etc/prometheus
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
|
|
@ -148,7 +146,7 @@ services:
|
||||||
|
|
||||||
loki:
|
loki:
|
||||||
profiles: ["observability"]
|
profiles: ["observability"]
|
||||||
image: grafana/loki:2.9.11
|
image: grafana/loki:2.9.1
|
||||||
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.9.1
|
||||||
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.2.3
|
||||||
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:10.1.1
|
||||||
volumes:
|
volumes:
|
||||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning
|
- ./observability/grafana/provisioning:/etc/grafana/provisioning
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13108
frontend/package-lock.json
generated
13108
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,44 +8,32 @@
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "1.1.2",
|
"@testing-library/jest-dom": "6.1.3",
|
||||||
"@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.5.1",
|
||||||
"@radix-ui/react-navigation-menu": "1.2.2",
|
"@types/jest": "29.5.5",
|
||||||
"@radix-ui/react-select": "2.1.3",
|
"@types/node": "20.6.2",
|
||||||
"@radix-ui/react-slot": "1.1.1",
|
"@types/react": "18.2.21",
|
||||||
"@testing-library/jest-dom": "6.6.3",
|
"@types/react-dom": "18.2.7",
|
||||||
"@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.4",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.15",
|
||||||
"axios": "1.7.9",
|
"axios": "1.5.0",
|
||||||
"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.1.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.29",
|
||||||
"prettier": "3.4.2",
|
"prettier": "3.0.3",
|
||||||
"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.16.0",
|
||||||
"react-router-dom": "6.28.0",
|
"recharts": "2.8.0",
|
||||||
"recharts": "2.15.0",
|
"tailwindcss": "3.3.3",
|
||||||
"tailwind-merge": "1.14.0",
|
"typescript": "5.2.2",
|
||||||
"tailwindcss": "3.4.16",
|
"vite": "4.4.9",
|
||||||
"tailwindcss-animate": "1.0.7",
|
"vitest": "0.34.4"
|
||||||
"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)\"",
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ 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 {}
|
||||||
|
|
||||||
|
|
@ -278,7 +276,7 @@ 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;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export interface ExtendedStreamingHistoryStatus {
|
|
||||||
total: number;
|
|
||||||
imported: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export interface SpotifyExtendedStreamingHistoryItem {
|
|
||||||
ts: string;
|
|
||||||
spotify_track_uri: string;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
.filter((token) => !token.revokedAt)
|
|
||||||
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
|
|
||||||
[apiTokens],
|
[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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
7
frontend/src/components/LoginSuccess.tsx
Normal file
7
frontend/src/components/LoginSuccess.tsx
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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/use-outside-click";
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -10,23 +10,18 @@ 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",
|
||||||
);
|
);
|
||||||
|
|
@ -46,53 +41,53 @@ export const ReportListens: React.FC = () => {
|
||||||
|
|
||||||
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 empty! :(</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
85
frontend/src/components/ReportTopAlbums.tsx
Normal file
85
frontend/src/components/ReportTopAlbums.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
frontend/src/components/ReportTopArtists.tsx
Normal file
67
frontend/src/components/ReportTopArtists.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
frontend/src/components/ReportTopGenres.tsx
Normal file
102
frontend/src/components/ReportTopGenres.tsx
Normal 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>
|
||||||
|
);
|
||||||
85
frontend/src/components/ReportTopTracks.tsx
Normal file
85
frontend/src/components/ReportTopTracks.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -146,7 +143,9 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
@ -163,38 +162,3 @@ export const useApiTokens = () => {
|
||||||
|
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
|
||||||
16
frontend/src/hooks/use-auth-protection.tsx
Normal file
16
frontend/src/hooks/use-auth-protection.tsx
Normal 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 };
|
||||||
|
}
|
||||||
26
frontend/src/hooks/use-outside-click.tsx
Normal file
26
frontend/src/hooks/use-outside-click.tsx
Normal 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]);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
@ -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,9 +13,7 @@ root.render(
|
||||||
<ProvideAuth>
|
<ProvideAuth>
|
||||||
<ProvideApiClient>
|
<ProvideApiClient>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<App />
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ProvideApiClient>
|
</ProvideApiClient>
|
||||||
</ProvideAuth>
|
</ProvideAuth>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { type ClassValue, clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
36
frontend/src/react-files.d.ts
vendored
36
frontend/src/react-files.d.ts
vendored
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export const numberToPercent = (ratio: number) =>
|
|
||||||
ratio.toLocaleString(undefined, {
|
|
||||||
style: "percent",
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
@ -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")],
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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/*"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
16614
package-lock.json
generated
16614
package-lock.json
generated
File diff suppressed because it is too large
Load diff
136
package.json
136
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@listory/api",
|
"name": "@listory/api",
|
||||||
"version": "1.31.0",
|
"version": "1.28.0",
|
||||||
"description": "Track your Spotify Listen History",
|
"description": "Track your Spotify Listen History",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Julian Tölle",
|
"name": "Julian Tölle",
|
||||||
|
|
@ -28,91 +28,91 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apricote/nest-pg-boss": "2.1.0",
|
"@apricote/nest-pg-boss": "2.1.0",
|
||||||
"@narando/nest-axios-interceptor": "3.0.0",
|
"@narando/nest-axios-interceptor": "3.0.0",
|
||||||
"@nestjs/axios": "3.1.3",
|
"@nestjs/axios": "3.0.0",
|
||||||
"@nestjs/common": "10.4.15",
|
"@nestjs/common": "10.2.5",
|
||||||
"@nestjs/config": "3.3.0",
|
"@nestjs/config": "3.1.1",
|
||||||
"@nestjs/core": "10.4.15",
|
"@nestjs/core": "10.2.5",
|
||||||
"@nestjs/jwt": "10.2.0",
|
"@nestjs/jwt": "10.1.1",
|
||||||
"@nestjs/passport": "10.0.3",
|
"@nestjs/passport": "10.0.2",
|
||||||
"@nestjs/platform-express": "10.4.15",
|
"@nestjs/platform-express": "10.2.5",
|
||||||
"@nestjs/serve-static": "4.0.2",
|
"@nestjs/serve-static": "4.0.0",
|
||||||
"@nestjs/swagger": "7.4.2",
|
"@nestjs/swagger": "7.1.11",
|
||||||
"@nestjs/terminus": "10.2.3",
|
"@nestjs/terminus": "10.1.1",
|
||||||
"@nestjs/typeorm": "10.0.2",
|
"@nestjs/typeorm": "10.0.0",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.6.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.17.0",
|
||||||
"@opentelemetry/exporter-prometheus": "0.56.0",
|
"@opentelemetry/exporter-prometheus": "0.43.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "0.56.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.43.0",
|
||||||
"@opentelemetry/instrumentation": "0.56.0",
|
"@opentelemetry/instrumentation": "0.43.0",
|
||||||
"@opentelemetry/instrumentation-dns": "0.42.0",
|
"@opentelemetry/instrumentation-dns": "0.32.2",
|
||||||
"@opentelemetry/instrumentation-express": "0.46.0",
|
"@opentelemetry/instrumentation-express": "0.33.1",
|
||||||
"@opentelemetry/instrumentation-http": "0.56.0",
|
"@opentelemetry/instrumentation-http": "0.43.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "0.43.0",
|
"@opentelemetry/instrumentation-nestjs-core": "0.33.1",
|
||||||
"@opentelemetry/instrumentation-pg": "0.49.0",
|
"@opentelemetry/instrumentation-pg": "0.36.1",
|
||||||
"@opentelemetry/instrumentation-pino": "0.45.0",
|
"@opentelemetry/instrumentation-pino": "0.34.1",
|
||||||
"@opentelemetry/resources": "1.29.0",
|
"@opentelemetry/resources": "1.17.0",
|
||||||
"@opentelemetry/sdk-metrics": "1.29.0",
|
"@opentelemetry/sdk-metrics": "1.17.0",
|
||||||
"@opentelemetry/sdk-node": "0.56.0",
|
"@opentelemetry/sdk-node": "0.43.0",
|
||||||
"@opentelemetry/sdk-trace-base": "1.29.0",
|
"@opentelemetry/sdk-trace-base": "1.17.0",
|
||||||
"@opentelemetry/semantic-conventions": "1.28.0",
|
"@opentelemetry/semantic-conventions": "1.17.0",
|
||||||
"@sentry/node": "7.120.3",
|
"@sentry/node": "7.69.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.10.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"nest-raven": "10.1.0",
|
"nest-raven": "10.0.0",
|
||||||
"nestjs-otel": "5.1.5",
|
"nestjs-otel": "5.1.5",
|
||||||
"nestjs-pino": "4.1.0",
|
"nestjs-pino": "3.4.0",
|
||||||
"nestjs-typeorm-paginate": "4.0.4",
|
"nestjs-typeorm-paginate": "4.0.4",
|
||||||
"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.11.3",
|
||||||
"pg-boss": "9.0.3",
|
"pg-boss": "9.0.3",
|
||||||
"pino": "8.21.0",
|
"pino": "8.15.1",
|
||||||
"pino-http": "9.0.0",
|
"pino-http": "8.5.0",
|
||||||
"reflect-metadata": "0.1.14",
|
"reflect-metadata": "0.1.13",
|
||||||
"rimraf": "5.0.10",
|
"rimraf": "5.0.1",
|
||||||
"rxjs": "7.8.1",
|
"rxjs": "7.8.1",
|
||||||
"typeorm": "0.3.20"
|
"typeorm": "0.3.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "10.4.9",
|
"@nestjs/cli": "10.1.17",
|
||||||
"@nestjs/schematics": "10.2.3",
|
"@nestjs/schematics": "10.0.2",
|
||||||
"@nestjs/testing": "10.4.15",
|
"@nestjs/testing": "10.2.5",
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.4",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "4.17.17",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.5",
|
||||||
"@types/lodash": "4.17.14",
|
"@types/lodash": "4.14.198",
|
||||||
"@types/node": "20.17.16",
|
"@types/node": "20.6.2",
|
||||||
"@types/passport-http-bearer": "1.0.41",
|
"@types/passport-http-bearer": "1.0.37",
|
||||||
"@types/passport-jwt": "4.0.1",
|
"@types/passport-jwt": "3.0.9",
|
||||||
"@types/supertest": "6.0.2",
|
"@types/supertest": "2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
"@typescript-eslint/eslint-plugin": "6.7.0",
|
||||||
"@typescript-eslint/parser": "6.21.0",
|
"@typescript-eslint/parser": "6.7.0",
|
||||||
"eslint": "8.57.1",
|
"eslint": "8.49.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.1.0",
|
||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "9.0.0",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.28.1",
|
||||||
"eslint-plugin-jsdoc": "48.11.0",
|
"eslint-plugin-jsdoc": "46.8.1",
|
||||||
"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.33.2",
|
||||||
"eslint-plugin-react-hooks": "4.6.2",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"pino-pretty": "10.3.1",
|
"pino-pretty": "10.2.0",
|
||||||
"prettier": "3.4.2",
|
"prettier": "3.0.3",
|
||||||
"supertest": "6.3.4",
|
"supertest": "6.3.3",
|
||||||
"ts-jest": "29.2.5",
|
"ts-jest": "29.1.1",
|
||||||
"ts-loader": "9.5.1",
|
"ts-loader": "9.4.4",
|
||||||
"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.2.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@
|
||||||
":automergeBranch",
|
":automergeBranch",
|
||||||
":automergeLinters",
|
":automergeLinters",
|
||||||
":automergeTesters",
|
":automergeTesters",
|
||||||
":automergeTypes",
|
":automergeTypes"
|
||||||
":maintainLockFilesWeekly"
|
|
||||||
],
|
],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// handled by Primary Key on (albumId, artistId)
|
// handled by Primary Key on (albumId, artistId)
|
||||||
await queryRunner.dropIndex("album_artists", "IDX_ALBUM_ARTISTS_ALBUM_ID");
|
await queryRunner.dropIndex("album_artist", "IDX_ALBUM_ARTISTS_ALBUM_ID");
|
||||||
|
|
||||||
// handled by Primary Key on (artistId, genreId)
|
// handled by Primary Key on (artistId, genreId)
|
||||||
await queryRunner.dropIndex("artist_genres", "IDX_ARTIST_GENRES_ARTIST_ID");
|
await queryRunner.dropIndex("artist_genres", "IDX_ARTIST_GENRES_ARTIST_ID");
|
||||||
|
|
@ -34,7 +34,7 @@ export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
||||||
}
|
}
|
||||||
|
|
||||||
async down(queryRunner: QueryRunner): Promise<void> {
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.createIndices("album_artists", [
|
await queryRunner.createIndices("album_artist", [
|
||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
|
name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
|
||||||
columnNames: ["albumId"],
|
columnNames: ["albumId"],
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,7 +17,20 @@ 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[]> {
|
||||||
|
|
@ -31,11 +46,9 @@ export class ListensService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ 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(
|
||||||
|
|
@ -52,10 +51,6 @@ async function bootstrap() {
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
export class FindTrackDto {
|
export class FindTrackDto {
|
||||||
spotify: {
|
spotify: {
|
||||||
id?: string;
|
id: string;
|
||||||
uri?: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,9 @@ export class MusicLibraryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTrack(query: FindTrackDto): Promise<Track | undefined> {
|
async findTrack(query: FindTrackDto): Promise<Track | undefined> {
|
||||||
return this.trackRepository.findOneBy(query);
|
return this.trackRepository.findOneBy({
|
||||||
|
spotify: { id: query.spotify.id },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTracks(query: FindTrackDto[]): Promise<Track[]> {
|
async findTracks(query: FindTrackDto[]): Promise<Track[]> {
|
||||||
|
|
|
||||||
5
src/override.d.ts
vendored
Normal file
5
src/override.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Issue with opentelemetry-js: https://github.com/open-telemetry/opentelemetry-js/issues/3580#issuecomment-1701157270
|
||||||
|
export {};
|
||||||
|
declare global {
|
||||||
|
type BlobPropertyBag = unknown;
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ export class SchedulerService implements OnApplicationBootstrap {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupSpotifyCrawlerSupervisor(): Promise<void> {
|
private async setupSpotifyCrawlerSupervisor(): Promise<void> {
|
||||||
// await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
|
await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Span()
|
@Span()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
|
|
||||||
export class ExtendedStreamingHistoryStatusDto {
|
|
||||||
@ApiProperty({
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
total: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
imported: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
import { ArrayMaxSize } from "class-validator";
|
|
||||||
import { SpotifyExtendedStreamingHistoryItemDto } from "./spotify-extended-streaming-history-item.dto";
|
|
||||||
|
|
||||||
export class ImportExtendedStreamingHistoryDto {
|
|
||||||
@ApiProperty({
|
|
||||||
type: SpotifyExtendedStreamingHistoryItemDto,
|
|
||||||
isArray: true,
|
|
||||||
maxItems: 50_000,
|
|
||||||
})
|
|
||||||
@ArrayMaxSize(50_000) // File size is ~16k by default, might need refactoring if Spotify starts exporting larger files
|
|
||||||
listens: SpotifyExtendedStreamingHistoryItemDto[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
|
|
||||||
export class SpotifyExtendedStreamingHistoryItemDto {
|
|
||||||
@ApiProperty({ format: "iso8601", example: "2018-11-30T08:33:33Z" })
|
|
||||||
ts: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: "spotify:track:6askbS4pEVWbbDnUGEXh3G" })
|
|
||||||
spotify_track_uri: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { Body as NestBody, Controller, Get, Post } from "@nestjs/common";
|
|
||||||
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { AuthAccessToken } from "../../../auth/decorators/auth-access-token.decorator";
|
|
||||||
import { ReqUser } from "../../../auth/decorators/req-user.decorator";
|
|
||||||
import { User } from "../../../users/user.entity";
|
|
||||||
import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto";
|
|
||||||
import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto";
|
|
||||||
import { ImportService } from "./import.service";
|
|
||||||
|
|
||||||
@ApiTags("import")
|
|
||||||
@Controller("api/v1/import")
|
|
||||||
export class ImportController {
|
|
||||||
constructor(private readonly importService: ImportService) {}
|
|
||||||
|
|
||||||
@Post("extended-streaming-history")
|
|
||||||
@ApiBody({ type: () => ImportExtendedStreamingHistoryDto })
|
|
||||||
@AuthAccessToken()
|
|
||||||
async importExtendedStreamingHistory(
|
|
||||||
@ReqUser() user: User,
|
|
||||||
@NestBody() data: ImportExtendedStreamingHistoryDto,
|
|
||||||
): Promise<void> {
|
|
||||||
return this.importService.importExtendedStreamingHistory(user, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("extended-streaming-history/status")
|
|
||||||
@AuthAccessToken()
|
|
||||||
async getExtendedStreamingHistoryStatus(
|
|
||||||
@ReqUser() user: User,
|
|
||||||
): Promise<ExtendedStreamingHistoryStatusDto> {
|
|
||||||
return this.importService.getExtendedStreamingHistoryStatus(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
import { JobService } from "@apricote/nest-pg-boss";
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { uniq } from "lodash";
|
|
||||||
import { Span } from "nestjs-otel";
|
|
||||||
import type { Job } from "pg-boss";
|
|
||||||
import { ListensService } from "../../../listens/listens.service";
|
|
||||||
import { User } from "../../../users/user.entity";
|
|
||||||
import { SpotifyService } from "../spotify.service";
|
|
||||||
import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto";
|
|
||||||
import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto";
|
|
||||||
import {
|
|
||||||
IProcessSpotifyExtendedStreamingHistoryListenJob,
|
|
||||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
|
||||||
} from "./jobs";
|
|
||||||
import { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ImportService {
|
|
||||||
private readonly logger = new Logger(this.constructor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly importListenRepository: SpotifyExtendedStreamingHistoryListenRepository,
|
|
||||||
@ProcessSpotifyExtendedStreamingHistoryListenJob.Inject()
|
|
||||||
private readonly processListenJobService: JobService<IProcessSpotifyExtendedStreamingHistoryListenJob>,
|
|
||||||
private readonly spotifyService: SpotifyService,
|
|
||||||
private readonly listensService: ListensService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Span()
|
|
||||||
async importExtendedStreamingHistory(
|
|
||||||
user: User,
|
|
||||||
{ listens: importListens }: ImportExtendedStreamingHistoryDto,
|
|
||||||
): Promise<void> {
|
|
||||||
// IDK what's happening, but my personal data set has entries with duplicate
|
|
||||||
// listens? might be related to offline mode.
|
|
||||||
// Anyway, this cleans it up:
|
|
||||||
const uniqEntries = new Set();
|
|
||||||
const uniqueListens = importListens.filter((listen) => {
|
|
||||||
const key = `${listen.spotify_track_uri}-${listen.ts}`;
|
|
||||||
|
|
||||||
if (!uniqEntries.has(key)) {
|
|
||||||
// New entry
|
|
||||||
uniqEntries.add(key);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
let listens = uniqueListens.map((listenData) =>
|
|
||||||
this.importListenRepository.create({
|
|
||||||
user,
|
|
||||||
playedAt: new Date(listenData.ts),
|
|
||||||
spotifyTrackUri: listenData.spotify_track_uri,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save listens to import table
|
|
||||||
const insertResult = await this.importListenRepository.upsert(listens, [
|
|
||||||
"user",
|
|
||||||
"playedAt",
|
|
||||||
"spotifyTrackUri",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const processJobs = insertResult.identifiers.map((listen) => ({
|
|
||||||
data: {
|
|
||||||
id: listen.id,
|
|
||||||
},
|
|
||||||
singletonKey: listen.id,
|
|
||||||
retryLimit: 10,
|
|
||||||
retryDelay: 5,
|
|
||||||
retryBackoff: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Schedule jobs to process imports
|
|
||||||
await this.processListenJobService.insert(processJobs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ProcessSpotifyExtendedStreamingHistoryListenJob.Handle({
|
|
||||||
// Spotify API "Get Several XY" allows max 50 IDs
|
|
||||||
batchSize: 50,
|
|
||||||
newJobCheckInterval: 500,
|
|
||||||
})
|
|
||||||
@Span()
|
|
||||||
async processListens(
|
|
||||||
jobs: Job<IProcessSpotifyExtendedStreamingHistoryListenJob>[],
|
|
||||||
): Promise<void> {
|
|
||||||
this.logger.debug(
|
|
||||||
{ jobs: jobs.length },
|
|
||||||
"processing extended streaming history listens",
|
|
||||||
);
|
|
||||||
const importListens = await this.importListenRepository.findBy(
|
|
||||||
jobs.map((job) => ({ id: job.data.id })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const listensWithoutTracks = importListens.filter(
|
|
||||||
(importListen) => !importListen.track,
|
|
||||||
);
|
|
||||||
if (listensWithoutTracks.length > 0) {
|
|
||||||
const missingTrackIDs = uniq(
|
|
||||||
listensWithoutTracks.map((importListen) =>
|
|
||||||
importListen.spotifyTrackUri.replace("spotify:track:", ""),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const tracks = await this.spotifyService.importTracks(missingTrackIDs);
|
|
||||||
|
|
||||||
listensWithoutTracks.forEach((listen) => {
|
|
||||||
listen.track = tracks.find(
|
|
||||||
(track) => listen.spotifyTrackUri === track.spotify.uri,
|
|
||||||
);
|
|
||||||
if (!listen.track) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ listen },
|
|
||||||
"could not find track for extended streaming history listen",
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`could not find track for extended streaming history listen`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Using upsert instead of save to only do a single query
|
|
||||||
await this.importListenRepository.upsert(listensWithoutTracks, ["id"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const listensWithoutListen = importListens.filter(
|
|
||||||
(importListen) => !importListen.listen,
|
|
||||||
);
|
|
||||||
if (listensWithoutListen.length > 0) {
|
|
||||||
const listens = await this.listensService.createListens(
|
|
||||||
listensWithoutListen.map((listen) => ({
|
|
||||||
user: listen.user,
|
|
||||||
track: listen.track,
|
|
||||||
playedAt: listen.playedAt,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
listensWithoutListen.forEach((importListen) => {
|
|
||||||
importListen.listen = listens.find(
|
|
||||||
(listen) =>
|
|
||||||
importListen.user.id === listen.user.id &&
|
|
||||||
importListen.track.id === listen.track.id &&
|
|
||||||
importListen.playedAt.getTime() === listen.playedAt.getTime(),
|
|
||||||
);
|
|
||||||
if (!importListen.listen) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ listen: importListen, listens: listens },
|
|
||||||
"could not find listen for extended streaming history listen",
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`could not find listen for extended streaming history listen`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Using upsert instead of save to only do a single query
|
|
||||||
await this.importListenRepository.upsert(listensWithoutListen, ["id"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Span()
|
|
||||||
async getExtendedStreamingHistoryStatus(
|
|
||||||
user: User,
|
|
||||||
): Promise<ExtendedStreamingHistoryStatusDto> {
|
|
||||||
const qb = this.importListenRepository
|
|
||||||
.createQueryBuilder("listen")
|
|
||||||
.where("listen.userId = :user", { user: user.id });
|
|
||||||
|
|
||||||
const [total, imported] = await Promise.all([
|
|
||||||
qb.clone().getCount(),
|
|
||||||
qb.clone().andWhere("listen.listenId IS NOT NULL").getCount(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { total, imported };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export { ImportController } from "./import.controller";
|
|
||||||
export { ImportService } from "./import.service";
|
|
||||||
export { ProcessSpotifyExtendedStreamingHistoryListenJob } from "./jobs";
|
|
||||||
export { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { createJob } from "@apricote/nest-pg-boss";
|
|
||||||
|
|
||||||
export type IProcessSpotifyExtendedStreamingHistoryListenJob = { id: string };
|
|
||||||
export const ProcessSpotifyExtendedStreamingHistoryListenJob =
|
|
||||||
createJob<IProcessSpotifyExtendedStreamingHistoryListenJob>(
|
|
||||||
"process-spotify-extended-streaming-history-listen",
|
|
||||||
);
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
|
||||||
import { Track } from "../../../music-library/track.entity";
|
|
||||||
import { User } from "../../../users/user.entity";
|
|
||||||
import { Listen } from "../../../listens/listen.entity";
|
|
||||||
|
|
||||||
@Entity({ name: "spotify_extended_streaming_history_listen" })
|
|
||||||
export class SpotifyExtendedStreamingHistoryListen {
|
|
||||||
@PrimaryGeneratedColumn("uuid")
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { eager: true })
|
|
||||||
user: User;
|
|
||||||
|
|
||||||
@Column({ type: "timestamp" })
|
|
||||||
playedAt: Date;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
spotifyTrackUri: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Track, { nullable: true, eager: true })
|
|
||||||
track?: Track;
|
|
||||||
|
|
||||||
@ManyToOne(() => Listen, { nullable: true, eager: true })
|
|
||||||
listen?: Listen;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { Repository } from "typeorm";
|
|
||||||
import { EntityRepository } from "../../../database/entity-repository";
|
|
||||||
import { SpotifyExtendedStreamingHistoryListen } from "./listen.entity";
|
|
||||||
|
|
||||||
@EntityRepository(SpotifyExtendedStreamingHistoryListen)
|
|
||||||
export class SpotifyExtendedStreamingHistoryListenRepository extends Repository<SpotifyExtendedStreamingHistoryListen> {}
|
|
||||||
|
|
@ -1,37 +1,25 @@
|
||||||
import { PGBossModule } from "@apricote/nest-pg-boss";
|
import { PGBossModule } from "@apricote/nest-pg-boss";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmRepositoryModule } from "../../database/entity-repository/typeorm-repository.module";
|
|
||||||
import { ListensModule } from "../../listens/listens.module";
|
import { ListensModule } from "../../listens/listens.module";
|
||||||
import { MusicLibraryModule } from "../../music-library/music-library.module";
|
import { MusicLibraryModule } from "../../music-library/music-library.module";
|
||||||
import { UsersModule } from "../../users/users.module";
|
import { UsersModule } from "../../users/users.module";
|
||||||
import { ImportSpotifyJob } from "../jobs";
|
import { ImportSpotifyJob } from "../jobs";
|
||||||
import {
|
|
||||||
ImportController,
|
|
||||||
ImportService,
|
|
||||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
|
||||||
SpotifyExtendedStreamingHistoryListenRepository,
|
|
||||||
} from "./import-extended-streaming-history";
|
|
||||||
import { SpotifyApiModule } from "./spotify-api/spotify-api.module";
|
import { SpotifyApiModule } from "./spotify-api/spotify-api.module";
|
||||||
import { SpotifyAuthModule } from "./spotify-auth/spotify-auth.module";
|
import { SpotifyAuthModule } from "./spotify-auth/spotify-auth.module";
|
||||||
import { SpotifyService } from "./spotify.service";
|
import { SpotifyService } from "./spotify.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PGBossModule.forJobs([
|
PGBossModule.forJobs([ImportSpotifyJob]),
|
||||||
ImportSpotifyJob,
|
|
||||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
|
||||||
]),
|
|
||||||
TypeOrmRepositoryModule.for([
|
|
||||||
SpotifyExtendedStreamingHistoryListenRepository,
|
|
||||||
]),
|
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ListensModule,
|
ListensModule,
|
||||||
MusicLibraryModule,
|
MusicLibraryModule,
|
||||||
SpotifyApiModule,
|
SpotifyApiModule,
|
||||||
SpotifyAuthModule,
|
SpotifyAuthModule,
|
||||||
],
|
],
|
||||||
providers: [SpotifyService, ImportService],
|
providers: [SpotifyService],
|
||||||
controllers: [ImportController],
|
|
||||||
exports: [SpotifyService],
|
exports: [SpotifyService],
|
||||||
})
|
})
|
||||||
export class SpotifyModule {}
|
export class SpotifyModule {
|
||||||
|
constructor(private readonly spotifyService: SpotifyService) {}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,6 @@ export class SpotifyService {
|
||||||
private readonly spotifyAuth: SpotifyAuthService,
|
private readonly spotifyAuth: SpotifyAuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a list of users that should be crawled for new listens.
|
|
||||||
* Only returns the lastListen date if it was within the last hour.
|
|
||||||
*/
|
|
||||||
@Span()
|
@Span()
|
||||||
async getCrawlableUserInfo(): Promise<
|
async getCrawlableUserInfo(): Promise<
|
||||||
{ userID: string; lastListen: Date }[]
|
{ userID: string; lastListen: Date }[]
|
||||||
|
|
@ -61,7 +57,6 @@ export class SpotifyService {
|
||||||
.select(`listen."userId"`)
|
.select(`listen."userId"`)
|
||||||
.addSelect(`listen."playedAt"`)
|
.addSelect(`listen."playedAt"`)
|
||||||
.from("listen", "listen")
|
.from("listen", "listen")
|
||||||
.where(`listen."playedAt" > now() - interval '1 hour'`)
|
|
||||||
.orderBy("listen.userId", "DESC")
|
.orderBy("listen.userId", "DESC")
|
||||||
.addOrderBy("listen.playedAt", "DESC"),
|
.addOrderBy("listen.playedAt", "DESC"),
|
||||||
"listen",
|
"listen",
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"target": "ES2022",
|
"target": "ES2020",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"paths": {},
|
"paths": {},
|
||||||
|
"lib": ["ES2020"]
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist"],
|
"exclude": ["node_modules", "dist"],
|
||||||
"include": ["src", "test"]
|
"include": ["src", "test"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue