feat(frontend): general revamp of navigation & pages (#303)

This commit is contained in:
Julian Tölle 2023-09-30 19:44:21 +02:00 committed by GitHub
parent f08633587d
commit 4b1dd10846
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 3059 additions and 659 deletions

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

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

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

View 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}
/>
);
};

View 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>
</>
);
};

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

View 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}
/>
);
};

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