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

16
frontend/components.json Normal file
View file

@ -0,0 +1,16 @@
{
"$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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,12 @@
},
"license": "MIT",
"dependencies": {
"@radix-ui/react-avatar": "1.0.3",
"@radix-ui/react-dropdown-menu": "2.0.5",
"@radix-ui/react-label": "2.0.2",
"@radix-ui/react-navigation-menu": "1.1.3",
"@radix-ui/react-select": "1.2.2",
"@radix-ui/react-slot": "1.0.2",
"@testing-library/jest-dom": "6.1.3",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.5.1",
@ -20,9 +26,12 @@
"@vitejs/plugin-react": "4.1.0",
"autoprefixer": "10.4.16",
"axios": "1.5.1",
"class-variance-authority": "0.7.0",
"clsx": "2.0.0",
"date-fns": "2.30.0",
"eslint-config-react-app": "7.0.1",
"jsdom": "22.1.0",
"lucide-react": "0.279.0",
"npm-run-all": "4.1.5",
"postcss": "8.4.31",
"prettier": "3.0.3",
@ -30,7 +39,9 @@
"react-dom": "18.2.0",
"react-router-dom": "6.16.0",
"recharts": "2.8.0",
"tailwind-merge": "1.14.0",
"tailwindcss": "3.3.3",
"tailwindcss-animate": "1.0.7",
"typescript": "5.2.2",
"vite": "4.4.9",
"vitest": "0.34.6"

View file

@ -4,18 +4,18 @@ import { AuthApiTokens } from "./components/AuthApiTokens";
import { Footer } from "./components/Footer";
import { LoginFailure } from "./components/LoginFailure";
import { LoginLoading } from "./components/LoginLoading";
import { LoginSuccess } from "./components/LoginSuccess";
import { NavBar } from "./components/NavBar";
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 { 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 { useAuth } from "./hooks/use-auth";
export function App() {
const { isLoaded } = useAuth();
const { isLoaded, user } = useAuth();
if (!isLoaded) {
return <LoginLoading />;
@ -27,18 +27,42 @@ 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 path="/" />
<Route path="/login/success" element={<LoginSuccess />} />
<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="/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>
)}
{!user && (
<Routes>
<Route index />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
)}
</div>
</div>
</main>
<footer>
<Footer />

View file

@ -5,22 +5,17 @@ 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 "./Spinner";
import { Spinner } from "./ui/Spinner";
export const AuthApiTokens: React.FC = () => {
const { requireUser } = useAuthProtection();
const { apiTokens, isLoading, createToken, revokeToken } = useApiTokens();
const sortedTokens = useMemo(
() => apiTokens.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
[apiTokens],
);
requireUser();
return (
<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>
@ -59,8 +54,7 @@ export const AuthApiTokens: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
</>
);
};

View file

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

View file

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

View file

@ -1,55 +1,123 @@
import React, { useCallback, useRef, useState } from "react";
import React 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 { 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 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 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>
<nav className="w-full block grow lg:flex lg:items-center lg:w-auto ">
<div className="text-sm lg:grow">
<nav className="w-full grow sm:flex sm:items-center sm:w-auto">
<div className="sm:grow">
{user && (
<>
<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>
</>
<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>
)}
</div>
<div>
{!user && (
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink
asChild
className={navigationMenuTriggerStyle()}
>
<a {...loginWithSpotifyProps()}>
<NavItem>
Login with Spotify{" "}
<span>Login with Spotify </span>
<SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
</NavItem>
</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)}
{user && <NavUserInfo user={user} />}
</div>
@ -58,58 +126,70 @@ export const NavBar: React.FC = () => {
);
};
const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const NavListItem = React.forwardRef<
React.ElementRef<typeof Link>,
React.ComponentPropsWithoutRef<typeof Link>
>(({ className, title, children, ...props }, ref) => {
return (
<span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
<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}
</span>
</p>
</Link>
</NavigationMenuLink>
</li>
);
};
});
NavListItem.displayName = "NavListItem";
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)}
<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 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 className="text-green-200 pl-2 sm:pr-2">
{user.displayName}
</span>
<Link to="/auth/api-tokens" onClick={closeMenu}>
<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>
</li>
</ul>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -1,85 +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 { 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

@ -1,67 +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 { 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

@ -1,102 +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 { 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

@ -1,85 +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 { 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

@ -0,0 +1,73 @@
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,17 +1,16 @@
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 { useAuthProtection } from "../hooks/use-auth-protection";
import { ReloadIcon } from "../icons/Reload";
import { getPaginationItems } from "../util/getPaginationItems";
import { Spinner } from "./Spinner";
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 { requireUser } = useAuthProtection();
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
@ -26,21 +25,19 @@ export const RecentListens: React.FC = () => {
}
}, [totalPages, paginationMeta]);
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">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Recent listens
</p>
<button
</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>
</Button>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-2 m-2">
{isLoading && <Spinner className="m-8" />}
@ -53,17 +50,18 @@ export const RecentListens: React.FC = () => {
)}
<div>
{recentListens.length > 0 && (
<div className="table-auto w-full">
<Table className="table-auto w-full text-base">
<TableBody>
{recentListens.map((listen) => (
<ListenItem listen={listen} key={listen.id} />
))}
</div>
</TableBody>
</Table>
)}
</div>
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
</div>
</div>
</div>
</>
);
};
@ -134,15 +132,19 @@ const ListenItem: React.FC<{ listen: Listen }> = ({ listen }) => {
});
const dateTime = format(new Date(listen.playedAt), "PP p");
return (
<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"
<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}
</div>
</div>
</TableCell>
</TableRow>
);
};

View file

@ -10,18 +10,23 @@ 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 { useAuthProtection } from "../hooks/use-auth-protection";
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 "./Spinner";
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 { requireUser } = useAuthProtection();
const [timeFrame, setTimeFrame] = useState<"day" | "week" | "month" | "year">(
"day",
);
@ -41,33 +46,34 @@ export const ReportListens: React.FC = () => {
const reportHasItems = report.length !== 0;
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">
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
Listen Report
</p>
</h2>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
<div className="md:flex">
<div className="sm: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",
)
<Label className="text-sm" htmlFor={"timeframe"}>
Timeframe
</Label>
<Select
onValueChange={(e: "day" | "week" | "month" | "year") =>
setTimeFrame(e)
}
>
<option value="day">Daily</option>
<option value="week">Weekly</option>
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
<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}
@ -86,8 +92,7 @@ export const ReportListens: React.FC = () => {
</div>
)}
</div>
</div>
</div>
</>
);
};

View file

@ -1,7 +1,15 @@
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 { 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;
@ -23,28 +31,34 @@ export const ReportTimeOptions: React.FC<ReportTimeOptionsProps> = ({
setTimeOptions,
}) => {
return (
<div className="md:flex mb-4">
<div className="sm:flex mb-4">
<div className="text-gray-700 dark:text-gray-300">
<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) =>
<Label className="text-sm" htmlFor={"period"}>
Period
</Label>
<Select
onValueChange={(e: TimePreset) =>
setTimeOptions({
...timeOptions,
timePreset: e.target.value as TimePreset,
timePreset: e,
})
}
value={timeOptions.timePreset}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Time period" />
</SelectTrigger>
<SelectContent>
{timePresetOptions.map(({ value, description }) => (
<option value={value} key={value}>
<SelectItem value={value} key={value}>
{description}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
</div>
{timeOptions.timePreset === TimePreset.CUSTOM && (
<div className="md:flex text-gray-700 dark:text-gray-200">
<div className="sm:flex text-gray-700 dark:text-gray-200">
<div className="pl-2">
<DateSelect
label="Start"

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

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

View file

@ -0,0 +1,48 @@
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

@ -0,0 +1,58 @@
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

@ -0,0 +1,198 @@
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

@ -0,0 +1,24 @@
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

@ -0,0 +1,129 @@
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

@ -0,0 +1,119 @@
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

@ -0,0 +1,114 @@
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 text-sm", 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

@ -1,26 +0,0 @@
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

@ -5,6 +5,7 @@ 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")!);
@ -13,7 +14,9 @@ root.render(
<ProvideAuth>
<ProvideApiClient>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</ProvideApiClient>
</ProvideAuth>

View file

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

View file

@ -1,6 +1,8 @@
const colors = require("tailwindcss/colors");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
theme: {
colors: {
@ -12,6 +14,7 @@ module.exports = {
// Tailwind v1 Colors
gray: {
50: "#ffffff",
100: "#f7fafc",
200: "#edf2f7",
300: "#e2e8f0",
@ -21,9 +24,11 @@ module.exports = {
700: "#4a5568",
800: "#2d3748",
900: "#1a202c",
950: "#0C0F12",
},
green: {
50: "#FFFFFF",
100: "#f0fff4",
200: "#c6f6d5",
300: "#9ae6b4",
@ -33,6 +38,7 @@ module.exports = {
700: "#2f855a",
800: "#276749",
900: "#22543d",
950: "#1C4A2F",
},
yellow: colors.yellow,
@ -40,5 +46,29 @@ 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

@ -21,5 +21,8 @@
},
"include": [
"src"
]
],
"paths": {
"src/*": ["./src/*"]
}
}

View file

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