More progress

This commit is contained in:
cte 2025-05-12 11:23:11 -07:00
parent a65942d4fc
commit 6ee2266c36
53 changed files with 481 additions and 822 deletions

View file

@ -16,12 +16,12 @@ export default function DashboardLayout({
return (
<>
<div className="shadow-md">
<div className="mx-auto flex max-w-screen-xl items-center justify-between px-3 py-4">
<div className="mx-auto flex max-w-screen-lg items-center justify-between px-4 py-4">
<DashboardHeader />
</div>
</div>
<div className="min-h-[calc(100vh-72px)] bg-muted">
<div className="mx-auto max-w-screen-xl px-3 pb-16 pt-6">
<div className="mx-auto max-w-screen-lg px-4 pb-16 pt-6">
{children}
</div>
</div>

View file

@ -0,0 +1,19 @@
'use client';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
import { OrganizationProfile } from '@clerk/nextjs';
export const OrgProfile = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<OrganizationProfile
appearance={{ baseTheme }}
routing="path"
path="/dashboard/org"
afterLeaveOrganizationUrl="/onboarding/select-org"
/>
);
};

View file

@ -0,0 +1,5 @@
import { OrgProfile } from './OrgProfile';
export default function Page() {
return <OrgProfile />;
}

View file

@ -1,23 +0,0 @@
import { OrganizationProfile } from '@clerk/nextjs';
import { useTranslations } from 'next-intl';
import { TitleBar } from '@/components/dashboard/TitleBar';
export default function Page() {
const t = useTranslations('OrganizationProfile');
return (
<>
<TitleBar
title={t('title_bar')}
description={t('title_bar_description')}
/>
<OrganizationProfile
routing="path"
path="/dashboard/organization-profile"
afterLeaveOrganizationUrl="/onboarding/select-org"
appearance={{ elements: { rootBox: 'w-full', cardBox: 'w-full flex' } }}
/>
</>
);
}

View file

@ -2,36 +2,10 @@ import { useTranslations } from 'next-intl';
import { TitleBar } from '@/components/dashboard/TitleBar';
const DashboardIndexPage = () => {
export default function Page() {
const t = useTranslations('DashboardIndex');
return (
<>
<TitleBar
title={t('title_bar')}
description={t('title_bar_description')}
/>
<div className="flex h-[600px] flex-col items-center justify-center rounded-md bg-card p-5">
<div className="size-16 rounded-full bg-muted p-3 [&_svg]:stroke-muted-foreground [&_svg]:stroke-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
</div>
<div className="mt-3 text-center">
<div className="text-xl font-semibold">
{t('message_state_title')}
</div>
</div>
</div>
</>
<TitleBar title={t('title_bar')} description={t('title_bar_description')} />
);
};
export default DashboardIndexPage;
}

View file

@ -1,22 +0,0 @@
import { UserProfile } from '@clerk/nextjs';
import { useTranslations } from 'next-intl';
import { TitleBar } from '@/components/dashboard/TitleBar';
export default function Page() {
const t = useTranslations('UserProfile');
return (
<>
<TitleBar
title={t('title_bar')}
description={t('title_bar_description')}
/>
<UserProfile
routing="path"
path="/dashboard/user-profile"
appearance={{ elements: { rootBox: 'w-full', cardBox: 'w-full flex' } }}
/>
</>
);
}

View file

@ -0,0 +1,18 @@
'use client';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
import { UserProfile as ClerkUserProfile } from '@clerk/nextjs';
export const UserProfile = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<ClerkUserProfile
appearance={{ baseTheme }}
routing="path"
path="/dashboard/user"
/>
);
};

View file

@ -0,0 +1,5 @@
import { UserProfile } from './UserProfile';
export default function Page() {
return <UserProfile />;
}

View file

@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
import { auth as clerkAuth } from '@clerk/nextjs/server';
export default async function CenteredLayout({
export default async function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;

View file

@ -6,7 +6,7 @@ import { getLocale } from 'next-intl/server';
import { enUS, frFR } from '@clerk/localizations';
import { ClerkProvider } from '@clerk/nextjs';
import { ThemeProvider } from '@/components/ThemeProvider';
import { ThemeProvider } from '@/components/layout/ThemeProvider';
export const metadata: Metadata = {
icons: [

View file

@ -0,0 +1,17 @@
'use client';
import { useTheme } from 'next-themes';
import { CreateOrganization } from '@clerk/nextjs';
import { dark } from '@clerk/themes';
export const CreateOrg = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<CreateOrganization
appearance={{ baseTheme }}
afterCreateOrganizationUrl="/dashboard"
/>
);
};

View file

@ -1,8 +1,7 @@
import { redirect } from 'next/navigation';
import { CreateOrganization } from '@clerk/nextjs';
import { auth as clerkAuth } from '@clerk/nextjs/server';
import { getLocale, getTranslations } from 'next-intl/server';
import { CreateOrg } from './CreateOrg';
export async function generateMetadata() {
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: 'Dashboard' });
@ -12,18 +11,5 @@ export async function generateMetadata() {
export const dynamic = 'force-dynamic';
export default async function Page() {
const auth = await clerkAuth();
if (!auth.userId) {
redirect('/');
}
return (
<div className="flex min-h-screen items-center justify-center">
<CreateOrganization
// afterSelectOrganizationUrl="/dashboard"
afterCreateOrganizationUrl="/dashboard"
/>
</div>
);
return <CreateOrg />;
}

View file

@ -0,0 +1,20 @@
import { redirect } from 'next/navigation';
import { auth as clerkAuth } from '@clerk/nextjs/server';
export default async function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
const auth = await clerkAuth();
if (!auth.userId) {
redirect('/');
}
return (
<div className="flex min-h-screen items-center justify-center">
{children}
</div>
);
}

View file

@ -0,0 +1,18 @@
'use client';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
import { OrganizationList } from '@clerk/nextjs';
export const SelectOrg = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<OrganizationList
appearance={{ baseTheme }}
afterSelectOrganizationUrl="/dashboard"
afterCreateOrganizationUrl="/dashboard"
/>
);
};

View file

@ -1,8 +1,7 @@
import { redirect } from 'next/navigation';
import { OrganizationList } from '@clerk/nextjs';
import { auth as clerkAuth } from '@clerk/nextjs/server';
import { getLocale, getTranslations } from 'next-intl/server';
import { SelectOrg } from './SelectOrg';
export async function generateMetadata() {
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: 'Dashboard' });
@ -12,18 +11,5 @@ export async function generateMetadata() {
export const dynamic = 'force-dynamic';
export default async function Page() {
const auth = await clerkAuth();
if (!auth.userId) {
redirect('/');
}
return (
<div className="flex min-h-screen items-center justify-center">
<OrganizationList
afterSelectOrganizationUrl="/dashboard"
afterCreateOrganizationUrl="/dashboard"
/>
</div>
);
return <SelectOrg />;
}

View file

@ -1,26 +1,20 @@
import { redirect } from 'next/navigation';
import { auth as clerkAuth } from '@clerk/nextjs/server';
import { getLocale, getTranslations, setRequestLocale } from 'next-intl/server';
import { Features } from '@/components/templates/Features';
import { Footer } from '@/components/templates/Footer';
import { Hero } from '@/components/templates/Hero';
import { Navbar } from '@/components/templates/Navbar';
import { redirect } from 'next/navigation';
import { Navbar, Footer } from '@/components/layout';
import { Hero, Features } from '@/components/landing';
export async function generateMetadata() {
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: 'Index' });
return {
title: t('meta_title'),
description: t('meta_description'),
};
return { title: t('meta_title'), description: t('meta_description') };
}
export default async function Page() {
const auth = await clerkAuth();
const { userId } = await clerkAuth();
if (auth.userId) {
if (userId) {
redirect('/dashboard');
}

View file

@ -0,0 +1,22 @@
'use client';
import Link from 'next/link';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
import { SignIn as ClerkSignIn } from '@clerk/nextjs';
import { Logo } from '@/components/layout';
export const SignIn = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<div className="flex flex-col gap-8 h-screen w-full items-center justify-center">
<Link href="/">
<Logo />
</Link>
<ClerkSignIn appearance={{ baseTheme }} />
</div>
);
};

View file

@ -1,6 +1,7 @@
import { SignIn } from '@clerk/nextjs';
import { getLocale, getTranslations } from 'next-intl/server';
import { SignIn } from './SignIn';
export async function generateMetadata() {
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: 'SignIn' });
@ -8,9 +9,5 @@ export async function generateMetadata() {
}
export default function Page() {
return (
<div className="flex h-screen w-full items-center justify-center">
<SignIn forceRedirectUrl="/dashboard" />
</div>
);
return <SignIn />;
}

View file

@ -0,0 +1,22 @@
'use client';
import Link from 'next/link';
import { useTheme } from 'next-themes';
import { dark } from '@clerk/themes';
import { SignUp as ClerkSignUp } from '@clerk/nextjs';
import { Logo } from '@/components/layout';
export const SignUp = () => {
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<div className="flex flex-col gap-8 h-screen w-full items-center justify-center">
<Link href="/">
<Logo />
</Link>
<ClerkSignUp appearance={{ baseTheme }} />
</div>
);
};

View file

@ -1,6 +1,7 @@
import { SignUp } from '@clerk/nextjs';
import { getLocale, getTranslations } from 'next-intl/server';
import { SignUp } from './SignUp';
export async function generateMetadata() {
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: 'SignUp' });
@ -8,9 +9,5 @@ export async function generateMetadata() {
}
export default function Page() {
return (
<div className="flex h-screen w-full items-center justify-center">
<SignUp />
</div>
);
return <SignUp />;
}

View file

@ -1,28 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
type ActiveLinkProps = {
href: string;
children: React.ReactNode;
};
export const ActiveLink = ({ href, children }: ActiveLinkProps) => {
const pathname = usePathname();
return (
<Link
href={href}
className={cn(
'px-3 py-2',
pathname.endsWith(href) &&
'rounded-md bg-primary text-primary-foreground',
)}
>
{children}
</Link>
);
};

View file

@ -1,19 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { ToggleMenuButton } from './ToggleMenuButton';
describe('ToggleMenuButton', () => {
describe('onClick props', () => {
it('should call the callback when the user click on the button', async () => {
const handler = vi.fn();
render(<ToggleMenuButton onClick={handler} />);
const button = screen.getByRole('button');
await userEvent.click(button);
expect(handler).toHaveBeenCalled();
});
});
});

View file

@ -1,36 +0,0 @@
import { type ForwardedRef, forwardRef } from 'react';
import { Button } from '@/components/ui';
type ToggleMenuButtonProps = {
onClick?: () => void;
};
const ToggleMenuButtonInternal = (
props: ToggleMenuButtonProps,
ref?: ForwardedRef<HTMLButtonElement>,
) => (
<Button
className="p-2 focus-visible:ring-offset-0"
variant="ghost"
ref={ref}
{...props}
>
<svg
className="size-6 stroke-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</Button>
);
const ToggleMenuButton = forwardRef(ToggleMenuButtonInternal);
export { ToggleMenuButton };

View file

@ -1,111 +1,45 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import {
OrganizationSwitcher,
UserButton,
useOrganization,
} from '@clerk/nextjs';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { OrganizationSwitcher, UserButton } from '@clerk/nextjs';
import { dark } from '@clerk/themes';
import { ActiveLink } from '@/components/ActiveLink';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { ToggleMenuButton } from '@/components/ToggleMenuButton';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Separator,
} from '@/components/ui';
import { Logo } from '@/components/templates/Logo';
import { LocaleSwitcher } from '@/components/layout/LocaleSwitcher';
import { ThemeSwitcher } from '@/components/layout/ThemeSwitcher';
import { Logo } from '@/components/layout/Logo';
export const DashboardHeader = () => {
const t = useTranslations('DashboardLayout');
const { organization } = useOrganization();
const menu = useMemo(() => {
const menu = [{ href: '/dashboard', label: t('home') }];
return organization
? [
...menu,
{
href: '/dashboard/organization-profile/organization-members',
label: t('members'),
},
{ href: '/dashboard/organization-profile', label: t('settings') },
]
: menu;
}, [organization, t]);
const { resolvedTheme } = useTheme();
const baseTheme = resolvedTheme === 'dark' ? dark : undefined;
return (
<>
<div className="flex items-center">
<Link href="/dashboard" className="max-sm:hidden">
<div className="flex items-center gap-2">
<Link href="/dashboard">
<Logo />
</Link>
<svg
className="size-8 stroke-muted-foreground max-sm:hidden"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M17 5 7 19" />
</svg>
<OrganizationSwitcher
appearance={{ baseTheme }}
organizationProfileMode="navigation"
organizationProfileUrl="/dashboard/organization-profile"
organizationProfileUrl="/dashboard/org"
afterCreateOrganizationUrl="/dashboard"
createOrganizationUrl="/onboarding/create-org"
/>
<nav className="ml-3 max-lg:hidden">
<ul className="flex flex-row items-center gap-x-3 text-lg font-medium [&_a:hover]:opacity-100 [&_a]:opacity-75">
{menu.map(({ href, label }) => (
<li key={href}>
<ActiveLink href={href}>{label}</ActiveLink>
</li>
))}
</ul>
</nav>
</div>
<div>
<ul className="flex items-center gap-x-1.5 [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
<li data-fade>
<div className="lg:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToggleMenuButton />
</DropdownMenuTrigger>
<DropdownMenuContent>
{menu.map(({ href, label }) => (
<DropdownMenuItem key={href} asChild>
<Link href={href}>{label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
<ul className="flex items-center gap-2 [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
<li data-fade>
<LocaleSwitcher />
</li>
<li data-fade>
<ThemeSwitcher />
</li>
<li>
<Separator orientation="vertical" className="h-4" />
</li>
<li>
<UserButton
appearance={{ baseTheme }}
userProfileMode="navigation"
userProfileUrl="/dashboard/user-profile"
appearance={{ elements: { rootBox: 'px-2 py-1.5' } }}
userProfileUrl="/dashboard/user"
/>
</li>
</ul>

View file

@ -1,21 +0,0 @@
type DashboardSectionProps = {
title: string;
description: string;
children: React.ReactNode;
};
export const DashboardSection = ({
title,
description,
children,
}: DashboardSectionProps) => (
<div className="rounded-md bg-card p-5">
<div className="max-w-3xl">
<div className="text-lg font-semibold">{title}</div>
<div className="mb-4 text-sm font-medium text-muted-foreground">
{description}
</div>
{children}
</div>
</div>
);

View file

@ -1,13 +1,14 @@
export const TitleBar = (props: {
type TitleBarProps = {
title: React.ReactNode;
description?: React.ReactNode;
}) => (
<div className="mb-8">
<div className="text-2xl font-semibold">{props.title}</div>
};
{props.description && (
export const TitleBar = ({ title, description }: TitleBarProps) => (
<div className="mb-8">
<div className="text-2xl font-semibold">{title}</div>
{description && (
<div className="text-sm font-medium text-muted-foreground">
{props.description}
{description}
</div>
)}
</div>

View file

@ -1,24 +0,0 @@
import { render, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import messages from '@/i18n/locales/en.json';
import { CenteredFooter } from './CenteredFooter';
describe('CenteredFooter', () => {
describe('Render method', () => {
it('should have copyright information', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<CenteredFooter iconList={null} legalLinks={null}>
Random children
</CenteredFooter>
</NextIntlClientProvider>,
);
const copyright = screen.getByText(/© Copyright/);
expect(copyright).toBeInTheDocument();
});
});
});

View file

@ -1,34 +0,0 @@
import React from 'react';
import { APP_NAME } from '@/lib/constants';
type CenteredFooterProps = {
iconList: React.ReactNode;
legalLinks: React.ReactNode;
children?: React.ReactNode;
};
export const CenteredFooter = ({
iconList,
legalLinks,
children,
}: CenteredFooterProps) => (
<div className="flex flex-col items-center text-center">
{children && (
<ul className="mt-4 flex gap-x-8 text-lg max-sm:flex-col [&_a:hover]:opacity-100 [&_a]:opacity-60">
{children}
</ul>
)}
<ul className="mt-4 flex flex-row gap-x-5 text-muted-foreground [&_svg:hover]:text-primary [&_svg:hover]:opacity-100 [&_svg]:size-5 [&_svg]:fill-current [&_svg]:opacity-60">
{iconList}
</ul>
<div className="mt-6 flex w-full items-center justify-between gap-y-2 border-t pt-3 text-sm text-muted-foreground max-md:flex-col">
<div>
© Copyright {new Date().getFullYear()} {APP_NAME}.
</div>
<ul className="flex gap-x-4 font-medium [&_a:hover]:opacity-100 [&_a]:opacity-60">
{legalLinks}
</ul>
</div>
</div>
);

View file

@ -1,25 +0,0 @@
type CenteredHeroProps = {
title: React.ReactNode;
description: string;
buttons?: React.ReactNode;
};
export const CenteredHero = ({
title,
description,
buttons,
}: CenteredHeroProps) => (
<>
<div className="mt-3 text-center text-5xl font-bold tracking-tight">
{title}
</div>
<div className="mx-auto mt-5 max-w-screen-md text-center text-xl text-muted-foreground">
{description}
</div>
{buttons && (
<div className="mt-8 flex justify-center gap-x-5 gap-y-3 max-sm:flex-col">
{buttons}
</div>
)}
</>
);

View file

@ -1,51 +0,0 @@
'use client';
import Link from 'next/link';
import { ToggleMenuButton } from '@/components/ToggleMenuButton';
import { useMenu } from '@/hooks/useMenu';
import { cn } from '@/lib/utils';
type CenteredMenuProps = {
logo: React.ReactNode;
rightMenu: React.ReactNode;
children?: React.ReactNode;
};
export const CenteredMenu = ({
logo,
rightMenu,
children,
}: CenteredMenuProps) => {
const { showMenu, handleToggleMenu } = useMenu();
const navClass = cn('max-lg:w-full max-lg:bg-secondary max-lg:p-5', {
'max-lg:hidden': !showMenu,
});
return (
<div className="flex flex-wrap items-center justify-between">
<Link href="/">{logo}</Link>
<div className="lg:hidden [&_button:hover]:opacity-100 [&_button]:opacity-60">
<ToggleMenuButton onClick={handleToggleMenu} />
</div>
{children && (
<nav className={cn('rounded-t max-lg:mt-2', navClass)}>
<ul className="flex gap-x-6 gap-y-1 text-lg font-medium max-lg:flex-col [&_a:hover]:opacity-100 [&_a]:opacity-60 max-lg:[&_a]:inline-block max-lg:[&_a]:w-full">
{children}
</ul>
</nav>
)}
<div
className={cn(
'rounded-b max-lg:border-t max-lg:border-border',
navClass,
)}
>
<ul className="flex flex-row items-center gap-x-1.5 text-lg font-medium [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
{rightMenu}
</ul>
</div>
</div>
);
};

View file

@ -1,17 +0,0 @@
export const FeatureCard = (props: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) => (
<div className="rounded-xl border border-border bg-card p-5">
<div className="size-12 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-400 to-pink-400 p-2 [&_svg]:stroke-white [&_svg]:stroke-2">
{props.icon}
</div>
<div className="mt-2 text-lg font-bold">{props.title}</div>
<div className="my-3 w-8 border-t border-purple-400" />
<div className="mt-2 text-muted-foreground">{props.children}</div>
</div>
);

View file

@ -0,0 +1,148 @@
import { useTranslations } from 'next-intl';
import { Section } from '@/components/layout';
export const Features = () => {
const t = useTranslations('Features');
return (
<Section className="bg-secondary">
<div className="mx-auto mb-12 max-w-screen-md text-center">
<div className="bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 bg-clip-text text-sm font-bold text-transparent">
{t('section_subtitle')}
</div>
<div className="mt-1 text-3xl font-bold">{t('section_title')}</div>
<div className="mt-2 text-lg text-muted-foreground">
{t('section_description')}
</div>
</div>
<div className="grid grid-cols-1 gap-x-3 gap-y-8 md:grid-cols-3">
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature1_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature2_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature3_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature4_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature5_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature6_title')}
>
{t('feature_description')}
</FeatureCard>
</div>
</Section>
);
};
type FeatureCardProps = {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
};
const FeatureCard = ({ icon, title, children }: FeatureCardProps) => (
<div className="rounded-xl border border-border bg-card p-5">
<div className="size-12 rounded-lg bg-gradient-to-br from-indigo-400 via-purple-400 to-pink-400 p-2 [&_svg]:stroke-white [&_svg]:stroke-2">
{icon}
</div>
<div className="mt-2 text-lg font-bold">{title}</div>
<div className="my-3 w-8 border-t border-purple-400" />
<div className="mt-2 text-muted-foreground">{children}</div>
</div>
);

View file

@ -1,23 +1,24 @@
import { useTranslations } from 'next-intl';
import { CenteredHero } from '@/components/landing/CenteredHero';
import { Section } from '@/components/landing/Section';
import { Section } from '@/components/layout/Section';
export const Hero = () => {
const t = useTranslations('Hero');
return (
<Section className="py-36">
<CenteredHero
title={t.rich('title', {
<div className="mt-3 text-center text-5xl font-bold tracking-tight">
{t.rich('title', {
important: (chunks) => (
<span className="bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 bg-clip-text text-transparent">
{chunks}
</span>
),
})}
description={t('description')}
/>
</div>
<div className="mx-auto mt-5 max-w-screen-md text-center text-xl text-muted-foreground">
{t('description')}
</div>
</Section>
);
};

View file

@ -1,33 +0,0 @@
import { cn } from '@/lib/utils';
export const Section = (props: {
children: React.ReactNode;
title?: string;
subtitle?: string;
description?: string;
className?: string;
}) => (
<div className={cn('px-3 py-16', props.className)}>
{(props.title || props.subtitle || props.description) && (
<div className="mx-auto mb-12 max-w-screen-md text-center">
{props.subtitle && (
<div className="bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 bg-clip-text text-sm font-bold text-transparent">
{props.subtitle}
</div>
)}
{props.title && (
<div className="mt-1 text-3xl font-bold">{props.title}</div>
)}
{props.description && (
<div className="mt-2 text-lg text-muted-foreground">
{props.description}
</div>
)}
</div>
)}
<div className="mx-auto max-w-screen-lg">{props.children}</div>
</div>
);

View file

@ -0,0 +1,2 @@
export { Hero } from './Hero';
export { Features } from './Features';

View file

@ -0,0 +1,52 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Section } from '@/components/layout';
import { APP_NAME } from '@/lib/constants';
export const Footer = () => {
const t = useTranslations('Footer');
return (
<Section>
<div className="flex flex-col items-center text-center">
<ul className="mt-4 flex flex-row gap-x-5 text-muted-foreground [&_svg:hover]:text-primary [&_svg:hover]:opacity-100 [&_svg]:size-5 [&_svg]:fill-current [&_svg]:opacity-60">
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.954 4.569a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.691 8.094 4.066 6.13 1.64 3.161a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.061a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.937 4.937 0 004.604 3.417 9.868 9.868 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.054 0 13.999-7.496 13.999-13.986 0-.209 0-.42-.015-.63a9.936 9.936 0 002.46-2.548l-.047-.02z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</Link>
</li>
</ul>
<div className="mt-6 flex w-full items-center justify-between gap-y-2 border-t pt-3 text-sm text-muted-foreground max-md:flex-col">
<div>
© Copyright {new Date().getFullYear()} {APP_NAME}.
</div>
<ul className="flex gap-x-4 font-medium [&_a:hover]:opacity-100 [&_a]:opacity-60">
<li>
<Link href="/sign-up">{t('terms_of_service')}</Link>
</li>
<li>
<Link href="/sign-up">{t('privacy_policy')}</Link>
</li>
</ul>
</div>
</div>
</Section>
);
};

View file

@ -9,10 +9,10 @@ import { setLocale } from '@/actions/locale';
import {
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui';
import { Globe } from 'lucide-react';

View file

@ -1,5 +1,5 @@
import { APP_NAME } from '@/lib/constants';
export const Logo = () => (
<div className="flex items-center text-xl font-semibold">{APP_NAME}</div>
<div className="text-lg font-semibold">{APP_NAME}</div>
);

View file

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui';
import { Section, LocaleSwitcher, ThemeSwitcher } from '@/components/layout';
import { Logo } from './Logo';
export const Navbar = () => {
const t = useTranslations('Navbar');
return (
<Section className="py-6">
<div className="flex flex-wrap items-center justify-between">
<Link href="/">
<Logo />
</Link>
<ul className="flex flex-row items-center gap-x-1.5 text-lg font-medium [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
<li data-fade>
<LocaleSwitcher />
</li>
<li data-fade>
<ThemeSwitcher />
</li>
<li>
<Button variant="ghost" asChild>
<Link href="/sign-in">{t('sign_in')}</Link>
</Button>
</li>
<li>
<Button asChild>
<Link href="/sign-up">{t('sign_up')}</Link>
</Button>
</li>
</ul>
</div>
</Section>
);
};

View file

@ -0,0 +1,9 @@
import { cn } from '@/lib/utils';
type SectionProps = React.HTMLAttributes<HTMLDivElement>;
export const Section = ({ className, children, ...rest }: SectionProps) => (
<div className={cn('px-4 py-16', className)} {...rest}>
<div className="mx-auto max-w-screen-lg">{children}</div>
</div>
);

View file

@ -6,13 +6,14 @@ import { useTheme } from 'next-themes';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from '@/components/ui';
export function ThemeSwitcher() {
const { setTheme } = useTheme();
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
@ -23,15 +24,11 @@ export function ThemeSwitcher() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -0,0 +1,6 @@
export { Navbar } from './Navbar';
export { Footer } from './Footer';
export { Section } from './Section';
export { LocaleSwitcher } from './LocaleSwitcher';
export { ThemeSwitcher } from './ThemeSwitcher';
export { Logo } from './Logo';

View file

@ -1,129 +0,0 @@
import { useTranslations } from 'next-intl';
import { FeatureCard } from '@/components/landing/FeatureCard';
import { Section } from '@/components/landing/Section';
export const Features = () => {
const t = useTranslations('Features');
return (
<div className="w-full bg-secondary">
<Section
subtitle={t('section_subtitle')}
title={t('section_title')}
description={t('section_description')}
>
<div className="grid grid-cols-1 gap-x-3 gap-y-8 md:grid-cols-3">
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature1_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature2_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature3_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature4_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature5_title')}
>
{t('feature_description')}
</FeatureCard>
<FeatureCard
icon={
<svg
className="stroke-primary-foreground stroke-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M0 0h24v24H0z" stroke="none" />
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</svg>
}
title={t('feature6_title')}
>
{t('feature_description')}
</FeatureCard>
</div>
</Section>
</div>
);
};

View file

@ -1,51 +0,0 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { CenteredFooter } from '@/components/landing/CenteredFooter';
import { Section } from '@/components/landing/Section';
export const Footer = () => {
const t = useTranslations('Footer');
return (
<Section className="pb-16 pt-0">
<CenteredFooter
iconList={
<>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.954 4.569a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.691 8.094 4.066 6.13 1.64 3.161a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.061a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.937 4.937 0 004.604 3.417 9.868 9.868 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.054 0 13.999-7.496 13.999-13.986 0-.209 0-.42-.015-.63a9.936 9.936 0 002.46-2.548l-.047-.02z" />
</svg>
</Link>
</li>
<li>
<Link href="/">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</Link>
</li>
</>
}
legalLinks={
<>
<li>
<Link href="/sign-up">{t('terms_of_service')}</Link>
</li>
<li>
<Link href="/sign-up">{t('privacy_policy')}</Link>
</li>
</>
}
/>
</Section>
);
};

View file

@ -1,40 +0,0 @@
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { CenteredMenu } from '@/components/landing/CenteredMenu';
import { Section } from '@/components/landing/Section';
import { Logo } from './Logo';
export const Navbar = () => {
const t = useTranslations('Navbar');
return (
<Section className="px-3 py-6">
<CenteredMenu
logo={<Logo />}
rightMenu={
<>
<li data-fade>
<LocaleSwitcher />
</li>
<li data-fade>
<ThemeSwitcher />
</li>
<li className="ml-1 mr-2.5" data-fade>
<Link href="/sign-in">{t('sign_in')}</Link>
</li>
<li>
<Button asChild>
<Link href="/sign-up">{t('sign_up')}</Link>
</Button>
</li>
</>
}
/>
</Section>
);
};

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{
variants: {
variant: {

View file

@ -74,7 +74,8 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'cursor-pointer',
className,
)}
{...props}
@ -92,7 +93,8 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'cursor-pointer',
className,
)}
checked={checked}
@ -128,7 +130,8 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'cursor-pointer',
className,
)}
{...props}
@ -211,7 +214,8 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
'cursor-pointer',
className,
)}
{...props}

View file

@ -1,4 +1,4 @@
import { Env } from '../lib/server/Env';
import { Env } from '../lib/server/env';
import { drizzle } from 'drizzle-orm/node-postgres';

View file

@ -1,44 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useMenu } from './useMenu';
describe('UseMenu', () => {
describe('Render hook', () => {
it("shouldn't show the menu by default", async () => {
const { result } = renderHook(() => useMenu());
expect(result.current.showMenu).toBeFalsy();
});
it('should make the menu visible by toggling the menu', () => {
const { result } = renderHook(() => useMenu());
act(() => {
result.current.handleToggleMenu();
});
expect(result.current.showMenu).toBeTruthy();
});
it("shouldn't make the menu visible after toggling and closing the menu", () => {
const { result } = renderHook(() => useMenu());
act(() => {
result.current.handleClose();
});
expect(result.current.showMenu).toBeFalsy();
});
it("shouldn't make the menu visible after toggling the menu twice", () => {
const { result } = renderHook(() => useMenu());
act(() => {
result.current.handleToggleMenu();
result.current.handleToggleMenu();
});
expect(result.current.showMenu).toBeFalsy();
});
});
});

View file

@ -1,19 +0,0 @@
import { useState } from 'react';
/**
* React Hook to toggle element. Mostly used for responsive menu.
* @hook
*/
export const useMenu = () => {
const [showMenu, setShowMenu] = useState(false);
const handleToggleMenu = () => {
setShowMenu((prevState) => !prevState);
};
const handleClose = () => {
setShowMenu(false);
};
return { showMenu, handleToggleMenu, handleClose };
};

View file

@ -3,7 +3,7 @@ import logtail from '@logtail/pino';
import pino from 'pino';
import pretty from 'pino-pretty';
import { Env } from './Env';
import { Env } from './env';
let stream: DestinationStream;
@ -13,7 +13,7 @@ if (Env.LOGTAIL_SOURCE_TOKEN) {
sourceToken: Env.LOGTAIL_SOURCE_TOKEN,
options: { sendLogsToBetterStack: true },
}),
{ stream: pretty() }, // Prints logs to the console
{ stream: pretty() }, // Prints logs to the console.
]);
} else {
stream = pretty({ colorize: true });