mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat(frontend): general revamp of navigation & pages (#303)
This commit is contained in:
parent
f08633587d
commit
4b1dd10846
35 changed files with 3059 additions and 659 deletions
150
frontend/src/components/reports/RecentListens.tsx
Normal file
150
frontend/src/components/reports/RecentListens.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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";
|
||||
|
||||
const LISTENS_PER_PAGE = 15;
|
||||
|
||||
export const RecentListens: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const options = useMemo(() => ({ page, limit: LISTENS_PER_PAGE }), [page]);
|
||||
|
||||
const { recentListens, paginationMeta, isLoading, reload } =
|
||||
useRecentListens(options);
|
||||
|
||||
useEffect(() => {
|
||||
if (paginationMeta && totalPages !== paginationMeta.totalPages) {
|
||||
setTotalPages(paginationMeta.totalPages);
|
||||
}
|
||||
}, [totalPages, paginationMeta]);
|
||||
|
||||
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 text-base">
|
||||
<TableBody>
|
||||
{recentListens.map((listen) => (
|
||||
<ListenItem listen={listen} key={listen.id} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Pagination: React.FC<{
|
||||
page: number;
|
||||
totalPages: number;
|
||||
setPage: (newPage: number) => void;
|
||||
}> = ({ page, totalPages, setPage }) => {
|
||||
const disabledBtn = "opacity-50 cursor-default";
|
||||
const hoverBtn = "hover:bg-gray-400 dark:hover:bg-gray-600";
|
||||
const defaultBtn =
|
||||
"bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-bold py-2 px-4";
|
||||
const pageButtons = getPaginationItems(page, totalPages, 1);
|
||||
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = page === totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-4">
|
||||
<button
|
||||
className={`${
|
||||
isFirstPage ? disabledBtn : hoverBtn
|
||||
} ${defaultBtn} rounded-l`}
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={isFirstPage}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageButtons.map((buttonPage, i) =>
|
||||
buttonPage ? (
|
||||
<button
|
||||
className={`${hoverBtn} ${defaultBtn} ${
|
||||
buttonPage === page &&
|
||||
"bg-green-400 dark:bg-green-500 hover:bg-green-400 dark:hover:bg-green-500 cursor-default"
|
||||
}`}
|
||||
onClick={() => setPage(buttonPage)}
|
||||
key={i}
|
||||
>
|
||||
{buttonPage}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={i}
|
||||
className={`cursor-default ${disabledBtn} ${defaultBtn}`}
|
||||
>
|
||||
...
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
className={`${
|
||||
isLastPage ? disabledBtn : hoverBtn
|
||||
} ${defaultBtn} rounded-r`}
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={isLastPage}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListenItem: React.FC<{ listen: Listen }> = ({ listen }) => {
|
||||
const trackName = listen.track.name;
|
||||
const artists = listen.track.artists.map((artist) => artist.name).join(", ");
|
||||
const timeAgo = formatDistanceToNow(new Date(listen.playedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
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"
|
||||
title={dateTime}
|
||||
>
|
||||
{timeAgo}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
214
frontend/src/components/reports/ReportListens.tsx
Normal file
214
frontend/src/components/reports/ReportListens.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { format, getTime } from "date-fns";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
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 { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
export const ReportListens: React.FC = () => {
|
||||
const [timeFrame, setTimeFrame] = useState<"day" | "week" | "month" | "year">(
|
||||
"day",
|
||||
);
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_7_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const reportOptions = useMemo(
|
||||
() => ({ timeFrame, time: timeOptions }),
|
||||
[timeFrame, timeOptions],
|
||||
);
|
||||
|
||||
const { report, isLoading } = useListensReport(reportOptions);
|
||||
|
||||
const reportHasItems = report.length !== 0;
|
||||
|
||||
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>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportGraph: React.FC<{
|
||||
timeFrame: ListenReportOptions["timeFrame"];
|
||||
data: ListenReportItem[];
|
||||
}> = ({ timeFrame, data }) => {
|
||||
const dataLocal = data.map(({ date, ...other }) => ({
|
||||
...other,
|
||||
date: getTime(date),
|
||||
}));
|
||||
|
||||
const ReportTooltip: React.FC<TooltipProps<number, string>> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}) => {
|
||||
if (!active || payload === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [{ value: listens }] = payload;
|
||||
const date = format(label as number, dateFormatFromTimeFrame(timeFrame));
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 dark:bg-gray-700 shadow-xl p-2 rounded text-sm font-light">
|
||||
<p>{date}</p>
|
||||
<p>
|
||||
Listens: <span className="font-bold">{listens}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<AreaChart
|
||||
data={dataLocal}
|
||||
margin={{
|
||||
left: -5,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#48bb78" stopOpacity={0.8} />
|
||||
<stop offset="90%" stopColor="#48bb78" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
scale="time"
|
||||
type="number"
|
||||
domain={["auto", "auto"]}
|
||||
dataKey="date"
|
||||
tickFormatter={(date) =>
|
||||
format(date, shortDateFormatFromTimeFrame(timeFrame))
|
||||
}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip content={ReportTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#48bb78"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCount)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const shortDateFormatFromTimeFrame = (
|
||||
timeFrame: "day" | "week" | "month" | "year",
|
||||
): string => {
|
||||
const FORMAT_DAY = "P";
|
||||
const FORMAT_WEEK = "'Week' w yyyy";
|
||||
const FORMAT_MONTH = "LLL yyyy";
|
||||
const FORMAT_YEAR = "yyyy";
|
||||
const FORMAT_DEFAULT = FORMAT_DAY;
|
||||
|
||||
switch (timeFrame) {
|
||||
case "day":
|
||||
return FORMAT_DAY;
|
||||
case "week":
|
||||
return FORMAT_WEEK;
|
||||
case "month":
|
||||
return FORMAT_MONTH;
|
||||
case "year":
|
||||
return FORMAT_YEAR;
|
||||
default:
|
||||
return FORMAT_DEFAULT;
|
||||
}
|
||||
};
|
||||
|
||||
const dateFormatFromTimeFrame = (
|
||||
timeFrame: "day" | "week" | "month" | "year",
|
||||
): string => {
|
||||
const FORMAT_DAY = "PPPP";
|
||||
const FORMAT_WEEK = "'Week starting on' PPPP";
|
||||
const FORMAT_MONTH = "LLLL yyyy";
|
||||
const FORMAT_YEAR = "yyyy";
|
||||
const FORMAT_DEFAULT = FORMAT_DAY;
|
||||
|
||||
switch (timeFrame) {
|
||||
case "day":
|
||||
return FORMAT_DAY;
|
||||
case "week":
|
||||
return FORMAT_WEEK;
|
||||
case "month":
|
||||
return FORMAT_MONTH;
|
||||
case "year":
|
||||
return FORMAT_YEAR;
|
||||
default:
|
||||
return FORMAT_DEFAULT;
|
||||
}
|
||||
};
|
||||
84
frontend/src/components/reports/ReportTimeOptions.tsx
Normal file
84
frontend/src/components/reports/ReportTimeOptions.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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";
|
||||
|
||||
interface ReportTimeOptionsProps {
|
||||
timeOptions: TimeOptions;
|
||||
setTimeOptions: (options: TimeOptions) => void;
|
||||
}
|
||||
|
||||
const timePresetOptions = [
|
||||
{ value: TimePreset.LAST_7_DAYS, description: "Last 7 days" },
|
||||
{ value: TimePreset.LAST_30_DAYS, description: "Last 30 days" },
|
||||
{ value: TimePreset.LAST_90_DAYS, description: "Last 90 days" },
|
||||
{ value: TimePreset.LAST_180_DAYS, description: "Last 180 days" },
|
||||
{ value: TimePreset.LAST_365_DAYS, description: "Last 365 days" },
|
||||
{ value: TimePreset.ALL_TIME, description: "All time" },
|
||||
{ value: TimePreset.CUSTOM, description: "Custom" },
|
||||
];
|
||||
|
||||
export const ReportTimeOptions: React.FC<ReportTimeOptionsProps> = ({
|
||||
timeOptions,
|
||||
setTimeOptions,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sm:flex mb-4">
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<Label className="text-sm" htmlFor={"period"}>
|
||||
Period
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(e: TimePreset) =>
|
||||
setTimeOptions({
|
||||
...timeOptions,
|
||||
timePreset: e,
|
||||
})
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
{timeOptions.timePreset === TimePreset.CUSTOM && (
|
||||
<div className="sm:flex text-gray-700 dark:text-gray-200">
|
||||
<div className="pl-2">
|
||||
<DateSelect
|
||||
label="Start"
|
||||
value={timeOptions.customTimeStart}
|
||||
onChange={(newDate) =>
|
||||
setTimeOptions({ ...timeOptions, customTimeStart: newDate })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pl-2">
|
||||
<DateSelect
|
||||
label="End"
|
||||
value={timeOptions.customTimeEnd}
|
||||
onChange={(newDate) =>
|
||||
setTimeOptions({ ...timeOptions, customTimeEnd: newDate })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
frontend/src/components/reports/ReportTopAlbums.tsx
Normal file
78
frontend/src/components/reports/ReportTopAlbums.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
60
frontend/src/components/reports/ReportTopArtists.tsx
Normal file
60
frontend/src/components/reports/ReportTopArtists.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
95
frontend/src/components/reports/ReportTopGenres.tsx
Normal file
95
frontend/src/components/reports/ReportTopGenres.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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>
|
||||
);
|
||||
78
frontend/src/components/reports/ReportTopTracks.tsx
Normal file
78
frontend/src/components/reports/ReportTopTracks.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
50
frontend/src/components/reports/TopListItem.tsx
Normal file
50
frontend/src/components/reports/TopListItem.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
|
||||
export interface TopListItemProps {
|
||||
title: string;
|
||||
subTitle?: string | React.ReactNode;
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* Highest Number that is displayed in the top list. Used to display a "progress bar".
|
||||
*/
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
export const TopListItem: React.FC<TopListItemProps> = ({
|
||||
title,
|
||||
subTitle,
|
||||
count,
|
||||
maxCount,
|
||||
}) => {
|
||||
return (
|
||||
<div className="group bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700/25 md:justify-around text-gray-700 dark:text-gray-300 md:px-2">
|
||||
<div className="flex pt-2">
|
||||
<div className="md:flex w-11/12">
|
||||
<div className={`${subTitle ? "md:w-1/2" : "md:w-full"} font-bold`}>
|
||||
{title}
|
||||
</div>
|
||||
{subTitle && <div className="md:w-1/3">{subTitle}</div>}
|
||||
</div>
|
||||
<div className="w-1/12 self-center">{count}</div>
|
||||
</div>
|
||||
{maxCount && isMaxCountValid(maxCount) && (
|
||||
<div className="h-1 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 - count / maxCount) }}
|
||||
className="h-full group-hover:bg-gray-200 dark:group-hover:bg-gray-700 bg-gray-100 dark:bg-gray-800"
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isMaxCountValid = (maxCount: number) =>
|
||||
!(Number.isNaN(maxCount) || maxCount === 0);
|
||||
|
||||
const numberToPercent = (ratio: number) =>
|
||||
ratio.toLocaleString(undefined, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue