mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
Compare commits
No commits in common. "main" and "v1.28.0" have entirely different histories.
85 changed files with 22185 additions and 11142 deletions
|
|
@ -19,6 +19,5 @@
|
|||
!frontend/tsconfig.json
|
||||
!frontend/vite.config.js
|
||||
!frontend/index.html
|
||||
!frontend/*.d.ts
|
||||
!frontend/src/**/*
|
||||
!frontend/public/**/*
|
||||
|
|
|
|||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
|
|
|||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
version: "v0.11.2"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
|
|
|||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -1,45 +1,3 @@
|
|||
# [1.31.0](https://github.com/apricote/Listory/compare/v1.30.1...v1.31.0) (2023-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** hide revoked api tokens ([#307](https://github.com/apricote/Listory/issues/307)) ([4cef4f7](https://github.com/apricote/Listory/commit/4cef4f75ace6a38ba19c1d2f93d81389ec2b7cb8))
|
||||
|
||||
## [1.30.1](https://github.com/apricote/Listory/compare/v1.30.0...v1.30.1) (2023-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* no listens are being crawled ([#306](https://github.com/apricote/Listory/issues/306)) ([9af3115](https://github.com/apricote/Listory/commit/9af3115cab19cc4ac4a6cd0fb680371154069aa2))
|
||||
|
||||
# [1.30.0](https://github.com/apricote/Listory/compare/v1.29.0...v1.30.0) (2023-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* import listens from spotify extended streaming history ([#305](https://github.com/apricote/Listory/issues/305)) ([7140cb0](https://github.com/apricote/Listory/commit/7140cb0679ec3aac8a2102197d9edb070cf0e6c0))
|
||||
|
||||
# [1.29.0](https://github.com/apricote/Listory/compare/v1.28.2...v1.29.0) (2023-09-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** general revamp of navigation & pages ([#303](https://github.com/apricote/Listory/issues/303)) ([4b1dd10](https://github.com/apricote/Listory/commit/4b1dd10846d741cd39fe9b0f41150e68510f2220))
|
||||
|
||||
## [1.28.2](https://github.com/apricote/Listory/compare/v1.28.1...v1.28.2) (2023-09-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* slow query taking up 66% of db time ([#298](https://github.com/apricote/Listory/issues/298)) ([625db7d](https://github.com/apricote/Listory/commit/625db7dbe71a7315921562bfab82420c04aa6c17))
|
||||
|
||||
## [1.28.1](https://github.com/apricote/Listory/compare/v1.28.0...v1.28.1) (2023-09-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid database migration ([9ba47a5](https://github.com/apricote/Listory/commit/9ba47a560c9c98866cd2dc34c3997f96f027f65e))
|
||||
|
||||
# [1.28.0](https://github.com/apricote/Listory/compare/v1.27.0...v1.28.0) (2023-09-16)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
# syntax=docker/dockerfile:1.12
|
||||
# syntax=docker/dockerfile:1.5
|
||||
|
||||
FROM scratch as ignore
|
||||
|
||||
WORKDIR /listory
|
||||
COPY . /listory/
|
||||
|
||||
##################
|
||||
## common
|
||||
|
|
|
|||
|
|
@ -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.28.0
|
||||
|
||||
# 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.28.0
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ services:
|
|||
#####
|
||||
|
||||
db:
|
||||
image: postgres:16.6
|
||||
image: postgres:16.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: listory
|
||||
|
|
@ -18,7 +18,7 @@ services:
|
|||
- db
|
||||
|
||||
api:
|
||||
image: apricote/listory:1.31.0
|
||||
image: apricote/listory:1.28.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: db
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
#####
|
||||
|
||||
db:
|
||||
image: postgres:16.6
|
||||
image: postgres:16.0
|
||||
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.4
|
||||
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.47.0
|
||||
volumes:
|
||||
- ./observability/prometheus:/etc/prometheus
|
||||
- prometheus_data:/prometheus
|
||||
|
|
@ -148,7 +146,7 @@ services:
|
|||
|
||||
loki:
|
||||
profiles: ["observability"]
|
||||
image: grafana/loki:2.9.11
|
||||
image: grafana/loki:2.9.1
|
||||
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.9.1
|
||||
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.2.3
|
||||
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:10.1.1
|
||||
volumes:
|
||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "./src/index.css",
|
||||
"baseColor": "gray",
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/components",
|
||||
"utils": "src/lib/utils"
|
||||
}
|
||||
}
|
||||
13108
frontend/package-lock.json
generated
13108
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,44 +8,32 @@
|
|||
},
|
||||
"license": "MIT",
|
||||
"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": "6.1.3",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.5.1",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@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.4",
|
||||
"autoprefixer": "10.4.15",
|
||||
"axios": "1.5.0",
|
||||
"date-fns": "2.30.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"jsdom": "22.1.0",
|
||||
"lucide-react": "0.468.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.29",
|
||||
"prettier": "3.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"recharts": "2.8.0",
|
||||
"tailwindcss": "3.3.3",
|
||||
"typescript": "5.2.2",
|
||||
"vite": "4.4.9",
|
||||
"vitest": "0.34.4"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write \"./*.js\" \"src/**/*.(tsx|ts|css)\"",
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ 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 {}
|
||||
|
||||
|
|
@ -278,7 +276,7 @@ export const revokeApiToken = async (
|
|||
id: string,
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export interface ExtendedStreamingHistoryStatus {
|
||||
total: number;
|
||||
imported: number;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export interface SpotifyExtendedStreamingHistoryItem {
|
||||
ts: string;
|
||||
spotify_track_uri: string;
|
||||
}
|
||||
|
|
@ -2,61 +2,65 @@ import { format, formatDistanceToNow } from "date-fns";
|
|||
import React, { FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import { 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.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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Files from "react-files";
|
||||
import type { ReactFile } from "react-files";
|
||||
import {
|
||||
useSpotifyImportExtendedStreamingHistory,
|
||||
useSpotifyImportExtendedStreamingHistoryStatus,
|
||||
} from "../hooks/use-api";
|
||||
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
|
||||
import { ErrorIcon } from "../icons/Error";
|
||||
import { numberToPercent } from "../util/numberToPercent";
|
||||
import { Button } from "./ui/button";
|
||||
import { Table, TableBody, TableCell, TableRow } from "./ui/table";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Code } from "./ui/code";
|
||||
|
||||
export const ImportListens: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal">
|
||||
Import Listens from Spotify Extended Streaming History
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
|
||||
<p className="my-4">
|
||||
Here you can import your full Spotify Listen history that was exported
|
||||
from the{" "}
|
||||
<a
|
||||
target="blank"
|
||||
href="https://www.spotify.com/us/account/privacy/"
|
||||
className="underline"
|
||||
>
|
||||
Extended streaming history
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
The extended streaming history contains additional personally
|
||||
identifiable data such as the IP address of the listen (which can be
|
||||
linked to locations). To avoid saving this on the server, the data is
|
||||
preprocessed in your web browser and only the necessary data
|
||||
(timestamp & track ID) are sent to the server.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
If an error occurs, you can always retry uploading the file, Listory
|
||||
deduplicates any listens to make sure that everything is saved only
|
||||
once.
|
||||
</p>
|
||||
|
||||
<FileUpload />
|
||||
<ImportProgress />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileData {
|
||||
file: ReactFile;
|
||||
status: Status;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
enum Status {
|
||||
Select,
|
||||
Import,
|
||||
Finished,
|
||||
Error,
|
||||
}
|
||||
|
||||
const FileUpload: React.FC = () => {
|
||||
// Using a map is ... meh, need to wrap all state updates in `new Map()` so react re-renders
|
||||
const [fileMap, setFileMap] = useState<Map<ReactFile["id"], FileData>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const [status, setStatus] = useState<Status>(Status.Select);
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: ReactFile[]) => {
|
||||
setFileMap((_fileMap) => {
|
||||
files.forEach((file) =>
|
||||
_fileMap.set(file.id, { file, status: Status.Select }),
|
||||
);
|
||||
return new Map(_fileMap);
|
||||
});
|
||||
},
|
||||
[setFileMap],
|
||||
);
|
||||
|
||||
const updateFile = useCallback((data: FileData) => {
|
||||
setFileMap((_fileMap) => new Map(_fileMap.set(data.file.id, data)));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFileMap(new Map());
|
||||
}, [setFileMap]);
|
||||
|
||||
const { importHistory } = useSpotifyImportExtendedStreamingHistory();
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
setStatus(Status.Import);
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
for (const data of fileMap.values()) {
|
||||
data.status = Status.Import;
|
||||
updateFile(data);
|
||||
|
||||
let items: SpotifyExtendedStreamingHistoryItem[];
|
||||
|
||||
// Scope so these tmp variables can be GC-ed ASAP
|
||||
{
|
||||
const fileContent = await data.file.text();
|
||||
|
||||
const rawItems = JSON.parse(
|
||||
fileContent,
|
||||
) as SpotifyExtendedStreamingHistoryItem[];
|
||||
|
||||
items = rawItems
|
||||
.filter(({ spotify_track_uri }) => spotify_track_uri !== null)
|
||||
.map(({ ts, spotify_track_uri }) => ({
|
||||
ts,
|
||||
spotify_track_uri,
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
await importHistory(items);
|
||||
|
||||
data.status = Status.Finished;
|
||||
} catch (err) {
|
||||
data.error = err as Error;
|
||||
data.status = Status.Error;
|
||||
|
||||
errorOccurred = true;
|
||||
}
|
||||
updateFile(data);
|
||||
}
|
||||
|
||||
if (!errorOccurred) {
|
||||
setStatus(Status.Finished);
|
||||
}
|
||||
}, [fileMap, importHistory, updateFile]);
|
||||
|
||||
return (
|
||||
<Card className="mb-5">
|
||||
<CardHeader>
|
||||
<CardTitle>File Upload</CardTitle>
|
||||
<CardDescription>
|
||||
Select <Code>endsong_XY.json</Code> files here and start the import.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Files
|
||||
className="shadow-inner bg-gray-200 dark:bg-gray-700 rounded p-4 text-center cursor-pointer"
|
||||
dragActiveClassName=""
|
||||
onChange={addFiles}
|
||||
accepts={["application/json"]}
|
||||
multiple
|
||||
clickable
|
||||
>
|
||||
Drop files here or click to upload
|
||||
</Files>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{Array.from(fileMap.values()).map((data) => (
|
||||
<File key={data.file.id} data={data} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-x-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleImport().catch((e) => console.error("Import Failed:", e))
|
||||
}
|
||||
variant="secondary"
|
||||
disabled={status !== Status.Select}
|
||||
>
|
||||
Start Import
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearFiles}
|
||||
variant="secondary"
|
||||
disabled={status !== Status.Select}
|
||||
>
|
||||
Remove All Files
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const File: React.FC<{ data: FileData }> = ({ data }) => {
|
||||
const hasErrors = data.status === Status.Error && data.error;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{data.file.name}</TableCell>
|
||||
<TableCell className="text-sm font-thin">
|
||||
{data.file.sizeReadable}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{data.status === Status.Select && <Badge>Prepared for import!</Badge>}
|
||||
{data.status === Status.Import && <Badge>Loading!</Badge>}
|
||||
{data.status === Status.Finished && <Badge>Check!</Badge>}
|
||||
{hasErrors && (
|
||||
<Badge variant="destructive">
|
||||
<ErrorIcon />
|
||||
{data.error?.message}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportProgress: React.FC = () => {
|
||||
const {
|
||||
importStatus: { total, imported },
|
||||
isLoading,
|
||||
reload,
|
||||
} = useSpotifyImportExtendedStreamingHistoryStatus();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isLoading) {
|
||||
reload();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading, reload]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Shows how many of the submitted listens are already imported and
|
||||
visible to you. This will take a while, and the process might halt for
|
||||
a few minutes if we hit the Spotify API rate limit. If this is not
|
||||
finished after a few hours, please contact your Listory administrator.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex pb-2">
|
||||
<div className="md:flex w-10/12">
|
||||
<div className={`md:w-full font-bold`}>
|
||||
Imported
|
||||
<br />
|
||||
{imported}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/12 text-right">
|
||||
Total
|
||||
<br />
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="h-2 w-full bg-gradient-to-r from-teal-200/25 via-green-400 to-violet-400 dark:from-teal-700/25 dark:via-green-600/85 dark:to-amber-500 flex flex-row-reverse">
|
||||
<div
|
||||
style={{ width: numberToPercent(1 - imported / total) }}
|
||||
className="h-full bg-gray-100 dark:bg-gray-900"
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { 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">
|
||||
|
|
|
|||
7
frontend/src/components/LoginSuccess.tsx
Normal file
7
frontend/src/components/LoginSuccess.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const LoginSuccess: React.FC = () => {
|
||||
useNavigate()("/", { replace: false });
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,124 +1,55 @@
|
|||
import React from "react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { User } from "../api/entities/user";
|
||||
import { useAuth } from "../hooks/use-auth";
|
||||
import { useOutsideClick } from "../hooks/use-outside-click";
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,23 +10,18 @@ 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",
|
||||
);
|
||||
|
|
@ -46,53 +41,53 @@ export const ReportListens: React.FC = () => {
|
|||
|
||||
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 empty! :(</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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"
|
||||
85
frontend/src/components/ReportTopAlbums.tsx
Normal file
85
frontend/src/components/ReportTopAlbums.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Album } from "../api/entities/album";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { useTopAlbums } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopAlbums: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topAlbums, isLoading } = useTopAlbums(options);
|
||||
|
||||
const reportHasItems = topAlbums.length !== 0;
|
||||
const maxCount = getMaxCount(topAlbums);
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Albums
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topAlbums.map(({ album, count }) => (
|
||||
<ReportItem
|
||||
key={album.id}
|
||||
album={album}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
album: Album;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ album, count, maxCount }) => {
|
||||
const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
key={album.id}
|
||||
title={album.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
67
frontend/src/components/ReportTopArtists.tsx
Normal file
67
frontend/src/components/ReportTopArtists.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { useTopArtists } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopArtists: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topArtists, isLoading } = useTopArtists(options);
|
||||
|
||||
const reportHasItems = topArtists.length !== 0;
|
||||
const maxCount = getMaxCount(topArtists);
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Artists
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topArtists.map(({ artist, count }) => (
|
||||
<TopListItem
|
||||
key={artist.id}
|
||||
title={artist.name}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
frontend/src/components/ReportTopGenres.tsx
Normal file
102
frontend/src/components/ReportTopGenres.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Artist } from "../api/entities/artist";
|
||||
import { Genre } from "../api/entities/genre";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { TopArtistsItem } from "../api/entities/top-artists-item";
|
||||
import { useTopGenres } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { capitalizeString } from "../util/capitalizeString";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopGenres: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topGenres, isLoading } = useTopGenres(options);
|
||||
|
||||
const reportHasItems = topGenres.length !== 0;
|
||||
|
||||
requireUser();
|
||||
|
||||
const maxCount = getMaxCount(topGenres);
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Genres
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topGenres.map(({ genre, artists, count }) => (
|
||||
<ReportItem
|
||||
key={genre.id}
|
||||
genre={genre}
|
||||
count={count}
|
||||
artists={artists}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
genre: Genre;
|
||||
artists: TopArtistsItem[];
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ genre, artists, count, maxCount }) => {
|
||||
const artistList = artists
|
||||
.map(({ artist, count: artistCount }) => (
|
||||
<ArtistItem key={artist.id} artist={artist} count={artistCount} />
|
||||
))
|
||||
// @ts-expect-error
|
||||
.reduce((acc, curr) => (acc === null ? [curr] : [acc, ", ", curr]), null);
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={capitalizeString(genre.name)}
|
||||
subTitle={artistList}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistItem: React.FC<{
|
||||
artist: Artist;
|
||||
count: number;
|
||||
}> = ({ artist, count }) => (
|
||||
<span title={`Listens: ${count}`}>{artist.name}</span>
|
||||
);
|
||||
85
frontend/src/components/ReportTopTracks.tsx
Normal file
85
frontend/src/components/ReportTopTracks.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { Track } from "../api/entities/track";
|
||||
import { useTopTracks } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopTracks: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topTracks, isLoading } = useTopTracks(options);
|
||||
|
||||
const reportHasItems = topTracks.length !== 0;
|
||||
|
||||
requireUser();
|
||||
|
||||
const maxCount = getMaxCount(topTracks);
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topTracks.map(({ track, count }) => (
|
||||
<ReportItem
|
||||
key={track.id}
|
||||
track={track}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
track: Track;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ track, count, maxCount }) => {
|
||||
const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={track.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { SpinnerIcon } from "../../icons/Spinner";
|
||||
import { SpinnerIcon } from "../icons/Spinner";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { 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,
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Album } from "../../api/entities/album";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { useTopAlbums } from "../../hooks/use-api";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopAlbums: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topAlbums, isLoading } = useTopAlbums(options);
|
||||
|
||||
const reportHasItems = topAlbums.length !== 0;
|
||||
const maxCount = getMaxCount(topAlbums);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Albums
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topAlbums.map(({ album, count }) => (
|
||||
<ReportItem
|
||||
key={album.id}
|
||||
album={album}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
album: Album;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ album, count, maxCount }) => {
|
||||
const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
key={album.id}
|
||||
title={album.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { useTopArtists } from "../../hooks/use-api";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopArtists: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topArtists, isLoading } = useTopArtists(options);
|
||||
|
||||
const reportHasItems = topArtists.length !== 0;
|
||||
const maxCount = getMaxCount(topArtists);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Artists
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topArtists.map(({ artist, count }) => (
|
||||
<TopListItem
|
||||
key={artist.id}
|
||||
title={artist.name}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Artist } from "../../api/entities/artist";
|
||||
import { Genre } from "../../api/entities/genre";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { TopArtistsItem } from "../../api/entities/top-artists-item";
|
||||
import { useTopGenres } from "../../hooks/use-api";
|
||||
import { capitalizeString } from "../../util/capitalizeString";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopGenres: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topGenres, isLoading } = useTopGenres(options);
|
||||
|
||||
const reportHasItems = topGenres.length !== 0;
|
||||
|
||||
const maxCount = getMaxCount(topGenres);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Genres
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topGenres.map(({ genre, artists, count }) => (
|
||||
<ReportItem
|
||||
key={genre.id}
|
||||
genre={genre}
|
||||
count={count}
|
||||
artists={artists}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
genre: Genre;
|
||||
artists: TopArtistsItem[];
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ genre, artists, count, maxCount }) => {
|
||||
const artistList = artists
|
||||
.map(({ artist, count: artistCount }) => (
|
||||
<ArtistItem key={artist.id} artist={artist} count={artistCount} />
|
||||
))
|
||||
// @ts-expect-error
|
||||
.reduce((acc, curr) => (acc === null ? [curr] : [acc, ", ", curr]), null);
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={capitalizeString(genre.name)}
|
||||
subTitle={artistList}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistItem: React.FC<{
|
||||
artist: Artist;
|
||||
count: number;
|
||||
}> = ({ artist, count }) => (
|
||||
<span title={`Listens: ${count}`}>{artist.name}</span>
|
||||
);
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { Track } from "../../api/entities/track";
|
||||
import { useTopTracks } from "../../hooks/use-api";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopTracks: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topTracks, isLoading } = useTopTracks(options);
|
||||
|
||||
const reportHasItems = topTracks.length !== 0;
|
||||
|
||||
const maxCount = getMaxCount(topTracks);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Tracks
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topTracks.map(({ track, count }) => (
|
||||
<ReportItem
|
||||
key={track.id}
|
||||
track={track}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
track: Track;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ track, count, maxCount }) => {
|
||||
const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={track.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-gray-900 text-gray-50 hover:bg-gray-900/80 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/80",
|
||||
secondary:
|
||||
"border-transparent bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
|
||||
destructive:
|
||||
"border-transparent bg-red-500 text-gray-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80",
|
||||
outline: "text-gray-900 dark:text-gray-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-gray-900 dark:focus-visible:ring-gray-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-gray-900 text-gray-50 hover:bg-gray-900/90 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-gray-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-gray-200 bg-white hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
|
||||
secondary:
|
||||
"bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
|
||||
ghost:
|
||||
"hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
|
||||
link: "text-gray-900 underline-offset-4 hover:underline dark:text-gray-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
export const Code: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<code className="tracking-wide font-mono bg-gray-200 dark:bg-gray-600 rounded-md px-1">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-800 dark:data-[state=open]:bg-gray-800",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-900 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 flex-col sm:flex-row list-none sm:items-center justify-center space-y-2 sm:space-x-1 sm:space-y-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-gray-100/50 data-[state=open]:bg-gray-100/50 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[active]:bg-gray-800/50 dark:data-[state=open]:bg-gray-800/50",
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{""}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-gray-200 bg-white text-gray-900 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-gray-200 shadow-md dark:bg-gray-800" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-gray-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:bg-gray-900 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus:ring-gray-300",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-gray-900 font-medium text-gray-50 dark:bg-gray-50 dark:text-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-gray-100/50 data-[state=selected]:bg-gray-100 dark:hover:bg-gray-800/50 dark:data-[state=selected]:bg-gray-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0 dark:text-gray-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-gray-500 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
|
@ -2,14 +2,12 @@ import { useCallback, useMemo } from "react";
|
|||
import {
|
||||
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);
|
||||
|
|
@ -146,7 +143,9 @@ 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;
|
||||
},
|
||||
|
|
@ -163,38 +162,3 @@ export const useApiTokens = () => {
|
|||
|
||||
return { apiTokens, isLoading, error, createToken, revokeToken };
|
||||
};
|
||||
|
||||
export const useSpotifyImportExtendedStreamingHistory = () => {
|
||||
const { client } = useApiClient();
|
||||
|
||||
const importHistory = useCallback(
|
||||
async (listens: SpotifyExtendedStreamingHistoryItem[]) => {
|
||||
return importExtendedStreamingHistory(listens, client);
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const getStatus = useCallback(async () => {
|
||||
return getExtendedStreamingHistoryStatus(client);
|
||||
}, [client]);
|
||||
|
||||
return { importHistory, getStatus };
|
||||
};
|
||||
|
||||
export const useSpotifyImportExtendedStreamingHistoryStatus = () => {
|
||||
const { client } = useApiClient();
|
||||
|
||||
const fetchData = useMemo(
|
||||
() => () => getExtendedStreamingHistoryStatus(client),
|
||||
[client]
|
||||
);
|
||||
|
||||
const {
|
||||
value: importStatus,
|
||||
pending: isLoading,
|
||||
error,
|
||||
reload,
|
||||
} = useAsync(fetchData, { total: 0, imported: 0 });
|
||||
|
||||
return { importStatus, isLoading, error, reload };
|
||||
};
|
||||
|
|
|
|||
16
frontend/src/hooks/use-auth-protection.tsx
Normal file
16
frontend/src/hooks/use-auth-protection.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "./use-auth";
|
||||
|
||||
export function useAuthProtection() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const requireUser = useCallback(async () => {
|
||||
if (!user) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
return { requireUser };
|
||||
}
|
||||
26
frontend/src/hooks/use-outside-click.tsx
Normal file
26
frontend/src/hooks/use-outside-click.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook that alerts clicks outside of the passed ref
|
||||
*/
|
||||
export const useOutsideClick = (
|
||||
ref: React.MutableRefObject<any>,
|
||||
callback: () => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Alert if clicked on outside of element
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
// Bind the event listener
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
// Unbind the event listener on clean up
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref, callback]);
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export const ErrorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
fill="#D75A4A"
|
||||
>
|
||||
<circle fill="fill" cx="25" cy="25" r="25" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import * as React from "react";
|
||||
export const ImportIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 60.903 60.903"
|
||||
{...props}
|
||||
>
|
||||
<path d="M49.561 16.464H39.45v6h10.111c3.008 0 5.341 1.535 5.341 2.857v26.607c0 1.321-2.333 2.858-5.341 2.858H11.34c-3.007 0-5.34-1.537-5.34-2.858V25.324c0-1.322 2.333-2.858 5.34-2.858h10.11v-6H11.34C4.981 16.466 0 20.357 0 25.324v26.605c0 4.968 4.981 8.857 11.34 8.857h38.223c6.357 0 11.34-3.891 11.34-8.857V25.324c-.001-4.969-4.982-8.86-11.342-8.86z" />
|
||||
<path d="M39.529 29.004a2.99 2.99 0 0 0-2.121.88l-3.756 3.755V3.117a3 3 0 0 0-6 0v30.724l-3.959-3.958a2.992 2.992 0 0 0-4.242 0 2.997 2.997 0 0 0 0 4.241l8.957 8.957a2.988 2.988 0 0 0 2.12.877h.045c.768 0 1.534-.291 2.12-.877l8.957-8.957a2.997 2.997 0 0 0-2.121-5.12z" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -5,7 +5,6 @@ import { App } from "./App";
|
|||
import { ProvideApiClient } from "./hooks/use-api-client";
|
||||
import { ProvideAuth } from "./hooks/use-auth";
|
||||
import "./index.css";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
|
|
@ -14,9 +13,7 @@ root.render(
|
|||
<ProvideAuth>
|
||||
<ProvideApiClient>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ProvideApiClient>
|
||||
</ProvideAuth>
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
36
frontend/src/react-files.d.ts
vendored
36
frontend/src/react-files.d.ts
vendored
|
|
@ -1,36 +0,0 @@
|
|||
//// <reference types="react" />
|
||||
|
||||
declare module "react-files" {
|
||||
declare const Files: React.FC<
|
||||
Partial<{
|
||||
accepts: string[];
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
clickable: boolean;
|
||||
dragActiveClassName: string;
|
||||
inputProps: unknown;
|
||||
multiple: boolean;
|
||||
maxFiles: number;
|
||||
maxFileSize: number;
|
||||
minFileSize: number;
|
||||
name: string;
|
||||
onChange: (files: ReactFile[]) => void;
|
||||
onDragEnter: () => void;
|
||||
onDragLeave: () => void;
|
||||
onError: (
|
||||
error: { code: number; message: string },
|
||||
file: ReactFile
|
||||
) => void;
|
||||
style: object;
|
||||
}>
|
||||
>;
|
||||
|
||||
export type ReactFile = File & {
|
||||
id: string;
|
||||
extension: string;
|
||||
sizeReadable: string;
|
||||
preview: { type: "image"; url: string } | { type: "file" };
|
||||
};
|
||||
|
||||
export default Files;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export const numberToPercent = (ratio: number) =>
|
||||
ratio.toLocaleString(undefined, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
const colors = require("tailwindcss/colors");
|
||||
|
||||
/** @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")],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
16614
package-lock.json
generated
16614
package-lock.json
generated
File diff suppressed because it is too large
Load diff
136
package.json
136
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@listory/api",
|
||||
"version": "1.31.0",
|
||||
"version": "1.28.0",
|
||||
"description": "Track your Spotify Listen History",
|
||||
"author": {
|
||||
"name": "Julian Tölle",
|
||||
|
|
@ -28,91 +28,91 @@
|
|||
"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",
|
||||
"@nestjs/axios": "3.0.0",
|
||||
"@nestjs/common": "10.2.5",
|
||||
"@nestjs/config": "3.1.1",
|
||||
"@nestjs/core": "10.2.5",
|
||||
"@nestjs/jwt": "10.1.1",
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.5",
|
||||
"@nestjs/serve-static": "4.0.0",
|
||||
"@nestjs/swagger": "7.1.11",
|
||||
"@nestjs/terminus": "10.1.1",
|
||||
"@nestjs/typeorm": "10.0.0",
|
||||
"@opentelemetry/api": "1.6.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.17.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.43.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.43.0",
|
||||
"@opentelemetry/instrumentation": "0.43.0",
|
||||
"@opentelemetry/instrumentation-dns": "0.32.2",
|
||||
"@opentelemetry/instrumentation-express": "0.33.1",
|
||||
"@opentelemetry/instrumentation-http": "0.43.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "0.33.1",
|
||||
"@opentelemetry/instrumentation-pg": "0.36.1",
|
||||
"@opentelemetry/instrumentation-pino": "0.34.1",
|
||||
"@opentelemetry/resources": "1.17.0",
|
||||
"@opentelemetry/sdk-metrics": "1.17.0",
|
||||
"@opentelemetry/sdk-node": "0.43.0",
|
||||
"@opentelemetry/sdk-trace-base": "1.17.0",
|
||||
"@opentelemetry/semantic-conventions": "1.17.0",
|
||||
"@sentry/node": "7.69.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",
|
||||
"joi": "17.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"nest-raven": "10.1.0",
|
||||
"nest-raven": "10.0.0",
|
||||
"nestjs-otel": "5.1.5",
|
||||
"nestjs-pino": "4.1.0",
|
||||
"nestjs-pino": "3.4.0",
|
||||
"nestjs-typeorm-paginate": "4.0.4",
|
||||
"passport": "0.7.0",
|
||||
"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": "8.11.3",
|
||||
"pg-boss": "9.0.3",
|
||||
"pino": "8.21.0",
|
||||
"pino-http": "9.0.0",
|
||||
"reflect-metadata": "0.1.14",
|
||||
"rimraf": "5.0.10",
|
||||
"pino": "8.15.1",
|
||||
"pino-http": "8.5.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "5.0.1",
|
||||
"rxjs": "7.8.1",
|
||||
"typeorm": "0.3.20"
|
||||
"typeorm": "0.3.17"
|
||||
},
|
||||
"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": "10.1.17",
|
||||
"@nestjs/schematics": "10.0.2",
|
||||
"@nestjs/testing": "10.2.5",
|
||||
"@types/cookie-parser": "1.4.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/lodash": "4.14.198",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/passport-http-bearer": "1.0.37",
|
||||
"@types/passport-jwt": "3.0.9",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.0",
|
||||
"@typescript-eslint/parser": "6.7.0",
|
||||
"eslint": "8.49.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-prettier": "9.0.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"eslint-plugin-jsdoc": "46.8.1",
|
||||
"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",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"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",
|
||||
"pino-pretty": "10.2.0",
|
||||
"prettier": "3.0.3",
|
||||
"supertest": "6.3.3",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-loader": "9.4.4",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.7.2"
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
":automergeBranch",
|
||||
":automergeLinters",
|
||||
":automergeTesters",
|
||||
":automergeTypes",
|
||||
":maintainLockFilesWeekly"
|
||||
":automergeTypes"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const DatabaseModule = TypeOrmModule.forRootAsync({
|
|||
|
||||
// Debug/Development Options
|
||||
//
|
||||
//logging: true,
|
||||
// logging: true,
|
||||
//
|
||||
// synchronize: true,
|
||||
// migrationsRun: false,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
|||
]);
|
||||
|
||||
// handled by Primary Key on (albumId, artistId)
|
||||
await queryRunner.dropIndex("album_artists", "IDX_ALBUM_ARTISTS_ALBUM_ID");
|
||||
await queryRunner.dropIndex("album_artist", "IDX_ALBUM_ARTISTS_ALBUM_ID");
|
||||
|
||||
// handled by Primary Key on (artistId, genreId)
|
||||
await queryRunner.dropIndex("artist_genres", "IDX_ARTIST_GENRES_ARTIST_ID");
|
||||
|
|
@ -34,7 +34,7 @@ export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
|||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createIndices("album_artists", [
|
||||
await queryRunner.createIndices("album_artist", [
|
||||
new TableIndex({
|
||||
name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
|
||||
columnNames: ["albumId"],
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
} from "typeorm";
|
||||
import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
|
||||
|
||||
const primaryUUIDColumn: TableColumnOptions = {
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "uuid",
|
||||
};
|
||||
|
||||
export class CreateSpotifyImportTables0000000000009
|
||||
implements MigrationInterface
|
||||
{
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "spotify_extended_streaming_history_listen",
|
||||
columns: [
|
||||
primaryUUIDColumn,
|
||||
{ name: "userId", type: "uuid" },
|
||||
{ name: "playedAt", type: "timestamp" },
|
||||
{ name: "spotifyTrackUri", type: "varchar" },
|
||||
{ name: "trackId", type: "uuid", isNullable: true },
|
||||
{ name: "listenId", type: "uuid", isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
new TableIndex({
|
||||
name: "IDX_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_PLAYED_AT",
|
||||
columnNames: ["userId", "playedAt", "spotifyTrackUri"],
|
||||
isUnique: true,
|
||||
}),
|
||||
],
|
||||
foreignKeys: [
|
||||
new TableForeignKey({
|
||||
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_ID",
|
||||
columnNames: ["userId"],
|
||||
referencedColumnNames: ["id"],
|
||||
referencedTableName: "user",
|
||||
}),
|
||||
new TableForeignKey({
|
||||
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_TRACK_ID",
|
||||
columnNames: ["trackId"],
|
||||
referencedColumnNames: ["id"],
|
||||
referencedTableName: "track",
|
||||
}),
|
||||
new TableForeignKey({
|
||||
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_LISTEN_ID",
|
||||
columnNames: ["listenId"],
|
||||
referencedColumnNames: ["id"],
|
||||
referencedTableName: "listen",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("spotify_extended_streaming_history_listen");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ import { Repository, SelectQueryBuilder } from "typeorm";
|
|||
import { EntityRepository } from "../database/entity-repository";
|
||||
import { 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 })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,7 +17,20 @@ 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[],
|
||||
): Promise<Listen[]> {
|
||||
|
|
@ -31,11 +46,9 @@ export class ListensService {
|
|||
),
|
||||
);
|
||||
|
||||
const newListens = await this.listenRepository.save(
|
||||
return this.listenRepository.save(
|
||||
missingListens.map((entry) => this.listenRepository.create(entry)),
|
||||
);
|
||||
|
||||
return [...existingListens, ...newListens];
|
||||
}
|
||||
|
||||
async getListens(
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ async function bootstrap() {
|
|||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: true,
|
||||
rawBody: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalPipes(
|
||||
|
|
@ -52,10 +51,6 @@ async function bootstrap() {
|
|||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
app.useBodyParser("json", {
|
||||
limit:
|
||||
"10mb" /* Need large bodies for Spotify Extended Streaming History */,
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const configService = app.get<ConfigService>(ConfigService);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
export class FindTrackDto {
|
||||
spotify: {
|
||||
id?: string;
|
||||
uri?: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,9 @@ export class MusicLibraryService {
|
|||
}
|
||||
|
||||
async findTrack(query: FindTrackDto): Promise<Track | undefined> {
|
||||
return this.trackRepository.findOneBy(query);
|
||||
return this.trackRepository.findOneBy({
|
||||
spotify: { id: query.spotify.id },
|
||||
});
|
||||
}
|
||||
|
||||
async findTracks(query: FindTrackDto[]): Promise<Track[]> {
|
||||
|
|
|
|||
5
src/override.d.ts
vendored
Normal file
5
src/override.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Issue with opentelemetry-js: https://github.com/open-telemetry/opentelemetry-js/issues/3580#issuecomment-1701157270
|
||||
export {};
|
||||
declare global {
|
||||
type BlobPropertyBag = unknown;
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export class SchedulerService implements OnApplicationBootstrap {
|
|||
}
|
||||
|
||||
private async setupSpotifyCrawlerSupervisor(): Promise<void> {
|
||||
// await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
|
||||
await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
|
||||
}
|
||||
|
||||
@Span()
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class ExtendedStreamingHistoryStatusDto {
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
})
|
||||
total: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
})
|
||||
imported: number;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ArrayMaxSize } from "class-validator";
|
||||
import { SpotifyExtendedStreamingHistoryItemDto } from "./spotify-extended-streaming-history-item.dto";
|
||||
|
||||
export class ImportExtendedStreamingHistoryDto {
|
||||
@ApiProperty({
|
||||
type: SpotifyExtendedStreamingHistoryItemDto,
|
||||
isArray: true,
|
||||
maxItems: 50_000,
|
||||
})
|
||||
@ArrayMaxSize(50_000) // File size is ~16k by default, might need refactoring if Spotify starts exporting larger files
|
||||
listens: SpotifyExtendedStreamingHistoryItemDto[];
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SpotifyExtendedStreamingHistoryItemDto {
|
||||
@ApiProperty({ format: "iso8601", example: "2018-11-30T08:33:33Z" })
|
||||
ts: string;
|
||||
|
||||
@ApiProperty({ example: "spotify:track:6askbS4pEVWbbDnUGEXh3G" })
|
||||
spotify_track_uri: string;
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { Body as NestBody, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
||||
import { AuthAccessToken } from "../../../auth/decorators/auth-access-token.decorator";
|
||||
import { ReqUser } from "../../../auth/decorators/req-user.decorator";
|
||||
import { User } from "../../../users/user.entity";
|
||||
import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto";
|
||||
import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto";
|
||||
import { ImportService } from "./import.service";
|
||||
|
||||
@ApiTags("import")
|
||||
@Controller("api/v1/import")
|
||||
export class ImportController {
|
||||
constructor(private readonly importService: ImportService) {}
|
||||
|
||||
@Post("extended-streaming-history")
|
||||
@ApiBody({ type: () => ImportExtendedStreamingHistoryDto })
|
||||
@AuthAccessToken()
|
||||
async importExtendedStreamingHistory(
|
||||
@ReqUser() user: User,
|
||||
@NestBody() data: ImportExtendedStreamingHistoryDto,
|
||||
): Promise<void> {
|
||||
return this.importService.importExtendedStreamingHistory(user, data);
|
||||
}
|
||||
|
||||
@Get("extended-streaming-history/status")
|
||||
@AuthAccessToken()
|
||||
async getExtendedStreamingHistoryStatus(
|
||||
@ReqUser() user: User,
|
||||
): Promise<ExtendedStreamingHistoryStatusDto> {
|
||||
return this.importService.getExtendedStreamingHistoryStatus(user);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import { JobService } from "@apricote/nest-pg-boss";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { uniq } from "lodash";
|
||||
import { Span } from "nestjs-otel";
|
||||
import type { Job } from "pg-boss";
|
||||
import { ListensService } from "../../../listens/listens.service";
|
||||
import { User } from "../../../users/user.entity";
|
||||
import { SpotifyService } from "../spotify.service";
|
||||
import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto";
|
||||
import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto";
|
||||
import {
|
||||
IProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
} from "./jobs";
|
||||
import { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
constructor(
|
||||
private readonly importListenRepository: SpotifyExtendedStreamingHistoryListenRepository,
|
||||
@ProcessSpotifyExtendedStreamingHistoryListenJob.Inject()
|
||||
private readonly processListenJobService: JobService<IProcessSpotifyExtendedStreamingHistoryListenJob>,
|
||||
private readonly spotifyService: SpotifyService,
|
||||
private readonly listensService: ListensService,
|
||||
) {}
|
||||
|
||||
@Span()
|
||||
async importExtendedStreamingHistory(
|
||||
user: User,
|
||||
{ listens: importListens }: ImportExtendedStreamingHistoryDto,
|
||||
): Promise<void> {
|
||||
// IDK what's happening, but my personal data set has entries with duplicate
|
||||
// listens? might be related to offline mode.
|
||||
// Anyway, this cleans it up:
|
||||
const uniqEntries = new Set();
|
||||
const uniqueListens = importListens.filter((listen) => {
|
||||
const key = `${listen.spotify_track_uri}-${listen.ts}`;
|
||||
|
||||
if (!uniqEntries.has(key)) {
|
||||
// New entry
|
||||
uniqEntries.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
let listens = uniqueListens.map((listenData) =>
|
||||
this.importListenRepository.create({
|
||||
user,
|
||||
playedAt: new Date(listenData.ts),
|
||||
spotifyTrackUri: listenData.spotify_track_uri,
|
||||
}),
|
||||
);
|
||||
|
||||
// Save listens to import table
|
||||
const insertResult = await this.importListenRepository.upsert(listens, [
|
||||
"user",
|
||||
"playedAt",
|
||||
"spotifyTrackUri",
|
||||
]);
|
||||
|
||||
const processJobs = insertResult.identifiers.map((listen) => ({
|
||||
data: {
|
||||
id: listen.id,
|
||||
},
|
||||
singletonKey: listen.id,
|
||||
retryLimit: 10,
|
||||
retryDelay: 5,
|
||||
retryBackoff: true,
|
||||
}));
|
||||
|
||||
// Schedule jobs to process imports
|
||||
await this.processListenJobService.insert(processJobs);
|
||||
}
|
||||
|
||||
@ProcessSpotifyExtendedStreamingHistoryListenJob.Handle({
|
||||
// Spotify API "Get Several XY" allows max 50 IDs
|
||||
batchSize: 50,
|
||||
newJobCheckInterval: 500,
|
||||
})
|
||||
@Span()
|
||||
async processListens(
|
||||
jobs: Job<IProcessSpotifyExtendedStreamingHistoryListenJob>[],
|
||||
): Promise<void> {
|
||||
this.logger.debug(
|
||||
{ jobs: jobs.length },
|
||||
"processing extended streaming history listens",
|
||||
);
|
||||
const importListens = await this.importListenRepository.findBy(
|
||||
jobs.map((job) => ({ id: job.data.id })),
|
||||
);
|
||||
|
||||
const listensWithoutTracks = importListens.filter(
|
||||
(importListen) => !importListen.track,
|
||||
);
|
||||
if (listensWithoutTracks.length > 0) {
|
||||
const missingTrackIDs = uniq(
|
||||
listensWithoutTracks.map((importListen) =>
|
||||
importListen.spotifyTrackUri.replace("spotify:track:", ""),
|
||||
),
|
||||
);
|
||||
|
||||
const tracks = await this.spotifyService.importTracks(missingTrackIDs);
|
||||
|
||||
listensWithoutTracks.forEach((listen) => {
|
||||
listen.track = tracks.find(
|
||||
(track) => listen.spotifyTrackUri === track.spotify.uri,
|
||||
);
|
||||
if (!listen.track) {
|
||||
this.logger.warn(
|
||||
{ listen },
|
||||
"could not find track for extended streaming history listen",
|
||||
);
|
||||
throw new Error(
|
||||
`could not find track for extended streaming history listen`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Using upsert instead of save to only do a single query
|
||||
await this.importListenRepository.upsert(listensWithoutTracks, ["id"]);
|
||||
}
|
||||
|
||||
const listensWithoutListen = importListens.filter(
|
||||
(importListen) => !importListen.listen,
|
||||
);
|
||||
if (listensWithoutListen.length > 0) {
|
||||
const listens = await this.listensService.createListens(
|
||||
listensWithoutListen.map((listen) => ({
|
||||
user: listen.user,
|
||||
track: listen.track,
|
||||
playedAt: listen.playedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
listensWithoutListen.forEach((importListen) => {
|
||||
importListen.listen = listens.find(
|
||||
(listen) =>
|
||||
importListen.user.id === listen.user.id &&
|
||||
importListen.track.id === listen.track.id &&
|
||||
importListen.playedAt.getTime() === listen.playedAt.getTime(),
|
||||
);
|
||||
if (!importListen.listen) {
|
||||
this.logger.warn(
|
||||
{ listen: importListen, listens: listens },
|
||||
"could not find listen for extended streaming history listen",
|
||||
);
|
||||
throw new Error(
|
||||
`could not find listen for extended streaming history listen`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Using upsert instead of save to only do a single query
|
||||
await this.importListenRepository.upsert(listensWithoutListen, ["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
@Span()
|
||||
async getExtendedStreamingHistoryStatus(
|
||||
user: User,
|
||||
): Promise<ExtendedStreamingHistoryStatusDto> {
|
||||
const qb = this.importListenRepository
|
||||
.createQueryBuilder("listen")
|
||||
.where("listen.userId = :user", { user: user.id });
|
||||
|
||||
const [total, imported] = await Promise.all([
|
||||
qb.clone().getCount(),
|
||||
qb.clone().andWhere("listen.listenId IS NOT NULL").getCount(),
|
||||
]);
|
||||
|
||||
return { total, imported };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export { ImportController } from "./import.controller";
|
||||
export { ImportService } from "./import.service";
|
||||
export { ProcessSpotifyExtendedStreamingHistoryListenJob } from "./jobs";
|
||||
export { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { createJob } from "@apricote/nest-pg-boss";
|
||||
|
||||
export type IProcessSpotifyExtendedStreamingHistoryListenJob = { id: string };
|
||||
export const ProcessSpotifyExtendedStreamingHistoryListenJob =
|
||||
createJob<IProcessSpotifyExtendedStreamingHistoryListenJob>(
|
||||
"process-spotify-extended-streaming-history-listen",
|
||||
);
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { Track } from "../../../music-library/track.entity";
|
||||
import { User } from "../../../users/user.entity";
|
||||
import { Listen } from "../../../listens/listen.entity";
|
||||
|
||||
@Entity({ name: "spotify_extended_streaming_history_listen" })
|
||||
export class SpotifyExtendedStreamingHistoryListen {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => User, { eager: true })
|
||||
user: User;
|
||||
|
||||
@Column({ type: "timestamp" })
|
||||
playedAt: Date;
|
||||
|
||||
@Column()
|
||||
spotifyTrackUri: string;
|
||||
|
||||
@ManyToOne(() => Track, { nullable: true, eager: true })
|
||||
track?: Track;
|
||||
|
||||
@ManyToOne(() => Listen, { nullable: true, eager: true })
|
||||
listen?: Listen;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { Repository } from "typeorm";
|
||||
import { EntityRepository } from "../../../database/entity-repository";
|
||||
import { SpotifyExtendedStreamingHistoryListen } from "./listen.entity";
|
||||
|
||||
@EntityRepository(SpotifyExtendedStreamingHistoryListen)
|
||||
export class SpotifyExtendedStreamingHistoryListenRepository extends Repository<SpotifyExtendedStreamingHistoryListen> {}
|
||||
|
|
@ -1,37 +1,25 @@
|
|||
import { PGBossModule } from "@apricote/nest-pg-boss";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmRepositoryModule } from "../../database/entity-repository/typeorm-repository.module";
|
||||
import { ListensModule } from "../../listens/listens.module";
|
||||
import { MusicLibraryModule } from "../../music-library/music-library.module";
|
||||
import { UsersModule } from "../../users/users.module";
|
||||
import { ImportSpotifyJob } from "../jobs";
|
||||
import {
|
||||
ImportController,
|
||||
ImportService,
|
||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
SpotifyExtendedStreamingHistoryListenRepository,
|
||||
} from "./import-extended-streaming-history";
|
||||
import { SpotifyApiModule } from "./spotify-api/spotify-api.module";
|
||||
import { SpotifyAuthModule } from "./spotify-auth/spotify-auth.module";
|
||||
import { SpotifyService } from "./spotify.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PGBossModule.forJobs([
|
||||
ImportSpotifyJob,
|
||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
]),
|
||||
TypeOrmRepositoryModule.for([
|
||||
SpotifyExtendedStreamingHistoryListenRepository,
|
||||
]),
|
||||
PGBossModule.forJobs([ImportSpotifyJob]),
|
||||
UsersModule,
|
||||
ListensModule,
|
||||
MusicLibraryModule,
|
||||
SpotifyApiModule,
|
||||
SpotifyAuthModule,
|
||||
],
|
||||
providers: [SpotifyService, ImportService],
|
||||
controllers: [ImportController],
|
||||
providers: [SpotifyService],
|
||||
exports: [SpotifyService],
|
||||
})
|
||||
export class SpotifyModule {}
|
||||
export class SpotifyModule {
|
||||
constructor(private readonly spotifyService: SpotifyService) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,6 @@ export class SpotifyService {
|
|||
private readonly spotifyAuth: SpotifyAuthService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a list of users that should be crawled for new listens.
|
||||
* Only returns the lastListen date if it was within the last hour.
|
||||
*/
|
||||
@Span()
|
||||
async getCrawlableUserInfo(): Promise<
|
||||
{ userID: string; lastListen: Date }[]
|
||||
|
|
@ -61,7 +57,6 @@ export class SpotifyService {
|
|||
.select(`listen."userId"`)
|
||||
.addSelect(`listen."playedAt"`)
|
||||
.from("listen", "listen")
|
||||
.where(`listen."playedAt" > now() - interval '1 hour'`)
|
||||
.orderBy("listen.userId", "DESC")
|
||||
.addOrderBy("listen.playedAt", "DESC"),
|
||||
"listen",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@
|
|||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "ES2022",
|
||||
"target": "ES2020",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"paths": {},
|
||||
"lib": ["ES2020"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src", "test"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue