Compare commits

..

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

85 changed files with 22185 additions and 11142 deletions

View file

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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

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

View file

@ -14,8 +14,8 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
version: 1.31.0
version: 1.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

View file

@ -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

View file

@ -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:

View file

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

13108
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,44 +8,32 @@
},
"license": "MIT",
"dependencies": {
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-dropdown-menu": "2.1.3",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-navigation-menu": "1.2.2",
"@radix-ui/react-select": "2.1.3",
"@radix-ui/react-slot": "1.1.1",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
"@types/node": "20.17.16",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@testing-library/jest-dom": "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)\"",

View file

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

View file

@ -14,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;
};

View file

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

View file

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

View file

@ -2,61 +2,65 @@ import { format, formatDistanceToNow } from "date-fns";
import React, { FormEvent, useCallback, useMemo, useState } from "react";
import { ApiToken, NewApiToken } from "../api/entities/api-token";
import { useApiTokens } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { SpinnerIcon } from "../icons/Spinner";
import TrashcanIcon from "../icons/Trashcan";
import { Spinner } from "./ui/Spinner";
import { Spinner } from "./Spinner";
export const AuthApiTokens: React.FC = () => {
const { requireUser } = useAuthProtection();
const { apiTokens, isLoading, createToken, revokeToken } = useApiTokens();
const sortedTokens = useMemo(
() =>
apiTokens
.filter((token) => !token.revokedAt)
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
() => apiTokens.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>
);
};

View file

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

View file

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

View file

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

View file

@ -1,124 +1,55 @@
import React from "react";
import React, { useCallback, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { User } from "../api/entities/user";
import { useAuth } from "../hooks/use-auth";
import { useOutsideClick } from "../hooks/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>
);
};

View file

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

View file

@ -10,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>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 };
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

16614
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "@listory/api",
"version": "1.31.0",
"version": "1.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": [

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import {
Body as NestBody,
Body,
Controller,
Delete,
Get,
@ -10,7 +10,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiBody, ApiTags } from "@nestjs/swagger";
import type { Response as ExpressResponse } from "express";
import type { Response } from "express";
import { User } from "../users/user.entity";
import { AuthSession } from "./auth-session.entity";
import { AuthService } from "./auth.service";
@ -42,7 +42,7 @@ export class AuthController {
@Get("spotify/callback")
@UseFilters(SpotifyAuthFilter)
@UseGuards(SpotifyAuthGuard)
async spotifyCallback(@ReqUser() user: User, @Res() res: ExpressResponse) {
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
const { refreshToken } = await this.authService.createSession(user);
// Refresh token should not be accessible to frontend to reduce risk
@ -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);

View file

@ -5,14 +5,14 @@ import {
ForbiddenException,
Logger,
} from "@nestjs/common";
import type { Response as ExpressResponse } from "express";
import type { Response } from "express";
@Catch()
export class SpotifyAuthFilter implements ExceptionFilter {
private readonly logger = new Logger(this.constructor.name);
catch(exception: Error, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<ExpressResponse>();
const response = host.switchToHttp().getResponse<Response>();
let reason = "unknown";

View file

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

View file

@ -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"],

View file

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

View file

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

View file

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

View file

@ -1,12 +1,14 @@
import { Injectable } from "@nestjs/common";
import { Span } from "nestjs-otel";
import {
IPaginationOptions,
paginate,
Pagination,
PaginationTypeEnum,
} from "nestjs-typeorm-paginate";
import { CreateListenRequestDto } from "./dto/create-listen.dto";
import {
CreateListenRequestDto,
CreateListenResponseDto,
} from "./dto/create-listen.dto";
import { GetListensDto } from "./dto/get-listens.dto";
import { Listen } from "./listen.entity";
import { ListenRepository, ListenScopes } from "./listen.repository";
@ -15,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(

View file

@ -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);

View file

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

View file

@ -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
View 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;
}

View file

@ -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()

View file

@ -1,13 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
export class ExtendedStreamingHistoryStatusDto {
@ApiProperty({
type: Number,
})
total: number;
@ApiProperty({
type: Number,
})
imported: number;
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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 };
}
}

View file

@ -1,4 +0,0 @@
export { ImportController } from "./import.controller";
export { ImportService } from "./import.service";
export { ProcessSpotifyExtendedStreamingHistoryListenJob } from "./jobs";
export { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";

View file

@ -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",
);

View file

@ -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;
}

View file

@ -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> {}

View file

@ -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) {}
}

View file

@ -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",

View file

@ -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"]