Compare commits

..

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

125 changed files with 23257 additions and 13016 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,8 +14,8 @@ type: application
# 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.
version: 1.31.0
version: 1.26.1
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application.
appVersion: 1.31.0
appVersion: 1.26.1

View file

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

View file

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

44
frontend/README.md Normal file
View file

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

View file

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

13622
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -2,21 +2,20 @@ import React from "react";
import { Route, Routes } from "react-router-dom";
import { AuthApiTokens } from "./components/AuthApiTokens";
import { Footer } from "./components/Footer";
import { ImportListens } from "./components/ImportListens";
import { LoginFailure } from "./components/LoginFailure";
import { LoginLoading } from "./components/LoginLoading";
import { LoginSuccess } from "./components/LoginSuccess";
import { NavBar } from "./components/NavBar";
import { Navigate } from "react-router-dom";
import { RecentListens } from "./components/reports/RecentListens";
import { ReportListens } from "./components/reports/ReportListens";
import { ReportTopAlbums } from "./components/reports/ReportTopAlbums";
import { ReportTopArtists } from "./components/reports/ReportTopArtists";
import { ReportTopGenres } from "./components/reports/ReportTopGenres";
import { ReportTopTracks } from "./components/reports/ReportTopTracks";
import { RecentListens } from "./components/RecentListens";
import { ReportListens } from "./components/ReportListens";
import { ReportTopAlbums } from "./components/ReportTopAlbums";
import { ReportTopArtists } from "./components/ReportTopArtists";
import { ReportTopGenres } from "./components/ReportTopGenres";
import { ReportTopTracks } from "./components/ReportTopTracks";
import { useAuth } from "./hooks/use-auth";
export function App() {
const { isLoaded, user } = useAuth();
const { isLoaded } = useAuth();
if (!isLoaded) {
return <LoginLoading />;
@ -28,43 +27,18 @@ export function App() {
<NavBar />
</header>
<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">
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 lg:max-w-screen-lg">
{user && (
<Routes>
<Route index element={<Navigate to="/listens" />} />
<Route path="/login/success" element={<Navigate to="/" />} />
<Route path="/login/failure" element={<LoginFailure />} />
<Route path="/listens" element={<RecentListens />} />
<Route path="/reports/listens" element={<ReportListens />} />
<Route
path="/reports/top-artists"
element={<ReportTopArtists />}
/>
<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>
<Routes>
<Route path="/" />
<Route path="/login/success" element={<LoginSuccess />} />
<Route path="/login/failure" element={<LoginFailure />} />
<Route path="/listens" element={<RecentListens />} />
<Route path="/reports/listens" element={<ReportListens />} />
<Route path="/reports/top-artists" element={<ReportTopArtists />} />
<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 />} />
</Routes>
</main>
<footer>
<Footer />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,124 +1,55 @@
import React from "react";
import React, { useCallback, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { User } from "../api/entities/user";
import { useAuth } from "../hooks/use-auth";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { CogwheelIcon } from "../icons/Cogwheel";
import { ImportIcon } from "../icons/Import";
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 = () => {
const { user, loginWithSpotifyProps } = useAuth();
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 shrink-0 mr-6">
<span className="font-semibold text-xl tracking-tight text-white">
Listory
</span>
<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 text-white mr-6">
<span className="font-semibold text-xl tracking-tight">Listory</span>
</div>
<nav className="w-full grow sm:flex sm:items-center sm:w-auto">
<div className="sm:grow">
<nav className="w-full block grow lg:flex lg:items-center lg:w-auto ">
<div className="text-sm lg:grow">
{user && (
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink
asChild
className={navigationMenuTriggerStyle()}
>
<Link to="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink
asChild
className={navigationMenuTriggerStyle()}
>
<Link to="/listens">Your Listens</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Reports</NavigationMenuTrigger>
<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>
<>
<Link to="/">
<NavItem>Home</NavItem>
</Link>
<Link to="/listens">
<NavItem>Your Listens</NavItem>
</Link>
<Link to="/reports/listens">
<NavItem>Listens Report</NavItem>
</Link>
<Link to="/reports/top-artists">
<NavItem>Top Artists</NavItem>
</Link>
<Link to="/reports/top-albums">
<NavItem>Top Albums</NavItem>
</Link>
<Link to="/reports/top-tracks">
<NavItem>Top Tracks</NavItem>
</Link>
<Link to="/reports/top-genres">
<NavItem>Top Genres</NavItem>
</Link>
</>
)}
</div>
<div>
{!user && (
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink
asChild
className={navigationMenuTriggerStyle()}
>
<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>
<a {...loginWithSpotifyProps()}>
<NavItem>
Login with Spotify{" "}
<SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
</NavItem>
</a>
)}
{user && <NavUserInfo user={user} />}
</div>
@ -127,76 +58,58 @@ export const NavBar: React.FC = () => {
);
};
const NavListItem = React.forwardRef<
React.ElementRef<typeof Link>,
React.ComponentPropsWithoutRef<typeof Link>
>(({ className, title, children, ...props }, ref) => {
const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<li>
<NavigationMenuLink asChild>
<Link
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,
)}
{...props}
>
<div className="text-sm font-medium leading-none">{title}</div>
<p className="line-clamp-3 text-sm leading-snug text-muted-foreground">
{children}
</p>
</Link>
</NavigationMenuLink>
</li>
);
});
NavListItem.displayName = "NavListItem";
const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={"ghost"}
className="flex flex-row-reverse sm:flex-row px-0 mt-2 sm:px-8"
>
<span className="text-green-200 pl-2 sm:pr-2">
{user.displayName}
</span>
<Avatar>
<AvatarImage
src={user.photo}
alt="Profile picture of logged in user"
/>
<AvatarFallback>
{user.displayName
.split(" ")
.filter((name) => name.length > 0)
.map((name) => name[0].toUpperCase())
.join("")}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<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>
<span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
{children}
</span>
);
};
const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const closeMenu = useCallback(() => setMenuOpen(false), [setMenuOpen]);
const wrapperRef = useRef(null);
useOutsideClick(wrapperRef, closeMenu);
return (
<div ref={wrapperRef}>
<div
className="flex items-center mr-4 mt-4 lg:mt-0 cursor-pointer"
onClick={() => setMenuOpen(!menuOpen)}
>
<span className="text-green-200 text-sm">{user.displayName}</span>
{user.photo && (
<img
className="w-6 h-6 rounded-full ml-4"
src={user.photo}
alt="Profile of logged in user"
></img>
)}
</div>
{menuOpen ? <NavUserInfoMenu closeMenu={closeMenu} /> : null}
</div>
);
};
const NavUserInfoMenu: React.FC<{ closeMenu: () => void }> = ({
closeMenu,
}) => {
return (
<div className="relative">
<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">
<ul>
<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">
<span>
<CogwheelIcon className="w-5 h-5 fill-current" />
</span>
<Link to="/auth/api-tokens" onClick={closeMenu}>
API Tokens
</Link>
</li>
</ul>
</div>
</div>
);
};

View file

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

View file

@ -10,25 +10,20 @@ import {
XAxis,
YAxis,
} from "recharts";
import { ListenReportItem } from "../../api/entities/listen-report-item";
import { ListenReportOptions } from "../../api/entities/listen-report-options";
import { TimeOptions } from "../../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum";
import { useListensReport } from "../../hooks/use-api";
import { ListenReportItem } from "../api/entities/listen-report-item";
import { ListenReportOptions } from "../api/entities/listen-report-options";
import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum";
import { useListensReport } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { ReportTimeOptions } from "./ReportTimeOptions";
import { Spinner } from "../ui/Spinner";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Spinner } from "./Spinner";
export const ReportListens: React.FC = () => {
const { requireUser } = useAuthProtection();
const [timeFrame, setTimeFrame] = useState<"day" | "week" | "month" | "year">(
"day",
"day"
);
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
@ -39,60 +34,60 @@ export const ReportListens: React.FC = () => {
const reportOptions = useMemo(
() => ({ timeFrame, time: timeOptions }),
[timeFrame, timeOptions],
[timeFrame, timeOptions]
);
const { report, isLoading } = useListensReport(reportOptions);
const reportHasItems = report.length !== 0;
requireUser();
return (
<>
<div className="flex justify-between">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Listen Report
</h2>
</div>
<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 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">
Listen Report
</p>
</div>
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is empty! :(</p>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<div className="md:flex">
<div className="text-gray-700 dark:text-gray-300 mr-2">
<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>
)}
{reportHasItems && (
<div className="w-full text-gray-700 dark:text-gray-300 mt-5">
<ReportGraph timeFrame={timeFrame} data={report} />
</div>
)}
{isLoading && <Spinner className="m-8" />}
{!reportHasItems && !isLoading && (
<div>
<p>Report is emtpy! :(</p>
</div>
)}
{reportHasItems && (
<div className="w-full text-gray-700 dark:text-gray-300 mt-5">
<ReportGraph timeFrame={timeFrame} data={report} />
</div>
)}
</div>
</div>
</>
</div>
);
};
@ -133,7 +128,7 @@ const ReportGraph: React.FC<{
<AreaChart
data={dataLocal}
margin={{
left: -5,
left: -20,
}}
>
<defs>
@ -168,7 +163,7 @@ const ReportGraph: React.FC<{
};
const shortDateFormatFromTimeFrame = (
timeFrame: "day" | "week" | "month" | "year",
timeFrame: "day" | "week" | "month" | "year"
): string => {
const FORMAT_DAY = "P";
const FORMAT_WEEK = "'Week' w yyyy";
@ -191,7 +186,7 @@ const shortDateFormatFromTimeFrame = (
};
const dateFormatFromTimeFrame = (
timeFrame: "day" | "week" | "month" | "year",
timeFrame: "day" | "week" | "month" | "year"
): string => {
const FORMAT_DAY = "PPPP";
const FORMAT_WEEK = "'Week starting on' PPPP";

View file

@ -1,15 +1,7 @@
import React from "react";
import { TimeOptions } from "../../api/entities/time-options";
import { TimePreset } from "../../api/entities/time-preset.enum";
import { DateSelect } from "../inputs/DateSelect";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum";
import { DateSelect } from "./inputs/DateSelect";
interface ReportTimeOptionsProps {
timeOptions: TimeOptions;
@ -31,34 +23,28 @@ export const ReportTimeOptions: React.FC<ReportTimeOptionsProps> = ({
setTimeOptions,
}) => {
return (
<div className="sm:flex mb-4">
<div className="md:flex mb-4">
<div className="text-gray-700 dark:text-gray-300">
<Label className="text-sm" htmlFor={"period"}>
Period
</Label>
<Select
onValueChange={(e: TimePreset) =>
<label className="text-sm">Timeframe</label>
<select
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"
onChange={(e) =>
setTimeOptions({
...timeOptions,
timePreset: e,
timePreset: e.target.value as TimePreset,
})
}
value={timeOptions.timePreset}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Time period" />
</SelectTrigger>
<SelectContent>
{timePresetOptions.map(({ value, description }) => (
<SelectItem value={value} key={value}>
{description}
</SelectItem>
))}
</SelectContent>
</Select>
{timePresetOptions.map(({ value, description }) => (
<option value={value} key={value}>
{description}
</option>
))}
</select>
</div>
{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">
<DateSelect
label="Start"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

18286
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,9 @@ import {
paginate,
PaginationTypeEnum,
} from "nestjs-typeorm-paginate";
import { Track } from "../music-library/track.entity";
import { User } from "../users/user.entity";
import { CreateListenResponseDto } from "./dto/create-listen.dto";
import { GetListensDto } from "./dto/get-listens.dto";
import { Listen } from "./listen.entity";
import { ListenRepository, ListenScopes } from "./listen.repository";
@ -33,6 +35,39 @@ describe("ListensService", () => {
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", () => {
let options: GetListensDto & IPaginationOptions;
let user: User;

View file

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

View file

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

View file

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

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