mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat: import listens from spotify extended streaming history (#305)
This commit is contained in:
parent
23d7ea0995
commit
7140cb0679
50 changed files with 1051 additions and 215 deletions
|
|
@ -2,6 +2,7 @@ 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 { NavBar } from "./components/NavBar";
|
||||
|
|
@ -53,6 +54,7 @@ export function App() {
|
|||
element={<ReportTopGenres />}
|
||||
/>
|
||||
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
|
||||
<Route path="/import" element={<ImportListens />} />
|
||||
</Routes>
|
||||
)}
|
||||
{!user && (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ 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 {}
|
||||
|
||||
|
|
@ -276,7 +278,7 @@ export const revokeApiToken = async (
|
|||
id: string,
|
||||
client: AxiosInstance,
|
||||
): Promise<void> => {
|
||||
const res = await client.delete<NewApiToken>(`/api/v1/auth/api-tokens/${id}`);
|
||||
const res = await client.delete(`/api/v1/auth/api-tokens/${id}`);
|
||||
|
||||
switch (res.status) {
|
||||
case 200: {
|
||||
|
|
@ -290,3 +292,50 @@ 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export interface ExtendedStreamingHistoryStatus {
|
||||
total: number;
|
||||
imported: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export interface SpotifyExtendedStreamingHistoryItem {
|
||||
ts: string;
|
||||
spotify_track_uri: string;
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ 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";
|
||||
|
|
|
|||
280
frontend/src/components/ImportListens.tsx
Normal file
280
frontend/src/components/ImportListens.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||
import { User } from "../api/entities/user";
|
||||
import { useAuth } from "../hooks/use-auth";
|
||||
import { CogwheelIcon } from "../icons/Cogwheel";
|
||||
import { ImportIcon } from "../icons/Import";
|
||||
import { SpotifyLogo } from "../icons/Spotify";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar";
|
||||
import {
|
||||
|
|
@ -188,6 +189,12 @@ const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const RecentListens: React.FC = () => {
|
|||
)}
|
||||
<div>
|
||||
{recentListens.length > 0 && (
|
||||
<Table className="table-auto w-full text-base">
|
||||
<Table className="table-auto w-full">
|
||||
<TableBody>
|
||||
{recentListens.map((listen) => (
|
||||
<ListenItem listen={listen} key={listen.id} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { numberToPercent } from "../../util/numberToPercent";
|
||||
|
||||
export interface TopListItemProps {
|
||||
title: string;
|
||||
|
|
@ -42,9 +43,3 @@ 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,
|
||||
});
|
||||
|
|
|
|||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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 };
|
||||
86
frontend/src/components/ui/card.tsx
Normal file
86
frontend/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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,
|
||||
};
|
||||
9
frontend/src/components/ui/code.tsx
Normal file
9
frontend/src/components/ui/code.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
export const Code: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<code className="tracking-wide font-mono bg-gray-200 dark:bg-gray-600 rounded-md px-1">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
|
|
@ -9,20 +9,20 @@ const Table = React.forwardRef<
|
|||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn("w-full caption-bottom", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
));
|
||||
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"
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
|
|
@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
|
|||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
|
|
@ -42,11 +42,14 @@ const TableFooter = React.forwardRef<
|
|||
>(({ 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)}
|
||||
className={cn(
|
||||
"bg-gray-900 font-medium text-gray-50 dark:bg-gray-50 dark:text-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
|
|
@ -56,12 +59,12 @@ const TableRow = React.forwardRef<
|
|||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
|
|
@ -71,12 +74,12 @@ const TableHead = React.forwardRef<
|
|||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
|
|
@ -87,8 +90,8 @@ const TableCell = React.forwardRef<
|
|||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
|
|
@ -99,8 +102,8 @@ const TableCaption = React.forwardRef<
|
|||
className={cn("mt-4 text-sm text-gray-500 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
|
|
@ -111,4 +114,4 @@ export {
|
|||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ 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";
|
||||
|
|
@ -18,6 +20,7 @@ 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);
|
||||
|
|
@ -143,9 +146,7 @@ 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;
|
||||
},
|
||||
|
|
@ -162,3 +163,38 @@ 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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
14
frontend/src/icons/Error.tsx
Normal file
14
frontend/src/icons/Error.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
12
frontend/src/icons/Import.tsx
Normal file
12
frontend/src/icons/Import.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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>
|
||||
);
|
||||
36
frontend/src/react-files.d.ts
vendored
Normal file
36
frontend/src/react-files.d.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//// <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;
|
||||
}
|
||||
5
frontend/src/util/numberToPercent.ts
Normal file
5
frontend/src/util/numberToPercent.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const numberToPercent = (ratio: number) =>
|
||||
ratio.toLocaleString(undefined, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue