feat: initial commit

This commit is contained in:
2026-04-09 20:52:10 -03:00
commit 1a92cc6c11
86 changed files with 19533 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import ThemeToggle from '@/ui/components/internal/theme-toggle';
import Link from 'next/link';
export type HeaderLinksProps = {
links: Array<{
href: string;
label: string;
condition: boolean;
}>;
};
export type ProfileButtonProps = {
userButton: React.ReactNode;
};
export const BaseDesktopHeader = ({
links,
userButton,
}: HeaderLinksProps & ProfileButtonProps) => {
return (
<>
<div className='flex w-3/5 flex-row items-center justify-around align-middle'>
{links
.filter((link) => link.condition)
.map((link) => (
<Link
key={link.href}
href={link.href}
className='text-xl font-normal transition-colors hover:font-bold hover:text-primary'
>
{link.label}
</Link>
))}
</div>
<div className='flex items-center space-x-4'>
<ThemeToggle></ThemeToggle>
{userButton}
</div>
</>
);
};

View File

@@ -0,0 +1,26 @@
'use client';
import { getSessionData } from '@/lib/session/session-storage';
import { BaseDesktopHeader } from '@/ui/components/internal/header/desktop-header/base-desktop-header';
import { UserButton } from '@/ui/components/internal/user-profile/user-profile-button';
import { useQuery } from '@tanstack/react-query';
export const DynamicDesktopHeader = () => {
const { data: sessionData } = useQuery({
queryKey: ['sessionData'],
queryFn: async () => {
return await getSessionData();
},
});
const user = sessionData?.user;
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
{ href: '/admin', label: 'Admin', condition: !!user },
];
const userButton = !!user ? <UserButton user={user} /> : <div />;
return <BaseDesktopHeader links={links} userButton={userButton} />;
};

View File

@@ -0,0 +1,11 @@
import { BaseDesktopHeader } from '@/ui/components/internal/header/desktop-header/base-desktop-header';
import { BlankUserButton } from '@/ui/components/internal/user-profile/user-profile-button';
export const StaticDesktopHeader = () => {
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
];
return <BaseDesktopHeader links={links} userButton={<BlankUserButton />} />;
};

View File

@@ -0,0 +1,22 @@
import { MenuBurgerButton } from '@/ui/components/internal/header/mobile-header/utils/menu-burguer-button';
import ThemeToggle from '@/ui/components/internal/theme-toggle';
export type BaseMobileHeaderProps = {
active?: boolean;
onStateChange?: (value: boolean) => void;
};
export const BaseMobileHeader = ({
active,
onStateChange,
}: BaseMobileHeaderProps) => {
return (
<div className='flex items-center space-x-4'>
<ThemeToggle></ThemeToggle>
<MenuBurgerButton
state={active ?? false}
onStateChange={onStateChange}
/>
</div>
);
};

View File

@@ -0,0 +1,62 @@
'use client';
import { getSessionData } from '@/lib/session/session-storage';
import { BaseMobileHeader } from '@/ui/components/internal/header/mobile-header/base-mobile-header';
import { HeaderSlider } from '@/ui/components/internal/header/mobile-header/utils/header-slider';
import { UserButton } from '@/ui/components/internal/user-profile/user-profile-button';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useState } from 'react';
export const DynamicMobileHeader = () => {
const { data: sessionData } = useQuery({
queryKey: ['sessionData'],
queryFn: async () => {
return await getSessionData();
},
});
const user = sessionData?.user;
const [isNavigationState, setNavigationState] = useState(false);
const links = [
{ href: '/home', label: 'Home', condition: true },
{ href: '/about', label: 'About', condition: true },
{ href: '/admin', label: 'Admin', condition: !!user },
];
const userButton = !!user ? <UserButton user={user} /> : <div />;
return (
<>
<BaseMobileHeader
active={isNavigationState}
onStateChange={(value: boolean) => setNavigationState(value)}
/>
<HeaderSlider
isOpen={isNavigationState}
onClose={() => setNavigationState(false)}
>
<div className='h-full flex flex-col items-center justify-center'>
<div className='h-2/3 flex flex-col items-start justify-around'>
{links
.filter((link) => link.condition)
.map((link) => (
<Link
key={link.href}
href={link.href}
className='text-xl font-normal transition-colors hover:font-bold hover:text-primary'
>
{link.label}
</Link>
))}
</div>
<div className='h-1/3 flex items-center space-x-4 mt-4'>
{userButton}
</div>
</div>
</HeaderSlider>
</>
);
};

View File

@@ -0,0 +1,5 @@
import { BaseMobileHeader } from '@/ui/components/internal/header/mobile-header/base-mobile-header';
export const StaticMobileHeader = () => {
return <BaseMobileHeader active={false} />;
};

View File

@@ -0,0 +1,29 @@
export const HeaderSlider = ({
children,
isOpen,
onClose,
}: Readonly<{
children: React.ReactNode;
isOpen: boolean;
onClose?: () => void;
}>) => {
const clickedOutside = () => {
if (onClose) onClose();
};
return (
<div
className={`absolute top-[8dvh] right-0 flex col w-full duration-300
${isOpen ? '' : 'delay-300 translate-x-[102vw]'}`}
>
<div
className={`page-height w-1/2 transition-all ease-in-out duration-300 ${
isOpen ? 'delay-300 bg-black/60' : 'bg-black/0'
}`}
onClick={clickedOutside}
/>
{/* Only slide the content panel */}
<div className={`bg-background page-height w-1/2`}>{children}</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
'use client';
type MenuBurgerButtonProps = {
state: boolean;
onStateChange?: (value: boolean) => void;
};
export const MenuBurgerButton = ({
state,
onStateChange,
}: MenuBurgerButtonProps) => {
state = state ?? false;
// This function is called when the button is clicked
const handleClick = () => {
if (onStateChange) onStateChange(!state);
};
return (
<div
className='flex flex-col md:hidden w-10 h-10 justify-center items-center cursor-pointer relative'
onClick={handleClick}
>
<span
className={`w-8 h-[4px] bg-primary rounded transition-all duration-200 ${
state ? 'opacity-0' : 'opacity-100'
}`}
/>
<span
className={`absolute w-8 h-[4px] bg-primary rounded transition-all duration-200 ${
state ? 'rotate-45' : '-translate-y-[10px]'
}`}
/>
<span
className={`absolute w-8 h-[4px] bg-primary rounded transition-all duration-200 ${
state ? '-rotate-45' : 'translate-y-[10px]'
}`}
/>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { siteConfig } from '@/site.config';
import { DynamicDesktopHeader } from '@/ui/components/internal/header/desktop-header/dynamic-desktop-header';
import { StaticDesktopHeader } from '@/ui/components/internal/header/desktop-header/static-desktop-header';
import { DynamicMobileHeader } from '@/ui/components/internal/header/mobile-header/dynamic-mobile-header';
import { StaticMobileHeader } from '@/ui/components/internal/header/mobile-header/static-mobile-header';
import Image from 'next/image';
import Link from 'next/link';
import { Suspense } from 'react';
export const SiteHeader = () => {
return (
<header className='header-height sticky top-0 z-50 w-full bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60'>
<div className='container mx-auto flex h-full items-center justify-between px-4 py-4 align-middle'>
<Link href='/'>
<Image
src={siteConfig['icon']['dark']}
className='hidden dark:block'
alt='Site Icon - Dark'
width={50}
height={50}
priority
/>
<Image
src={siteConfig['icon']['light']}
className='block dark:hidden'
alt='Site Icon - Light'
width={50}
height={50}
priority
/>
</Link>
<div className='hidden w-full justify-between align-middle md:flex'>
<Suspense fallback={<StaticDesktopHeader />}>
<DynamicDesktopHeader />
</Suspense>
</div>
<div className='flex w-full justify-end align-middle md:hidden'>
<Suspense fallback={<StaticMobileHeader />}>
<DynamicMobileHeader />
</Suspense>
</div>
</div>
</header>
);
};

View File

@@ -0,0 +1,30 @@
import { siteConfig } from '@/site.config';
export function SiteFooter() {
const buildCopyrightYear = (): string => {
if (siteConfig.copyright.initialYear == new Date().getFullYear()) {
return `(${new Date().getFullYear()})`;
}
return `(${siteConfig.copyright.initialYear}-${new Date().getFullYear()})`;
};
return (
<footer className='w-full'>
<div className='footer-height container mx-auto flex flex-col items-center justify-between gap-4 md:flex-row'>
<p className='text-balance text-center text-sm leading-loose text-muted-foreground md:text-left'>
Built by{' '}
<a
href={siteConfig.author.links.twitter}
target='_blank'
rel='noreferrer'
className='font-medium underline underline-offset-4'
>
{siteConfig.author.name}
</a>
. ©{buildCopyrightYear()} {siteConfig.copyright.company}.
All rights reserved.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
} from '@/ui/components/shadcn/dropdown-menu';
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import * as React from 'react';
export default function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='icon'>
<Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,15 @@
import { twMerge } from 'tailwind-merge';
const EmptyProfileIcon = ({ className }: { className?: string }) => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 448 512'
className={twMerge('h-[50px] w-[50px]', className)}
>
<path d='M304 128a80 80 0 1 0 -160 0 80 80 0 1 0 160 0zM96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM49.3 464l349.5 0c-8.9-63.3-63.3-112-129-112l-91.4 0c-65.7 0-120.1 48.7-129 112zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3z' />
</svg>
);
};
export default EmptyProfileIcon;

View File

@@ -0,0 +1,15 @@
import { twMerge } from 'tailwind-merge';
const LoginIcon = ({ className }: { className?: string }) => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 640 640'
className={twMerge('h-[50px] w-[50px]', className)}
>
<path d='M409 337C418.4 327.6 418.4 312.4 409 303.1L265 159C258.1 152.1 247.8 150.1 238.8 153.8C229.8 157.5 224 166.3 224 176L224 256L112 256C85.5 256 64 277.5 64 304L64 336C64 362.5 85.5 384 112 384L224 384L224 464C224 473.7 229.8 482.5 238.8 486.2C247.8 489.9 258.1 487.9 265 481L409 337zM416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480z' />
</svg>
);
};
export default LoginIcon;

View File

@@ -0,0 +1,42 @@
'use client';
import { montserrat } from '@/app/fonts';
import { Separator } from '@/ui/components/shadcn/separator';
import { useClerk } from '@clerk/nextjs';
import { useQueryClient } from '@tanstack/react-query';
export type UserProfileButtonProps = {
username: string;
};
export function UserManagmentPopover({ username }: UserProfileButtonProps) {
const queryClient = useQueryClient();
const { signOut } = useClerk();
const onSignOutUser = () => {
signOut({ redirectUrl: '/api/user/unload' }).then(() => {
queryClient.invalidateQueries({ queryKey: ['sessionData'] });
console.log(`Signing out user: ${username}`);
});
};
return (
<>
<div className='space-y-4'>
<p className='text-sm font-medium leading-none'>{username}</p>
<Separator />
<button
className='flex h-[24px] w-full cursor-pointer items-center justify-start'
onClick={onSignOutUser}
>
<p
className={`text-sm font-medium leading-none ${montserrat.className}`}
>
Sign out
</p>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,63 @@
import LoginIcon from './icons/login-icon';
import { UserModel } from '@/lib/feature/user/user.model';
import EmptyProfileIcon from '@/ui/components/internal/user-profile/icons/empty-profile-icon';
import { UserManagmentPopover } from '@/ui/components/internal/user-profile/popover/user-manage';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/ui/components/shadcn/avatar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/ui/components/shadcn/popover';
import { SignInButton } from '@clerk/nextjs';
const genInitials = (name: string | null) => {
if (!name) {
return 'US';
}
return name.match(/(\b\S)?/g)?.join('') || '';
};
export const UserButton = ({ user }: { user: UserModel }) => {
const userName = user.name || 'PlaceHolder';
return (
<Popover>
<PopoverTrigger>
<Avatar className='flex h-[47px] w-[47px] items-center justify-center align-middle'>
<>
<AvatarImage
src={user.imageUrl || 'https://no-image'}
className='fill-white'
/>
<AvatarFallback>{genInitials(userName)}</AvatarFallback>
</>
</Avatar>
</PopoverTrigger>
<PopoverContent align='end' className='w-[200px]'>
<UserManagmentPopover username={userName} />
</PopoverContent>
</Popover>
);
};
export const BlankUserButton = () => {
return (
<>
<SignInButton>
<Avatar className='group flex h-[47px] w-[47px] items-center justify-center align-middle cursor-pointer'>
<>
<AvatarImage />
<AvatarFallback>
<EmptyProfileIcon className='block group-hover:hidden h-[22px] w-[22px] fill-black dark:fill-white font-light' />
<LoginIcon className='hidden group-hover:block h-[22px] w-[22px] fill-black dark:fill-white font-light' />
</AvatarFallback>
</>
</Avatar>
</SignInButton>
</>
);
};

View File

@@ -0,0 +1,111 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Avatar as AvatarPrimitive } from 'radix-ui';
import * as React from 'react';
function Avatar({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg';
}) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn(
'aspect-square size-full rounded-full object-cover',
className
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='avatar-badge'
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group'
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group-count'
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};

View File

@@ -0,0 +1,66 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot='button'
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,280 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import * as React from 'react';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal
data-slot='dropdown-menu-portal'
{...props}
/>
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
function DropdownMenuContent({
className,
align = 'start',
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
align={align}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group
data-slot='dropdown-menu-group'
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className='pointer-events-none absolute right-2 flex items-center justify-center'
data-slot='dropdown-menu-checkbox-item-indicator'
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className='pointer-events-none absolute right-2 flex items-center justify-center'
data-slot='dropdown-menu-radio-item-indicator'
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return (
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

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

View File

@@ -0,0 +1,88 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Popover as PopoverPrimitive } from 'radix-ui';
import * as React from 'react';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='popover-header'
className={cn('flex flex-col gap-0.5 text-sm', className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return (
<div
data-slot='popover-title'
className={cn('font-heading font-medium', className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<'p'>) {
return (
<p
data-slot='popover-description'
className={cn('text-muted-foreground', className)}
{...props}
/>
);
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};

View File

@@ -0,0 +1,27 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import * as React from 'react';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator'
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,45 @@
'use client';
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className='toaster group'
icons={{
success: <CircleCheckIcon className='size-4' />,
info: <InfoIcon className='size-4' />,
warning: <TriangleAlertIcon className='size-4' />,
error: <OctagonXIcon className='size-4' />,
loading: <Loader2Icon className='size-4 animate-spin' />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: 'cn-toast',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,15 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Loader2Icon } from 'lucide-react';
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role='status'
aria-label='Loading'
className={cn('size-4 animate-spin', className)}
{...props}
/>
);
}
export { Spinner };

View File

@@ -0,0 +1,56 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import * as React from 'react';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot='tooltip-provider'
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot='tooltip' {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot='tooltip-content'
sideOffset={sideOffset}
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@@ -0,0 +1,30 @@
'use client';
import { TooltipProvider } from '@/ui/components/shadcn/tooltip';
import { getQueryClient } from '@/ui/providers/utils/get-query-client';
import { ClerkProvider } from '@clerk/nextjs';
import { QueryClientProvider } from '@tanstack/react-query';
import { Provider as JotaiProvider } from 'jotai';
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps,
} from 'next-themes';
import * as React from 'react';
export function Provider({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient();
return (
<ClerkProvider>
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<NextThemesProvider {...props}>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</NextThemesProvider>
</QueryClientProvider>
</JotaiProvider>
</ClerkProvider>
);
}

View File

@@ -0,0 +1,23 @@
import { environmentManager } from '@tanstack/query-core';
import { QueryClient } from '@tanstack/react-query';
const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
};
let browserQueryClient: QueryClient | null = null;
export const getQueryClient = () => {
if (environmentManager.isServer()) {
return makeQueryClient();
}
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
};